feat: live service status dots, +4 services, richer markets widget

This commit is contained in:
Manohar Gupta 2026-08-13 02:13:53 +05:30
parent cb188d3b9b
commit dfbb7ef16d

View file

@ -59,6 +59,7 @@ const SECTIONS=[
{id:"miniflux",name:"Miniflux",desc:"RSS reader",emoji:"📰",url:"https://feeds.manohargupta.com"}, {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:"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:"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:"dev",label:"Dev",services:[
{id:"forgejo",name:"Forgejo",desc:"Self-hosted Git",emoji:"🌿",url:"https://git.manohargupta.com"}, {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:"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:"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:"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 greet(h){return h<12?"Good morning":h<17?"Good afternoon":"Good evening"}
function Dot({status}){ 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"}}/>; 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}){ function Markets({data}){
if(!data)return <Shell label="Markets"><span style={{color:"var(--c-muted)",fontSize:12}}>Loading...</span></Shell>; 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( return(
<Shell label="Markets"> <Shell label="Markets" right={n.market_open===true
{live ? <span style={{fontSize:10,fontWeight:600,background:"#22c55e22",color:"#22c55e",padding:"1px 5px",borderRadius:3}}>LIVE</span>
?<div style={{display:"flex",gap:16,marginTop:6}}> : <span style={{fontSize:10,color:"var(--c-muted)"}}>closed</span>}>
<div> {(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={{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>
<div style={{width:1,background:"var(--c-border)"}}/> <div style={{width:1,background:"var(--c-border)"}}/>
<div> <div style={{flex:1,minWidth:0}}>
<div style={{fontSize:11,color:"var(--c-muted)"}}>IEX DAM</div> <div style={{fontSize:11,color:"var(--c-muted)"}}>IEX DAM · avg</div>
<div style={{fontFamily:"'DM Mono',monospace",fontSize:16,fontWeight:500,color:"var(--c-text)"}}>₹{data.iex_dam?.weighted_avg_price_inr??"—"}</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> </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> </Shell>
); );
} }
function StatCell({val,lbl}){ function StatCell({val,lbl,alert}){
const[h,sH]=useState(false); 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( return(
<div className='pill-cell' onMouseEnter={()=>sH(true)} onMouseLeave={()=>sH(false)} <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"}}> 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 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> </div>
); );
} }
function Card({svc}){ function Card({svc,health}){
const[h,sH]=useState(false); 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( return(
<div onClick={()=>window.open(svc.url,"_blank")} onMouseEnter={()=>sH(true)} onMouseLeave={()=>sH(false)} <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"}}> 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={{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 style={{fontSize:12,color:"var(--c-muted)",marginTop:2,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{svc.desc}</div>
</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>} {h&&<div style={{position:"absolute",right:10,top:10,fontSize:10,fontWeight:600,color:"var(--c-accent)",letterSpacing:"0.04em"}}>OPEN →</div>}
</div> </div>
); );
} }
function Section({sec}){ function Section({sec,health}){
const[open,setOpen]=useState(true); 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( return(
<div style={{marginBottom:28}}> <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"}}> <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> <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> <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> </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> </div>
); );
} }
@ -282,11 +324,17 @@ function App(){
},[dark]); },[dark]);
const srv=live?.server||{}; 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=[ const stats=[
[srv.cpu_percent!=null?`${srv.cpu_percent}%`:"—","CPU"], [srv.cpu_percent!=null?`${srv.cpu_percent}%`:"—","CPU",
[srv.ram_used_gb!=null?`${srv.ram_used_gb} GB`:"—","RAM used"], srv.cpu_percent>=90?RED:srv.cpu_percent>=70?AMBER:null],
[srv.disk?.free_gb!=null?`${srv.disk.free_gb} GB`:"—","Disk free"], [srv.ram_used_gb!=null?`${srv.ram_used_gb} GB`:"—","RAM used",
[srv.uptime_days!=null?`${srv.uptime_days}d`:"—","Uptime"], 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( return(
@ -311,9 +359,9 @@ function App(){
</div> </div>
</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"}}> <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}> <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"}}/>} {i<arr.length-1&&<div style={{width:1,background:"var(--c-border)",margin:"8px 0"}}/>}
</React.Fragment> </React.Fragment>
))} ))}
@ -328,7 +376,7 @@ function App(){
<Markets data={live?.markets}/> <Markets data={live?.markets}/>
</div> </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}}> <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"}}> <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"}}>