feat: live service status dots, +4 services, richer markets widget
This commit is contained in:
parent
cb188d3b9b
commit
dfbb7ef16d
1 changed files with 72 additions and 24 deletions
96
index.html
96
index.html
|
|
@ -59,6 +59,7 @@ const SECTIONS=[
|
|||
{id:"miniflux",name:"Miniflux",desc:"RSS reader",emoji:"📰",url:"https://feeds.manohargupta.com"},
|
||||
{id:"changedetect",name:"ChangeDetection",desc:"Page change monitor",emoji:"👁",url:"https://watch.manohargupta.com"},
|
||||
{id:"openwebui",name:"Open WebUI",desc:"AI chat interface",emoji:"💬",url:"https://chat.manohargupta.com"},
|
||||
{id:"notes",name:"Notes",desc:"Obsidian vault portal",emoji:"🪶",url:"https://notes.manohargupta.com"},
|
||||
]},
|
||||
{id:"dev",label:"Dev",services:[
|
||||
{id:"forgejo",name:"Forgejo",desc:"Self-hosted Git",emoji:"🌿",url:"https://git.manohargupta.com"},
|
||||
|
|
@ -70,6 +71,11 @@ const SECTIONS=[
|
|||
{id:"tiger",name:"Tiger Agent",desc:"AI orchestration",emoji:"🐯",url:"https://agent.manohargupta.com"},
|
||||
{id:"ladder",name:"Ladder",desc:"Paywall bypass · CORS proxy",emoji:"🪜",url:"https://ladder.manohargupta.com"},
|
||||
{id:"angel",name:"Angel",desc:"Portfolio tracker",emoji:"<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 3v18h18' stroke='#6b7280'/><line x1='8.5' y1='2' x2='8.5' y2='22' stroke='#6b7280' stroke-width='1' opacity='0.3'/><line x1='13.5' y1='2' x2='13.5' y2='22' stroke='#6b7280' stroke-width='1' opacity='0.3'/><line x1='18.5' y1='2' x2='18.5' y2='22' stroke='#6b7280' stroke-width='1' opacity='0.3'/><rect x='7' y='10' width='3' height='8' fill='none'/><path d='M8.5 7 L8.5 10' stroke='#22c55e' stroke-width='2'/><path d='M8.5 18 L8.5 21' stroke='#22c55e' stroke-width='2'/><rect x='7' y='10' width='3' height='8' stroke='#22c55e'/><rect x='12' y='8' width='3' height='10' fill='none'/><path d='M13.5 4 L13.5 8' stroke='#22c55e' stroke-width='2'/><path d='M13.5 18 L13.5 22' stroke='#22c55e' stroke-width='2'/><rect x='12' y='8' width='3' height='10' stroke='#22c55e' fill='#22c55e' fill-opacity='0.2'/><rect x='17' y='12' width='3' height='6' fill='none'/><path d='M18.5 9 L18.5 12' stroke='#ef4444' stroke-width='2'/><path d='M18.5 18 L18.5 21' stroke='#ef4444' stroke-width='2'/><rect x='17' y='12' width='3' height='6' stroke='#ef4444' fill='#ef4444' fill-opacity='0.2'/></svg>",url:"https://angel.manohargupta.com"},
|
||||
{id:"remodel",name:"REmodel",desc:"RE project finance model",emoji:"☀️",url:"https://model.manohargupta.com"},
|
||||
]},
|
||||
{id:"projects",label:"Projects",services:[
|
||||
{id:"tia",name:"TIA",desc:"Baby growth tracker",emoji:"👶",url:"https://tia.manohargupta.com"},
|
||||
{id:"awadhgully",name:"Awadhgully",desc:"Storefront · Vendure",emoji:"🛍",url:"https://awadhgully.com"},
|
||||
]},
|
||||
];
|
||||
|
||||
|
|
@ -88,7 +94,9 @@ function useTime(){const[n,s]=useState(new Date());useEffect(()=>{const t=setInt
|
|||
function greet(h){return h<12?"Good morning":h<17?"Good afternoon":"Good evening"}
|
||||
|
||||
function Dot({status}){
|
||||
const c={online:"#22c55e",running:"#22c55e",warning:"#f59e0b",offline:"#ef4444",exited:"#ef4444"}[status]||"#94a3b8";
|
||||
// "degraded" = reachable but 5xx. "unknown"/undefined = grey, meaning the API
|
||||
// hasn't reported on this id yet — never assume green.
|
||||
const c={online:"#22c55e",running:"#22c55e",warning:"#f59e0b",degraded:"#f59e0b",offline:"#ef4444",exited:"#ef4444"}[status]||"#94a3b8";
|
||||
return <span style={{display:"inline-block",width:7,height:7,borderRadius:"50%",background:c,flexShrink:0,"--pulse-c":c+"55",animation:(status==="online"||status==="running")?"pulse 2s ease infinite":"none"}}/>;
|
||||
}
|
||||
|
||||
|
|
@ -176,40 +184,67 @@ function TigerTasks({data}){
|
|||
|
||||
function Markets({data}){
|
||||
if(!data)return <Shell label="Markets"><span style={{color:"var(--c-muted)",fontSize:12}}>Loading...</span></Shell>;
|
||||
const live=data.nifty?.close!=null;
|
||||
const n=data.nifty||{}, x=data.iex_dam||{};
|
||||
const nOk=n.close!=null, xOk=x.weighted_avg_price_inr!=null;
|
||||
// Green/red on NIFTY change; IEX shows the day's volume-weighted MCP.
|
||||
const nc=(n.change_pct??0)>=0?"#22c55e":"#ef4444";
|
||||
const sign=(n.change_pct??0)>=0?"+":"";
|
||||
return(
|
||||
<Shell label="Markets">
|
||||
{live
|
||||
?<div style={{display:"flex",gap:16,marginTop:6}}>
|
||||
<div>
|
||||
<Shell label="Markets" right={n.market_open===true
|
||||
? <span style={{fontSize:10,fontWeight:600,background:"#22c55e22",color:"#22c55e",padding:"1px 5px",borderRadius:3}}>LIVE</span>
|
||||
: <span style={{fontSize:10,color:"var(--c-muted)"}}>closed</span>}>
|
||||
{(nOk||xOk)
|
||||
?<div style={{display:"flex",gap:14,marginTop:6}}>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<div style={{fontSize:11,color:"var(--c-muted)"}}>NIFTY 50</div>
|
||||
<div style={{fontFamily:"'DM Mono',monospace",fontSize:16,fontWeight:500,color:"var(--c-text)"}}>{data.nifty.close?.toLocaleString()}</div>
|
||||
{nOk
|
||||
?<>
|
||||
<div style={{fontFamily:"'DM Mono',monospace",fontSize:16,fontWeight:500,color:"var(--c-text)"}}>{n.close.toLocaleString("en-IN")}</div>
|
||||
<div style={{fontSize:10,color:nc,fontFamily:"'DM Mono',monospace"}}>{sign}{n.change_pts?.toFixed(2)} ({sign}{n.change_pct?.toFixed(2)}%)</div>
|
||||
</>
|
||||
:<div style={{fontSize:12,color:"var(--c-muted)"}}>—</div>}
|
||||
</div>
|
||||
<div style={{width:1,background:"var(--c-border)"}}/>
|
||||
<div>
|
||||
<div style={{fontSize:11,color:"var(--c-muted)"}}>IEX DAM</div>
|
||||
<div style={{fontFamily:"'DM Mono',monospace",fontSize:16,fontWeight:500,color:"var(--c-text)"}}>₹{data.iex_dam?.weighted_avg_price_inr??"—"}</div>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<div style={{fontSize:11,color:"var(--c-muted)"}}>IEX DAM · avg</div>
|
||||
{xOk
|
||||
?<>
|
||||
<div style={{fontFamily:"'DM Mono',monospace",fontSize:16,fontWeight:500,color:"var(--c-text)"}}>₹{x.weighted_avg_price_inr.toLocaleString("en-IN")}</div>
|
||||
<div style={{fontSize:10,color:"var(--c-muted)",fontFamily:"'DM Mono',monospace"}}>₹{x.min_price_inr}–{x.max_price_inr} · {x.total_volume_mu} MU</div>
|
||||
</>
|
||||
:<div style={{fontSize:12,color:"var(--c-muted)"}}>—</div>}
|
||||
</div>
|
||||
</div>
|
||||
:<div style={{fontSize:12,color:"var(--c-muted)",fontStyle:"italic",marginTop:4}}>📡 Data pipeline coming soon</div>
|
||||
:<div style={{fontSize:12,color:"var(--c-muted)",fontStyle:"italic",marginTop:4}}>⚠️ Sources unreachable</div>
|
||||
}
|
||||
{xOk&&<div style={{fontSize:10,color:"var(--c-muted)",marginTop:5,fontFamily:"'DM Mono',monospace"}}>Rs/MWh · DAM {x.as_of} · {x.blocks_parsed} blocks</div>}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({val,lbl}){
|
||||
function StatCell({val,lbl,alert}){
|
||||
const[h,sH]=useState(false);
|
||||
// `alert` overrides the normal text colour so a threshold breach is visible
|
||||
// at a glance. A number you look at daily that never changes colour is a
|
||||
// number you stop reading.
|
||||
const col=alert||(h?"var(--c-accent)":"var(--c-text)");
|
||||
return(
|
||||
<div className='pill-cell' onMouseEnter={()=>sH(true)} onMouseLeave={()=>sH(false)}
|
||||
style={{textAlign:"center",padding:"8px 20px",background:h?"var(--c-accent-faint)":"transparent",transition:"background 0.15s",cursor:"default"}}>
|
||||
<div className='pill-val' style={{fontFamily:"'DM Mono',monospace",fontWeight:500,fontSize:15,color:h?"var(--c-accent)":"var(--c-text)",transition:"color 0.15s"}}>{val}</div>
|
||||
<div className='pill-val' style={{fontFamily:"'DM Mono',monospace",fontWeight:500,fontSize:15,color:col,transition:"color 0.15s"}}>{val}</div>
|
||||
<div className='pill-lbl' style={{fontSize:11,color:h?"var(--c-accent)":"var(--c-muted)",marginTop:2,letterSpacing:"0.06em",textTransform:"uppercase",transition:"color 0.15s"}}>{lbl}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({svc}){
|
||||
function Card({svc,health}){
|
||||
const[h,sH]=useState(false);
|
||||
// health is {status, code, ms} from /api/all -> health.services[svc.id],
|
||||
// or undefined if the API is unreachable / this id isn't probed yet.
|
||||
const st=health?.status;
|
||||
const tip=health
|
||||
? `${st}${health.code?` · HTTP ${health.code}`:""}${health.ms!=null?` · ${health.ms}ms`:""}`
|
||||
: "status unknown";
|
||||
return(
|
||||
<div onClick={()=>window.open(svc.url,"_blank")} onMouseEnter={()=>sH(true)} onMouseLeave={()=>sH(false)}
|
||||
style={{background:h?"var(--c-surface2)":"var(--c-surface)",border:`1px solid ${h?"var(--c-accent)":"var(--c-border)"}`,borderRadius:4,padding:"14px 16px",cursor:"pointer",transform:h?"translateY(-1px)":"none",boxShadow:h?"0 4px 16px rgba(0,0,0,0.08)":"none",transition:"all 0.15s",display:"flex",alignItems:"center",gap:12,position:"relative"}}>
|
||||
|
|
@ -220,21 +255,28 @@ function Card({svc}){
|
|||
<div style={{fontWeight:500,fontSize:14,color:"var(--c-text)"}}>{svc.name}</div>
|
||||
<div style={{fontSize:12,color:"var(--c-muted)",marginTop:2,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{svc.desc}</div>
|
||||
</div>
|
||||
<Dot status="online"/>
|
||||
<span title={tip} style={{display:"flex",alignItems:"center"}}><Dot status={st}/></span>
|
||||
{h&&<div style={{position:"absolute",right:10,top:10,fontSize:10,fontWeight:600,color:"var(--c-accent)",letterSpacing:"0.04em"}}>OPEN →</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({sec}){
|
||||
function Section({sec,health}){
|
||||
const[open,setOpen]=useState(true);
|
||||
// Surface faults on the section header too — otherwise collapsing a section
|
||||
// hides the very thing you need to see.
|
||||
const bad=sec.services.filter(s=>{
|
||||
const st=health?.[s.id]?.status;
|
||||
return st==="offline"||st==="degraded";
|
||||
}).length;
|
||||
return(
|
||||
<div style={{marginBottom:28}}>
|
||||
<button onClick={()=>setOpen(o=>!o)} style={{display:"flex",alignItems:"center",width:"100%",background:"none",border:"none",cursor:"pointer",padding:"0 0 10px",gap:8,fontFamily:"inherit"}}>
|
||||
<span style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",textTransform:"uppercase",color:"var(--c-muted)"}}>{sec.label}</span>
|
||||
{bad>0&&<span style={{fontSize:10,fontWeight:600,background:"#ef444422",color:"#ef4444",padding:"1px 6px",borderRadius:3}}>{bad} down</span>}
|
||||
<span style={{marginLeft:"auto",fontSize:12,color:"var(--c-muted)",display:"inline-block",transition:"transform 0.2s",transform:open?"rotate(0deg)":"rotate(-90deg)"}}>▾</span>
|
||||
</button>
|
||||
{open&&<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(200px, 1fr))",gap:10}}>{sec.services.map(s=><Card key={s.id} svc={s}/>)}</div>}
|
||||
{open&&<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(200px, 1fr))",gap:10}}>{sec.services.map(s=><Card key={s.id} svc={s} health={health?.[s.id]}/>)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -282,11 +324,17 @@ function App(){
|
|||
},[dark]);
|
||||
|
||||
const srv=live?.server||{};
|
||||
// Threshold colours: amber = worth noticing, red = act now.
|
||||
const AMBER="#f59e0b", RED="#ef4444";
|
||||
const dSt=srv.disk?.status; // "ok" | "warning" | "critical"
|
||||
const stats=[
|
||||
[srv.cpu_percent!=null?`${srv.cpu_percent}%`:"—","CPU"],
|
||||
[srv.ram_used_gb!=null?`${srv.ram_used_gb} GB`:"—","RAM used"],
|
||||
[srv.disk?.free_gb!=null?`${srv.disk.free_gb} GB`:"—","Disk free"],
|
||||
[srv.uptime_days!=null?`${srv.uptime_days}d`:"—","Uptime"],
|
||||
[srv.cpu_percent!=null?`${srv.cpu_percent}%`:"—","CPU",
|
||||
srv.cpu_percent>=90?RED:srv.cpu_percent>=70?AMBER:null],
|
||||
[srv.ram_used_gb!=null?`${srv.ram_used_gb} GB`:"—","RAM used",
|
||||
srv.ram_percent>=90?RED:srv.ram_percent>=80?AMBER:null],
|
||||
[srv.disk?.free_gb!=null?`${srv.disk.free_gb} GB`:"—","Disk free",
|
||||
dSt==="critical"?RED:dSt==="warning"?AMBER:null],
|
||||
[srv.uptime_days!=null?`${srv.uptime_days}d`:"—","Uptime",null],
|
||||
];
|
||||
|
||||
return(
|
||||
|
|
@ -311,9 +359,9 @@ function App(){
|
|||
</div>
|
||||
</div>
|
||||
<div style={{display:"flex",alignItems:"stretch",background:"var(--c-surface)",border:"1px solid var(--c-border)",borderRadius:999,overflow:"hidden",boxShadow:"0 1px 4px rgba(0,0,0,0.06)",alignSelf:"center"}}>
|
||||
{stats.map(([val,lbl],i,arr)=>(
|
||||
{stats.map(([val,lbl,alert],i,arr)=>(
|
||||
<React.Fragment key={lbl}>
|
||||
<StatCell val={val} lbl={lbl}/>
|
||||
<StatCell val={val} lbl={lbl} alert={alert}/>
|
||||
{i<arr.length-1&&<div style={{width:1,background:"var(--c-border)",margin:"8px 0"}}/>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
|
@ -328,7 +376,7 @@ function App(){
|
|||
<Markets data={live?.markets}/>
|
||||
</div>
|
||||
|
||||
{SECTIONS.map(s=><Section key={s.id} sec={s}/>)}
|
||||
{SECTIONS.map(s=><Section key={s.id} sec={s} health={live?.health?.services}/>)}
|
||||
|
||||
<div style={{marginTop:8}}>
|
||||
<button onClick={()=>setQlOpen(o=>!o)} style={{display:"flex",alignItems:"center",width:"100%",background:"none",border:"none",cursor:"pointer",padding:"0 0 12px",gap:8,fontFamily:"inherit"}}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue