Spaces:
Running
Running
Trace analytics on Usage page (turns/tools/web/tokens per agent), quiet loading states, X for sidebar delete, compact files header
Browse files- server/src/index.js +3 -0
- server/src/traces.js +222 -0
- web/src/api.ts +10 -0
- web/src/components/FilesPane.tsx +4 -10
- web/src/components/Sidebar.tsx +3 -3
- web/src/components/UsagePanel.tsx +61 -4
- web/src/styles.css +17 -3
server/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as groups from './groups.js';
|
|
| 14 |
import * as order from './order.js';
|
| 15 |
import { attach, agentInfo, deriveState, stop } from './runner.js';
|
| 16 |
import { buildUsage } from './usage.js';
|
|
|
|
| 17 |
import { startVisibilityWatch, isPublic, visibility } from './visibility.js';
|
| 18 |
|
| 19 |
ensureDirs();
|
|
@@ -90,6 +91,8 @@ app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
|
|
| 90 |
|
| 91 |
app.get('/api/usage', async (req, res) => res.json(await buildUsage(req.query.debug === '1')));
|
| 92 |
|
|
|
|
|
|
|
| 93 |
const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
|
| 94 |
|
| 95 |
// Env var names that existed at build time (baked in by the Dockerfile). Names
|
|
|
|
| 14 |
import * as order from './order.js';
|
| 15 |
import { attach, agentInfo, deriveState, stop } from './runner.js';
|
| 16 |
import { buildUsage } from './usage.js';
|
| 17 |
+
import { buildTraces } from './traces.js';
|
| 18 |
import { startVisibilityWatch, isPublic, visibility } from './visibility.js';
|
| 19 |
|
| 20 |
ensureDirs();
|
|
|
|
| 91 |
|
| 92 |
app.get('/api/usage', async (req, res) => res.json(await buildUsage(req.query.debug === '1')));
|
| 93 |
|
| 94 |
+
app.get('/api/traces', async (_req, res) => res.json(await buildTraces()));
|
| 95 |
+
|
| 96 |
const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
|
| 97 |
|
| 98 |
// Env var names that existed at build time (baked in by the Dockerfile). Names
|
server/src/traces.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import fsp from 'node:fs/promises';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
import * as store from './sessions.js';
|
| 5 |
+
|
| 6 |
+
// Workspace-wide trace analytics: parse every Claude transcript and Codex
|
| 7 |
+
// rollout on the Space into per-conversation stats (turns, tool calls, web
|
| 8 |
+
// searches, tokens), attribute them to Agent Manager sessions where possible
|
| 9 |
+
// (Claude: transcript filename == sessionUuid; Codex: pinned codexSessionId),
|
| 10 |
+
// and aggregate the rest as "other".
|
| 11 |
+
//
|
| 12 |
+
// Parsing is memoized per file by (mtime, size), so repeat calls only re-read
|
| 13 |
+
// files that actually changed — important on the bucket mount.
|
| 14 |
+
|
| 15 |
+
const fileCache = new Map(); // path -> { key, stats }
|
| 16 |
+
let resultMemo = { ts: 0, val: null };
|
| 17 |
+
const TTL = 30_000;
|
| 18 |
+
|
| 19 |
+
function emptyStats() {
|
| 20 |
+
return { turns: 0, prompts: 0, toolCalls: 0, tools: {}, web: 0, tokensIn: 0, tokensOut: 0, cacheRead: 0, firstTs: 0, lastTs: 0, files: 0 };
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function addTs(st, iso) {
|
| 24 |
+
const t = Date.parse(iso);
|
| 25 |
+
if (!t) return;
|
| 26 |
+
if (!st.firstTs || t < st.firstTs) st.firstTs = t;
|
| 27 |
+
if (t > st.lastTs) st.lastTs = t;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function mergeInto(a, b) {
|
| 31 |
+
a.turns += b.turns; a.prompts += b.prompts; a.toolCalls += b.toolCalls; a.web += b.web;
|
| 32 |
+
a.tokensIn += b.tokensIn; a.tokensOut += b.tokensOut; a.cacheRead += b.cacheRead; a.files += b.files;
|
| 33 |
+
for (const [k, v] of Object.entries(b.tools)) a.tools[k] = (a.tools[k] || 0) + v;
|
| 34 |
+
if (b.firstTs && (!a.firstTs || b.firstTs < a.firstTs)) a.firstTs = b.firstTs;
|
| 35 |
+
if (b.lastTs > a.lastTs) a.lastTs = b.lastTs;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// ---------- Claude transcripts (CLAUDE_CONFIG_DIR/projects/**/<uuid>.jsonl) ----------
|
| 39 |
+
// Multi-block assistant messages repeat the same message.id AND usage across
|
| 40 |
+
// several lines — dedupe both turns/usage (by message id) and tool_use blocks
|
| 41 |
+
// (by block id) or everything double-counts.
|
| 42 |
+
function parseClaude(txt) {
|
| 43 |
+
const st = emptyStats();
|
| 44 |
+
st.files = 1;
|
| 45 |
+
const seenMsg = new Set();
|
| 46 |
+
const seenTool = new Set();
|
| 47 |
+
for (const line of txt.split('\n')) {
|
| 48 |
+
if (!line) continue;
|
| 49 |
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
| 50 |
+
if (j.timestamp) addTs(st, j.timestamp);
|
| 51 |
+
if (j.type === 'assistant' && j.message) {
|
| 52 |
+
const m = j.message;
|
| 53 |
+
const id = m.id || `${j.uuid || Math.random()}`;
|
| 54 |
+
if (!seenMsg.has(id)) {
|
| 55 |
+
seenMsg.add(id);
|
| 56 |
+
st.turns++;
|
| 57 |
+
const u = m.usage;
|
| 58 |
+
if (u) {
|
| 59 |
+
st.tokensIn += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
| 60 |
+
st.cacheRead += u.cache_read_input_tokens || 0;
|
| 61 |
+
st.tokensOut += u.output_tokens || 0;
|
| 62 |
+
const w = u.server_tool_use;
|
| 63 |
+
if (w) st.web += (w.web_search_requests || 0) + (w.web_fetch_requests || 0);
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
if (Array.isArray(m.content)) {
|
| 67 |
+
for (const c of m.content) {
|
| 68 |
+
if (c && c.type === 'tool_use' && !seenTool.has(c.id)) {
|
| 69 |
+
seenTool.add(c.id);
|
| 70 |
+
st.toolCalls++;
|
| 71 |
+
const name = c.name || 'tool';
|
| 72 |
+
st.tools[name] = (st.tools[name] || 0) + 1;
|
| 73 |
+
if (/^web(search|fetch)$/i.test(name)) st.web++;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
} else if (j.type === 'user' && !j.toolUseResult) {
|
| 78 |
+
st.prompts++;
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return st;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// ---------- Codex rollouts (CODEX_HOME/sessions/**/rollout-*-<uuid>.jsonl) ----------
|
| 85 |
+
function parseCodex(txt) {
|
| 86 |
+
const st = emptyStats();
|
| 87 |
+
st.files = 1;
|
| 88 |
+
let tok = null; // token_count events are cumulative per run — keep the last
|
| 89 |
+
for (const line of txt.split('\n')) {
|
| 90 |
+
if (!line) continue;
|
| 91 |
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
| 92 |
+
if (j.timestamp) addTs(st, j.timestamp);
|
| 93 |
+
const p = j.payload || {};
|
| 94 |
+
if (j.type === 'response_item') {
|
| 95 |
+
switch (p.type) {
|
| 96 |
+
case 'message':
|
| 97 |
+
if (p.role === 'assistant') st.turns++;
|
| 98 |
+
else if (p.role === 'user') st.prompts++;
|
| 99 |
+
break;
|
| 100 |
+
case 'function_call':
|
| 101 |
+
case 'custom_tool_call':
|
| 102 |
+
case 'local_shell_call': {
|
| 103 |
+
st.toolCalls++;
|
| 104 |
+
const name = p.name || p.type;
|
| 105 |
+
st.tools[name] = (st.tools[name] || 0) + 1;
|
| 106 |
+
break;
|
| 107 |
+
}
|
| 108 |
+
case 'web_search_call':
|
| 109 |
+
st.web++;
|
| 110 |
+
break;
|
| 111 |
+
default:
|
| 112 |
+
}
|
| 113 |
+
} else if (j.type === 'event_msg' && p.type === 'token_count' && p.info && p.info.total_token_usage) {
|
| 114 |
+
tok = p.info.total_token_usage;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
if (tok) {
|
| 118 |
+
const cached = tok.cached_input_tokens || 0;
|
| 119 |
+
st.tokensIn = Math.max(0, (tok.input_tokens || 0) - cached); // align with Claude: fresh input only
|
| 120 |
+
st.cacheRead = cached;
|
| 121 |
+
st.tokensOut = tok.output_tokens || 0;
|
| 122 |
+
}
|
| 123 |
+
return st;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function statsFor(p, parser) {
|
| 127 |
+
let m;
|
| 128 |
+
try { m = await fsp.stat(p); } catch { return null; }
|
| 129 |
+
const key = `${m.mtimeMs}:${m.size}`;
|
| 130 |
+
const c = fileCache.get(p);
|
| 131 |
+
if (c && c.key === key) return c.stats;
|
| 132 |
+
let stats;
|
| 133 |
+
try { stats = parser(await fsp.readFile(p, 'utf8')); } catch { return null; }
|
| 134 |
+
fileCache.set(p, { key, stats });
|
| 135 |
+
return stats;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async function claudeFiles() {
|
| 139 |
+
const home = process.env.HOME || '';
|
| 140 |
+
const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')]
|
| 141 |
+
.filter(Boolean).filter((d, i, a) => a.indexOf(d) === i);
|
| 142 |
+
const out = [];
|
| 143 |
+
for (const d of dirs) {
|
| 144 |
+
const proj = path.join(d, 'projects');
|
| 145 |
+
let projects = [];
|
| 146 |
+
try { projects = await fsp.readdir(proj, { withFileTypes: true }); } catch { continue; }
|
| 147 |
+
for (const e of projects) {
|
| 148 |
+
if (!e.isDirectory()) continue;
|
| 149 |
+
let files = [];
|
| 150 |
+
try { files = await fsp.readdir(path.join(proj, e.name)); } catch { continue; }
|
| 151 |
+
for (const f of files) if (f.endsWith('.jsonl')) out.push(path.join(proj, e.name, f));
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
return out;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function codexFiles() {
|
| 158 |
+
const home = process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex');
|
| 159 |
+
const root = path.join(home, 'sessions');
|
| 160 |
+
const out = [];
|
| 161 |
+
const walk = async (dir, depth) => {
|
| 162 |
+
if (depth > 5) return;
|
| 163 |
+
let ents = [];
|
| 164 |
+
try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
| 165 |
+
for (const e of ents) {
|
| 166 |
+
const p = path.join(dir, e.name);
|
| 167 |
+
if (e.isDirectory()) await walk(p, depth + 1);
|
| 168 |
+
else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) out.push(p);
|
| 169 |
+
}
|
| 170 |
+
};
|
| 171 |
+
await walk(root, 0);
|
| 172 |
+
return out;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.jsonl)?$/;
|
| 176 |
+
|
| 177 |
+
async function build() {
|
| 178 |
+
const sessions = store.list();
|
| 179 |
+
const byClaudeUuid = new Map(sessions.filter((s) => s.cli === 'claude' && s.sessionUuid).map((s) => [s.sessionUuid, s]));
|
| 180 |
+
const byCodexId = new Map(sessions.filter((s) => s.codexSessionId).map((s) => [s.codexSessionId, s]));
|
| 181 |
+
|
| 182 |
+
const perSession = new Map(); // session id -> stats
|
| 183 |
+
const other = emptyStats();
|
| 184 |
+
const totals = emptyStats();
|
| 185 |
+
|
| 186 |
+
const attribute = (session, stats) => {
|
| 187 |
+
if (!stats) return;
|
| 188 |
+
mergeInto(totals, stats);
|
| 189 |
+
if (session) {
|
| 190 |
+
if (!perSession.has(session.id)) perSession.set(session.id, emptyStats());
|
| 191 |
+
mergeInto(perSession.get(session.id), stats);
|
| 192 |
+
} else {
|
| 193 |
+
mergeInto(other, stats);
|
| 194 |
+
}
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
for (const p of await claudeFiles()) {
|
| 198 |
+
const m = path.basename(p).match(UUID_RE);
|
| 199 |
+
attribute(m ? byClaudeUuid.get(m[1]) : null, await statsFor(p, parseClaude));
|
| 200 |
+
}
|
| 201 |
+
for (const p of await codexFiles()) {
|
| 202 |
+
const m = path.basename(p).match(UUID_RE);
|
| 203 |
+
attribute(m ? byCodexId.get(m[1]) : null, await statsFor(p, parseCodex));
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
sessions: sessions
|
| 208 |
+
.filter((s) => perSession.has(s.id))
|
| 209 |
+
.map((s) => ({ id: s.id, name: s.name, cli: s.cli, path: s.path, ...perSession.get(s.id) }))
|
| 210 |
+
.sort((a, b) => b.lastTs - a.lastTs),
|
| 211 |
+
other: other.files ? other : null,
|
| 212 |
+
totals,
|
| 213 |
+
generatedAt: new Date().toISOString(),
|
| 214 |
+
};
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
export function buildTraces() {
|
| 218 |
+
if (resultMemo.val && Date.now() - resultMemo.ts < TTL) return resultMemo.val;
|
| 219 |
+
const val = build().catch(() => ({ sessions: [], other: null, totals: emptyStats(), generatedAt: new Date().toISOString() }));
|
| 220 |
+
resultMemo = { ts: Date.now(), val };
|
| 221 |
+
return val;
|
| 222 |
+
}
|
web/src/api.ts
CHANGED
|
@@ -60,6 +60,16 @@ export interface ProviderUsage {
|
|
| 60 |
export interface Usage { providers: Record<string, ProviderUsage>; generatedAt: string; }
|
| 61 |
export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
// ---- files ----
|
| 64 |
export interface FileEntry { name: string; dir: boolean; size: number; }
|
| 65 |
export interface FileListing { path: string; root: string; entries: FileEntry[]; }
|
|
|
|
| 60 |
export interface Usage { providers: Record<string, ProviderUsage>; generatedAt: string; }
|
| 61 |
export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
|
| 62 |
|
| 63 |
+
// ---- trace analytics ----
|
| 64 |
+
export interface TraceStats {
|
| 65 |
+
turns: number; prompts: number; toolCalls: number; tools: Record<string, number>;
|
| 66 |
+
web: number; tokensIn: number; tokensOut: number; cacheRead: number;
|
| 67 |
+
firstTs: number; lastTs: number; files: number;
|
| 68 |
+
}
|
| 69 |
+
export interface SessionTraces extends TraceStats { id: string; name: string; cli: string; path: string | null; }
|
| 70 |
+
export interface Traces { sessions: SessionTraces[]; other: TraceStats | null; totals: TraceStats; generatedAt: string; }
|
| 71 |
+
export const getTraces = (): Promise<Traces> => fetch('/api/traces').then(json);
|
| 72 |
+
|
| 73 |
// ---- files ----
|
| 74 |
export interface FileEntry { name: string; dir: boolean; size: number; }
|
| 75 |
export interface FileListing { path: string; root: string; entries: FileEntry[]; }
|
web/src/components/FilesPane.tsx
CHANGED
|
@@ -139,21 +139,14 @@ export default function FilesPane({
|
|
| 139 |
|
| 140 |
return (
|
| 141 |
<div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
|
|
|
|
| 142 |
<div
|
| 143 |
-
className={`pane-head${dragId ? ' draggable' : ''}`}
|
| 144 |
draggable={!!dragId}
|
| 145 |
onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
|
| 146 |
onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
|
| 147 |
>
|
| 148 |
-
<
|
| 149 |
-
<Logo cli="files" size={16} tint="#d99a2b" />
|
| 150 |
-
<span className="status idle" />
|
| 151 |
-
</div>
|
| 152 |
-
<span className="ph-title">{session.name}</span>
|
| 153 |
-
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 154 |
-
</div>
|
| 155 |
-
|
| 156 |
-
<div className="files-bar">
|
| 157 |
<button className="mini-btn" title="Up" disabled={!root} onClick={up}><UpGlyph /></button>
|
| 158 |
<div className="crumbs">
|
| 159 |
{crumbs.map((c, i) => (
|
|
@@ -168,6 +161,7 @@ export default function FilesPane({
|
|
| 168 |
<UploadGlyph /> Upload
|
| 169 |
<input type="file" multiple hidden onChange={(e) => { if (e.target.files) upload(e.target.files); e.target.value = ''; }} />
|
| 170 |
</label>
|
|
|
|
| 171 |
</div>
|
| 172 |
|
| 173 |
<div
|
|
|
|
| 139 |
|
| 140 |
return (
|
| 141 |
<div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
|
| 142 |
+
{/* One compact bar: logo, navigation, upload, close — no title. */}
|
| 143 |
<div
|
| 144 |
+
className={`pane-head files-head${dragId ? ' draggable' : ''}`}
|
| 145 |
draggable={!!dragId}
|
| 146 |
onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
|
| 147 |
onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
|
| 148 |
>
|
| 149 |
+
<Logo cli="files" size={16} tint="#d99a2b" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
<button className="mini-btn" title="Up" disabled={!root} onClick={up}><UpGlyph /></button>
|
| 151 |
<div className="crumbs">
|
| 152 |
{crumbs.map((c, i) => (
|
|
|
|
| 161 |
<UploadGlyph /> Upload
|
| 162 |
<input type="file" multiple hidden onChange={(e) => { if (e.target.files) upload(e.target.files); e.target.value = ''; }} />
|
| 163 |
</label>
|
| 164 |
+
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 165 |
</div>
|
| 166 |
|
| 167 |
<div
|
web/src/components/Sidebar.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { STATE_LABEL } from '../types';
|
|
| 4 |
import Logo from './Logo';
|
| 5 |
import NewSession from './NewSession';
|
| 6 |
import FolderPicker from './FolderPicker';
|
| 7 |
-
import { SlidersGlyph, SunGlyph, MoonGlyph,
|
| 8 |
|
| 9 |
type Zone = 'before' | 'after' | 'on';
|
| 10 |
|
|
@@ -132,7 +132,7 @@ export default function Sidebar({
|
|
| 132 |
<Logo cli={s.cli} size={11} tint={colorOf[s.cli]} />
|
| 133 |
<span className="row-actions">
|
| 134 |
{s.running && <button className="mini-btn" title="Stop" onClick={(e) => { e.stopPropagation(); onStopSession(s.id); }}><StopGlyph /></button>}
|
| 135 |
-
<button className="mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}><
|
| 136 |
</span>
|
| 137 |
</div>
|
| 138 |
);
|
|
@@ -167,7 +167,7 @@ export default function Sidebar({
|
|
| 167 |
<span className="count">{g.sessionIds.length}</span>
|
| 168 |
<span className="row-actions">
|
| 169 |
<button className="mini-btn" title="Rename" onClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }}><PencilGlyph /></button>
|
| 170 |
-
<button className="mini-btn" title="Delete group" onClick={(e) => { e.stopPropagation(); onDeleteGroup(g.id); }}><
|
| 171 |
</span>
|
| 172 |
</>
|
| 173 |
)}
|
|
|
|
| 4 |
import Logo from './Logo';
|
| 5 |
import NewSession from './NewSession';
|
| 6 |
import FolderPicker from './FolderPicker';
|
| 7 |
+
import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph } from './icons';
|
| 8 |
|
| 9 |
type Zone = 'before' | 'after' | 'on';
|
| 10 |
|
|
|
|
| 132 |
<Logo cli={s.cli} size={11} tint={colorOf[s.cli]} />
|
| 133 |
<span className="row-actions">
|
| 134 |
{s.running && <button className="mini-btn" title="Stop" onClick={(e) => { e.stopPropagation(); onStopSession(s.id); }}><StopGlyph /></button>}
|
| 135 |
+
<button className="mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}><CloseGlyph /></button>
|
| 136 |
</span>
|
| 137 |
</div>
|
| 138 |
);
|
|
|
|
| 167 |
<span className="count">{g.sessionIds.length}</span>
|
| 168 |
<span className="row-actions">
|
| 169 |
<button className="mini-btn" title="Rename" onClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }}><PencilGlyph /></button>
|
| 170 |
+
<button className="mini-btn" title="Delete group" onClick={(e) => { e.stopPropagation(); onDeleteGroup(g.id); }}><CloseGlyph /></button>
|
| 171 |
</span>
|
| 172 |
</>
|
| 173 |
)}
|
web/src/components/UsagePanel.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
import * as api from '../api';
|
| 3 |
-
import type { Usage, QuotaWindow } from '../api';
|
|
|
|
| 4 |
|
| 5 |
const PROVS = [
|
| 6 |
{ id: 'claude', label: 'Claude Code', color: '#d97757' },
|
|
@@ -17,6 +18,16 @@ const resetStr = (s?: number) => {
|
|
| 17 |
if (mins < 60) return `resets in ${mins}m`;
|
| 18 |
return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`;
|
| 19 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
|
| 22 |
if (!q || q.usedPercent == null) return null;
|
|
@@ -30,13 +41,36 @@ function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
|
|
| 30 |
);
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
export default function UsagePanel() {
|
| 34 |
const [u, setU] = useState<Usage | null>(null);
|
|
|
|
| 35 |
const [err, setErr] = useState(false);
|
| 36 |
-
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
if (err) return <div className="
|
| 39 |
-
if (!u) return <div className="
|
| 40 |
|
| 41 |
return (
|
| 42 |
<div className="usage">
|
|
@@ -67,6 +101,29 @@ export default function UsagePanel() {
|
|
| 67 |
</div>
|
| 68 |
);
|
| 69 |
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
<div className="s-help">
|
| 71 |
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.
|
| 72 |
</div>
|
|
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
import * as api from '../api';
|
| 3 |
+
import type { Usage, QuotaWindow, Traces, TraceStats } from '../api';
|
| 4 |
+
import Logo from './Logo';
|
| 5 |
|
| 6 |
const PROVS = [
|
| 7 |
{ id: 'claude', label: 'Claude Code', color: '#d97757' },
|
|
|
|
| 18 |
if (mins < 60) return `resets in ${mins}m`;
|
| 19 |
return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`;
|
| 20 |
};
|
| 21 |
+
const fmtAgo = (ts: number) => {
|
| 22 |
+
if (!ts) return '—';
|
| 23 |
+
const m = Math.round((Date.now() - ts) / 60000);
|
| 24 |
+
if (m < 1) return 'now';
|
| 25 |
+
if (m < 60) return `${m}m ago`;
|
| 26 |
+
if (m < 48 * 60) return `${Math.round(m / 60)}h ago`;
|
| 27 |
+
return `${Math.round(m / 1440)}d ago`;
|
| 28 |
+
};
|
| 29 |
+
const topTools = (tools: Record<string, number>) =>
|
| 30 |
+
Object.entries(tools).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k} ${v}`).join(' · ');
|
| 31 |
|
| 32 |
function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
|
| 33 |
if (!q || q.usedPercent == null) return null;
|
|
|
|
| 41 |
);
|
| 42 |
}
|
| 43 |
|
| 44 |
+
function TraceRow({ label, cli, path, st, strong }: { label: string; cli?: string; path?: string | null; st: TraceStats; strong?: boolean }) {
|
| 45 |
+
const cachePct = st.tokensIn + st.cacheRead > 0 ? Math.round((st.cacheRead / (st.tokensIn + st.cacheRead)) * 100) : 0;
|
| 46 |
+
return (
|
| 47 |
+
<tr className={strong ? 'tr-total' : undefined}>
|
| 48 |
+
<td className="tr-agent" title={path || undefined}>
|
| 49 |
+
{cli && <Logo cli={cli} size={12} />}
|
| 50 |
+
<span>{label}</span>
|
| 51 |
+
</td>
|
| 52 |
+
<td>{st.turns}</td>
|
| 53 |
+
<td>{st.prompts}</td>
|
| 54 |
+
<td title={topTools(st.tools) || undefined}>{st.toolCalls}</td>
|
| 55 |
+
<td>{st.web}</td>
|
| 56 |
+
<td title={`${cachePct}% served from cache (${fmtTok(st.cacheRead)} cached)`}>{fmtTok(st.tokensIn)}</td>
|
| 57 |
+
<td>{fmtTok(st.tokensOut)}</td>
|
| 58 |
+
<td className="tr-when">{fmtAgo(st.lastTs)}</td>
|
| 59 |
+
</tr>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
export default function UsagePanel() {
|
| 64 |
const [u, setU] = useState<Usage | null>(null);
|
| 65 |
+
const [t, setT] = useState<Traces | null>(null);
|
| 66 |
const [err, setErr] = useState(false);
|
| 67 |
+
useEffect(() => {
|
| 68 |
+
api.getUsage().then(setU).catch(() => setErr(true));
|
| 69 |
+
api.getTraces().then(setT).catch(() => {});
|
| 70 |
+
}, []);
|
| 71 |
|
| 72 |
+
if (err) return <div className="usage-msg mono">usage unavailable — is ccusage installed?</div>;
|
| 73 |
+
if (!u) return <div className="usage-msg mono">reading usage…<span className="et-cursor" /></div>;
|
| 74 |
|
| 75 |
return (
|
| 76 |
<div className="usage">
|
|
|
|
| 101 |
</div>
|
| 102 |
);
|
| 103 |
})}
|
| 104 |
+
|
| 105 |
+
<h3>Traces</h3>
|
| 106 |
+
<div className="s-help">
|
| 107 |
+
Parsed from every Claude Code transcript and Codex rollout stored on this Space —
|
| 108 |
+
hover the tools count for the breakdown, tokens-in for the cache share.
|
| 109 |
+
</div>
|
| 110 |
+
{!t ? (
|
| 111 |
+
<div className="usage-msg mono">analyzing traces…<span className="et-cursor" /></div>
|
| 112 |
+
) : t.totals.files === 0 ? (
|
| 113 |
+
<div className="usage-msg mono">no traces yet — run a Claude or Codex session.</div>
|
| 114 |
+
) : (
|
| 115 |
+
<table className="traces-table">
|
| 116 |
+
<thead>
|
| 117 |
+
<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>
|
| 118 |
+
</thead>
|
| 119 |
+
<tbody>
|
| 120 |
+
{t.sessions.map((s) => <TraceRow key={s.id} label={s.name} cli={s.cli} path={s.path} st={s} />)}
|
| 121 |
+
{t.other && <TraceRow label={`other traces (${t.other.files} files)`} st={t.other} />}
|
| 122 |
+
<TraceRow label={`total (${t.totals.files} files)`} st={t.totals} strong />
|
| 123 |
+
</tbody>
|
| 124 |
+
</table>
|
| 125 |
+
)}
|
| 126 |
+
|
| 127 |
<div className="s-help">
|
| 128 |
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.
|
| 129 |
</div>
|
web/src/styles.css
CHANGED
|
@@ -308,9 +308,9 @@ body {
|
|
| 308 |
|
| 309 |
.brand-actions { display: flex; gap: 6px; }
|
| 310 |
|
| 311 |
-
/* files pane */
|
| 312 |
-
.files-
|
| 313 |
-
.files-
|
| 314 |
.crumbs { display: flex; align-items: center; gap: 1px; overflow: hidden; white-space: nowrap; }
|
| 315 |
.crumb { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 12px; padding: 1px 4px; border-radius: var(--r-sm); }
|
| 316 |
.crumb:hover { background: var(--panel-2); }
|
|
@@ -446,6 +446,20 @@ body {
|
|
| 446 |
|
| 447 |
/* usage page */
|
| 448 |
.usage { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
.usage-top { display: flex; align-items: center; gap: 8px; }
|
| 450 |
.usage-top .spacer { flex: 1; }
|
| 451 |
.usage-top .s-muted { font-size: 12px; }
|
|
|
|
| 308 |
|
| 309 |
.brand-actions { display: flex; gap: 6px; }
|
| 310 |
|
| 311 |
+
/* files pane — one compact header bar (logo · nav · upload · close) */
|
| 312 |
+
.pane-head.files-head { display: flex; align-items: center; gap: 7px; }
|
| 313 |
+
.files-head .spacer { flex: 1; }
|
| 314 |
.crumbs { display: flex; align-items: center; gap: 1px; overflow: hidden; white-space: nowrap; }
|
| 315 |
.crumb { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 12px; padding: 1px 4px; border-radius: var(--r-sm); }
|
| 316 |
.crumb:hover { background: var(--panel-2); }
|
|
|
|
| 446 |
|
| 447 |
/* usage page */
|
| 448 |
.usage { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
|
| 449 |
+
.usage-msg { color: var(--muted); font-size: 12.5px; padding: 14px 2px; }
|
| 450 |
+
.usage-msg .et-cursor { height: 12px; width: 6px; margin-left: 6px; }
|
| 451 |
+
.usage h3 { margin: 14px 0 0; font-size: 14px; }
|
| 452 |
+
|
| 453 |
+
/* trace analytics: hairline rows, mono numbers, no card-cage */
|
| 454 |
+
.traces-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
| 455 |
+
.traces-table th { text-align: right; font-weight: 500; color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 10px; border-bottom: 1px solid var(--border); }
|
| 456 |
+
.traces-table th:first-child { text-align: left; }
|
| 457 |
+
.traces-table td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
| 458 |
+
.traces-table tbody tr:hover { background: var(--panel-2); }
|
| 459 |
+
.traces-table .tr-agent { text-align: left; display: flex; align-items: center; gap: 7px; overflow: hidden; }
|
| 460 |
+
.traces-table .tr-agent span { overflow: hidden; text-overflow: ellipsis; }
|
| 461 |
+
.traces-table .tr-when { color: var(--muted); font-size: 11px; }
|
| 462 |
+
.traces-table .tr-total td { font-weight: 600; border-top: 1px solid var(--border-strong); border-bottom: none; }
|
| 463 |
.usage-top { display: flex; align-items: center; gap: 8px; }
|
| 464 |
.usage-top .spacer { flex: 1; }
|
| 465 |
.usage-top .s-muted { font-size: 12px; }
|