Spaces:
Sleeping
Sleeping
| import { useEffect, useState } from 'react'; | |
| import * as api from '../api'; | |
| import type { ProviderUsage, QuotaWindow, Traces, TraceStats } from '../api'; | |
| import Logo from './Logo'; | |
| const PROVS = [ | |
| { id: 'claude', label: 'Claude Code', color: '#d97757' }, | |
| { id: 'codex', label: 'Codex', color: '#5eb6a6' }, | |
| { id: 'gemini', label: 'Gemini CLI', color: '#4796e3' }, | |
| ]; | |
| const fmtTok = (n = 0) => | |
| n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n); | |
| const resetStr = (s?: number) => { | |
| if (!s) return ''; | |
| const mins = Math.round((s * 1000 - Date.now()) / 60000); | |
| if (mins <= 0) return 'resetting'; | |
| if (mins < 60) return `resets in ${mins}m`; | |
| return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`; | |
| }; | |
| const fmtAgo = (ts: number) => { | |
| if (!ts) return '—'; | |
| const m = Math.round((Date.now() - ts) / 60000); | |
| if (m < 1) return 'now'; | |
| if (m < 60) return `${m}m ago`; | |
| if (m < 48 * 60) return `${Math.round(m / 60)}h ago`; | |
| return `${Math.round(m / 1440)}d ago`; | |
| }; | |
| const topTools = (tools: Record<string, number>) => | |
| Object.entries(tools).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k} ${v}`).join(' · '); | |
| function Bar({ label, q }: { label: string; q?: QuotaWindow }) { | |
| if (!q || q.usedPercent == null) return null; | |
| const pct = Math.max(0, Math.min(100, Math.round(q.usedPercent))); | |
| return ( | |
| <div className="qrow"> | |
| <span className="qlabel">{label}</span> | |
| <div className="qbar"><div className="qfill" style={{ width: `${pct}%` }} /></div> | |
| <span className="qpct">{pct}%<span className="s-muted"> {resetStr(q.resetsAt)}</span></span> | |
| </div> | |
| ); | |
| } | |
| function TraceRow({ label, cli, path, st, strong }: { label: string; cli?: string; path?: string | null; st: TraceStats; strong?: boolean }) { | |
| const cachePct = st.tokensIn + st.cacheRead > 0 ? Math.round((st.cacheRead / (st.tokensIn + st.cacheRead)) * 100) : 0; | |
| return ( | |
| <tr className={strong ? 'tr-total' : undefined}> | |
| <td className="tr-agent" title={path || undefined}> | |
| {cli && <Logo cli={cli} size={12} />} | |
| <span>{label}</span> | |
| </td> | |
| <td>{st.turns}</td> | |
| <td>{st.prompts}</td> | |
| <td title={topTools(st.tools) || undefined}>{st.toolCalls}</td> | |
| <td>{st.web}</td> | |
| <td title={`${cachePct}% served from cache (${fmtTok(st.cacheRead)} cached)`}>{fmtTok(st.tokensIn)}</td> | |
| <td>{fmtTok(st.tokensOut)}</td> | |
| <td className="tr-when">{fmtAgo(st.lastTs)}</td> | |
| </tr> | |
| ); | |
| } | |
| export default function UsagePanel() { | |
| // Each provider loads independently (undefined = loading, null = failed), so | |
| // the page frame paints immediately and cards fill in as answers arrive — | |
| // one hung provider no longer blanks the whole page. | |
| const [prov, setProv] = useState<Record<string, ProviderUsage | null | undefined>>({}); | |
| const [t, setT] = useState<Traces | null>(null); | |
| useEffect(() => { | |
| let alive = true; | |
| for (const p of PROVS) { | |
| api.getUsage(p.id) | |
| .then((r) => { if (alive) setProv((m) => ({ ...m, [p.id]: r.providers[p.id] || {} })); }) | |
| .catch(() => { if (alive) setProv((m) => ({ ...m, [p.id]: null })); }); | |
| } | |
| api.getTraces().then((r) => { if (alive) setT(r); }).catch(() => {}); | |
| return () => { alive = false; }; | |
| }, []); | |
| return ( | |
| <div className="usage"> | |
| {PROVS.map((p) => { | |
| const d = prov[p.id]; | |
| const q = d?.quota; | |
| return ( | |
| <div key={p.id} className="usage-card"> | |
| <div className="usage-head"> | |
| <span className="status" style={{ background: p.color }} /> | |
| <b>{p.label}</b> | |
| </div> | |
| {d === undefined ? ( | |
| <> | |
| <div className="usage-stats"> | |
| <div><span className="s-muted">Today</span><span className="skel" style={{ width: 64, height: 12, marginTop: 4 }} /></div> | |
| <div><span className="s-muted">This week</span><span className="skel" style={{ width: 64, height: 12, marginTop: 4 }} /></div> | |
| </div> | |
| <div className="usage-quota"><span className="skel" style={{ width: '78%' }} /></div> | |
| </> | |
| ) : d === null ? ( | |
| <div className="s-help">unavailable — is ccusage installed?</div> | |
| ) : ( | |
| <> | |
| <div className="usage-stats"> | |
| <div><span className="s-muted">Today</span><b>{d.tokensToday == null ? '—' : `${fmtTok(d.tokensToday)} tok`}</b></div> | |
| <div><span className="s-muted">This week</span><b>{d.tokensWeek == null ? '—' : `${fmtTok(d.tokensWeek)} tok`}</b></div> | |
| </div> | |
| {q ? ( | |
| <div className="usage-quota"> | |
| <Bar label="5-hour" q={q.fiveHour} /> | |
| <Bar label="Weekly" q={q.weekly} /> | |
| {!q.fiveHour && !q.weekly && <div className="s-help">No quota yet — run a session to populate.</div>} | |
| </div> | |
| ) : p.id === 'gemini' ? ( | |
| <div className="s-help">No quota (consumer tier deprecated — uses an API key).</div> | |
| ) : ( | |
| <div className="s-help">No quota yet — run a session to populate.</div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| <h3>Traces</h3> | |
| <div className="s-help"> | |
| Parsed from every agent transcript stored on this Space (Claude, Codex, opencode, Hermes, OpenClaw) — | |
| hover the tools count for the breakdown, tokens-in for the cache share. The total also counts | |
| traces of deleted agents. | |
| </div> | |
| <div className="table-scroll"> | |
| <table className="traces-table"> | |
| <thead> | |
| <tr><th>agent</th><th>turns</th><th>prompts</th><th>tools</th><th>web</th><th>tok in</th><th>tok out</th><th>last active</th></tr> | |
| </thead> | |
| <tbody> | |
| {!t ? ( | |
| [0, 1, 2].map((i) => ( | |
| <tr key={i}><td colSpan={8}><span className="skel" style={{ width: `${86 - i * 14}%` }} /></td></tr> | |
| )) | |
| ) : ( | |
| <> | |
| {t.sessions.map((s) => <TraceRow key={s.id} label={s.name} cli={s.cli} path={s.path} st={s} />)} | |
| <TraceRow label={`total (${t.totals.files} files)`} st={t.totals} strong /> | |
| </> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="s-help"> | |
| Token counts and quota are read from each agent's local logs on the Space. They reflect the state as of that agent's <em>last model call here</em> — running a session updates them; activity outside the Space won't. | |
| </div> | |
| </div> | |
| ); | |
| } | |