File size: 6,794 Bytes
5b5da0e
 
e34ce4a
604c3c1
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604c3c1
 
 
 
 
 
 
 
 
 
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
604c3c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b5da0e
e34ce4a
 
 
 
604c3c1
 
e34ce4a
 
 
 
 
 
 
 
604c3c1
5b5da0e
 
 
 
e34ce4a
 
5b5da0e
 
 
a9cda9b
5b5da0e
 
e34ce4a
 
 
 
 
 
 
 
 
 
5b5da0e
e34ce4a
 
0dd286d
 
e34ce4a
 
 
 
 
 
 
 
 
 
 
 
 
5b5da0e
 
 
 
604c3c1
 
 
37775da
 
 
604c3c1
e34ce4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604c3c1
5b5da0e
43e2e47
5b5da0e
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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>
  );
}