dsh-token-total 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/lib/client.js +1318 -176
- package/lib/index.js +434 -4
- package/lib/types/client/SessionUsageView.d.ts +14 -0
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/format.d.ts +9 -5
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +62 -0
- package/lib/types/client/use-session-detail.d.ts +8 -0
- package/lib/types/routes.d.ts +2 -2
- package/lib/types/session-detail.d.ts +3 -0
- package/lib/types/wire.d.ts +55 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +117 -0
- package/src/client/SessionUsageView.tsx +217 -0
- package/src/client/TokenTotalPanel.module.css +259 -99
- package/src/client/TokenTotalPanel.tsx +210 -63
- package/src/client/api.ts +8 -2
- package/src/client/format.ts +13 -9
- package/src/client/index.tsx +20 -1
- package/src/client/locales.ts +76 -0
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/query.ts +53 -2
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
package/lib/types/wire.d.ts
CHANGED
|
@@ -129,6 +129,61 @@ export interface SessionUsageTree {
|
|
|
129
129
|
export interface SessionUsageRequest {
|
|
130
130
|
sessionId: string;
|
|
131
131
|
}
|
|
132
|
+
/** Detailed per-session usage view used by the Usage tab. */
|
|
133
|
+
export interface SessionUsageDetail {
|
|
134
|
+
sessionId: string;
|
|
135
|
+
totals: SessionUsageTotals;
|
|
136
|
+
requests: number;
|
|
137
|
+
averageFirstTokenMs: number | null;
|
|
138
|
+
tokensPerSecond: number | null;
|
|
139
|
+
subagents: Array<{
|
|
140
|
+
sessionId: string;
|
|
141
|
+
name: string;
|
|
142
|
+
totals: SessionUsageTotals;
|
|
143
|
+
requests: number;
|
|
144
|
+
averageFirstTokenMs: number | null;
|
|
145
|
+
tokensPerSecond: number | null;
|
|
146
|
+
}>;
|
|
147
|
+
models: Array<{
|
|
148
|
+
/** Raw model id from the historical route. Stays the row key and the
|
|
149
|
+
* fallback label (also the tooltip) when no display name is resolved. */
|
|
150
|
+
model: string;
|
|
151
|
+
/** The model selector's display name for this model, resolved host-side
|
|
152
|
+
* from the llm service metadata. Optional so an older host (or an
|
|
153
|
+
* unresolvable route) still serves a valid payload. */
|
|
154
|
+
name?: string;
|
|
155
|
+
/** Display names (raw ids when unknown) of every provider that served this
|
|
156
|
+
* model in the session, first-seen order. The by-model card shows these in
|
|
157
|
+
* gray when two rows end up sharing one display name. */
|
|
158
|
+
providers?: string[];
|
|
159
|
+
totals: SessionUsageTotals;
|
|
160
|
+
requests: number;
|
|
161
|
+
}>;
|
|
162
|
+
records: Array<{
|
|
163
|
+
id: string;
|
|
164
|
+
sessionId: string;
|
|
165
|
+
sessionName: string;
|
|
166
|
+
turn: number;
|
|
167
|
+
step: number;
|
|
168
|
+
startedAt: number;
|
|
169
|
+
finishedAt: number | null;
|
|
170
|
+
provider: string;
|
|
171
|
+
model: string;
|
|
172
|
+
status: 'running' | 'completed' | 'failed' | 'interrupted';
|
|
173
|
+
totals: SessionUsageTotals;
|
|
174
|
+
firstTokenMs: number | null;
|
|
175
|
+
tokensPerSecond: number | null;
|
|
176
|
+
turnName: string;
|
|
177
|
+
effort: string;
|
|
178
|
+
}>;
|
|
179
|
+
routes: Array<{
|
|
180
|
+
id: string;
|
|
181
|
+
provider: string;
|
|
182
|
+
name: string;
|
|
183
|
+
model: string;
|
|
184
|
+
status: 'idle' | 'running' | 'completed' | 'failed';
|
|
185
|
+
}>;
|
|
186
|
+
}
|
|
132
187
|
/** The backfill (historical session scan) progress state. */
|
|
133
188
|
export interface BackfillStatus {
|
|
134
189
|
running: boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* A light, self-contained Usage surface. Tables fit the available width:
|
|
2
|
+
* no horizontal scroll containers, fixed column contracts, titles for names.
|
|
3
|
+
* Stat cards follow the reference dashboard: tinted left edge + icon chip,
|
|
4
|
+
* label, big value, small subtitle. Request log folds by conversation turn. */
|
|
5
|
+
.scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
|
|
6
|
+
.page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
|
|
7
|
+
.page * { box-sizing:border-box; }
|
|
8
|
+
.scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
|
|
9
|
+
.stats { display:grid; gap:12px; margin-bottom:18px; }
|
|
10
|
+
.statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
|
|
11
|
+
.stat { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }
|
|
12
|
+
.statHead { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
|
|
13
|
+
.statIcon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }
|
|
14
|
+
.statMark { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }
|
|
15
|
+
.statLabel { font-size:12px; color:#7d8495; }
|
|
16
|
+
.statValue { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }
|
|
17
|
+
.statSub { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
18
|
+
.toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
|
|
19
|
+
.tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
|
|
20
|
+
.toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
|
|
21
|
+
.toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
|
|
22
|
+
.toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
|
|
23
|
+
.tonePink { --tone:#d9538c; --tone-soft:#fbeaf1; }
|
|
24
|
+
.card { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }
|
|
25
|
+
.cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }
|
|
26
|
+
.cardHead h2 { font-size:14px; font-weight:600; margin:0; }
|
|
27
|
+
.cardHead > span { display:flex; gap:7px; align-items:center; font-size:11px; }
|
|
28
|
+
.count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }
|
|
29
|
+
.mid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
|
|
30
|
+
.mid .card { height:100%; margin-bottom:0; }
|
|
31
|
+
.mid { margin-bottom:18px; }
|
|
32
|
+
.table { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }
|
|
33
|
+
.table th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
|
|
34
|
+
.table td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
35
|
+
.table th:first-child,.table td:first-child { text-align:left; padding-left:16px; }
|
|
36
|
+
.table th:last-child,.table td:last-child { padding-right:16px; }
|
|
37
|
+
.childName { width:28%; }
|
|
38
|
+
.childRequests { width:11%; }
|
|
39
|
+
.modelName { width:30%; }
|
|
40
|
+
.modelRequests { width:11%; }
|
|
41
|
+
.modelHit { width:16%; }
|
|
42
|
+
.ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }
|
|
43
|
+
/* Gray provider label under a model name shown by several rows (同名模型). */
|
|
44
|
+
.modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
45
|
+
.number { font-variant-numeric:tabular-nums; }
|
|
46
|
+
.speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
|
|
47
|
+
.speedCell small { font-size:9px; color:#929aaa; }
|
|
48
|
+
.good { color:#21a17b; }
|
|
49
|
+
.warn { color:#d69a21; }
|
|
50
|
+
.bad { color:#e06571; }
|
|
51
|
+
.live { color:#169bb3; }
|
|
52
|
+
.muted { color:#939aaa; }
|
|
53
|
+
.empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
|
|
54
|
+
.action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
|
|
55
|
+
.action:hover { color:#279cb6; }
|
|
56
|
+
.action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
|
|
57
|
+
.turnList { padding:0 14px 14px; }
|
|
58
|
+
.turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
|
|
59
|
+
.turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
|
|
60
|
+
.chevron { color:#99a1af; transition:transform 0.2s; }
|
|
61
|
+
.rotated { transform:rotate(90deg); }
|
|
62
|
+
.turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
|
|
63
|
+
/* Turn topic is the group title now (the page header owns the session name),
|
|
64
|
+
* so it carries the primary text color and truncates before the metrics. */
|
|
65
|
+
.turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
66
|
+
/* A subagent's turns look like this session's turns apart from the agent tag;
|
|
67
|
+
* the tag keeps the full session name in its tooltip. */
|
|
68
|
+
.turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
|
|
69
|
+
.turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
|
|
70
|
+
.turnSummary > span:last-child { margin-left:8px; }
|
|
71
|
+
.turnBody { border-top:1px solid #eef0f4; }
|
|
72
|
+
.turnBody[hidden] { display:none; }
|
|
73
|
+
/* A request row is one line: time, what answered, and its metrics pinned to the
|
|
74
|
+
* row end. The metrics never wrap; they truncate with a tooltip before the head
|
|
75
|
+
* gives up its model name. */
|
|
76
|
+
.turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
|
|
77
|
+
.turnRequest:first-child { border-top:0; }
|
|
78
|
+
.turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
|
|
79
|
+
.requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
|
|
80
|
+
.statusDot { width:7px; height:7px; border-radius:50%; background:currentColor; flex-shrink:0; }
|
|
81
|
+
.routeDot { width:6px; height:6px; border-radius:50%; background:#21a17b; flex-shrink:0; }
|
|
82
|
+
.whale { color:#4d6bfe; flex-shrink:0; }
|
|
83
|
+
.recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
|
|
84
|
+
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
|
|
85
|
+
.effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
|
|
86
|
+
.recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }
|
|
87
|
+
.note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
|
|
88
|
+
.error { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
|
|
89
|
+
@container (max-width:1050px) {
|
|
90
|
+
.mid { grid-template-columns:minmax(0,1fr); }
|
|
91
|
+
.statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
92
|
+
.statValue { font-size:23px; }
|
|
93
|
+
.table { font-size:12px; }
|
|
94
|
+
.childName { width:34%; }
|
|
95
|
+
}
|
|
96
|
+
@container (max-width:700px) {
|
|
97
|
+
.page { padding:18px 14px 24px; }
|
|
98
|
+
.stat { padding:12px 12px 11px; }
|
|
99
|
+
.statValue { font-size:20px; }
|
|
100
|
+
.table { font-size:10px; }
|
|
101
|
+
.table th,.table td { padding:10px 3px; }
|
|
102
|
+
.table th:first-child,.table td:first-child { padding-left:10px; }
|
|
103
|
+
.table th:last-child,.table td:last-child { padding-right:10px; }
|
|
104
|
+
.childName { width:25%; }
|
|
105
|
+
.childRequests { width:11%; }
|
|
106
|
+
.modelName { width:25%; }
|
|
107
|
+
.modelRequests { width:11%; }
|
|
108
|
+
.modelHit { width:17%; }
|
|
109
|
+
.turnHead { gap:8px; padding:11px 10px; }
|
|
110
|
+
.turnSummary { grid-column:2; font-size:10px; }
|
|
111
|
+
.turnRequest { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }
|
|
112
|
+
.turnRequestHead { gap:6px; }
|
|
113
|
+
.routeText { display:none; }
|
|
114
|
+
}
|
|
115
|
+
@media (prefers-reduced-motion:reduce) {
|
|
116
|
+
.chevron { transition:none; }
|
|
117
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import type { SessionUsageDetail, SessionUsageTotals } from '../wire.ts'
|
|
4
|
+
import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
|
|
5
|
+
import { formatCompact, formatTokens, normalizeModelName } from './format.ts'
|
|
6
|
+
import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
|
|
7
|
+
import { useSessionDetail } from './use-session-detail.ts'
|
|
8
|
+
import css from './SessionUsageView.module.css'
|
|
9
|
+
|
|
10
|
+
type T = (key: SessionUsageKey) => string
|
|
11
|
+
type Row = SessionUsageDetail['records'][number]
|
|
12
|
+
export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
|
|
13
|
+
|
|
14
|
+
const seconds = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : '—'
|
|
15
|
+
// Request-log style metrics follow the reference log: seconds without trailing
|
|
16
|
+
// ".0", short lowercase tokens (80.0k / 1.5M), integer percentages.
|
|
17
|
+
const secText = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? String(Math.round(ms / 100) / 10) : '—'
|
|
18
|
+
const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
|
|
19
|
+
const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
|
|
20
|
+
const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
|
|
21
|
+
const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
22
|
+
const whale = 'M8 39.5C13.5 27.6 26.8 19.4 42.5 19.1c-1.6 2.4-2.3 5-2 7.9-5.6-1.7-11.7-.4-16.2 3.4 4.7 1.1 8.4 4.3 10.2 8.7 1.3-.4 2.7-1 4-1.9.4 2.1-.2 4.3-1.6 5.9-7.7.6-15.5-1.6-20.7-6.6-.7 1.9-.9 3.9-.6 5.9-3.1-1.2-5.6-3-7.6-2.9z'
|
|
23
|
+
const modelShort = (ref: string): string => ref.includes('/') ? ref.slice(ref.indexOf('/') + 1) : ref
|
|
24
|
+
const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
25
|
+
const sum = (rows: Row[]): SessionUsageTotals => rows.reduce((acc, r) => ({
|
|
26
|
+
uncachedInputTokens: acc.uncachedInputTokens + r.totals.uncachedInputTokens,
|
|
27
|
+
outputTokens: acc.outputTokens + r.totals.outputTokens,
|
|
28
|
+
cacheReadTokens: acc.cacheReadTokens + r.totals.cacheReadTokens,
|
|
29
|
+
cacheWriteTokens: acc.cacheWriteTokens + r.totals.cacheWriteTokens,
|
|
30
|
+
}), zero())
|
|
31
|
+
|
|
32
|
+
function Token({ n }: { n: number }): JSX.Element {
|
|
33
|
+
return <span className={css.number} title={formatTokens(n)}>{formatCompact(n)}</span>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Hit({ totals }: { totals: SessionUsageTotals }): JSX.Element {
|
|
37
|
+
const rate = cacheHitPercentOf(totals)
|
|
38
|
+
return <span className={rate === null ? css.muted : rate >= 80 ? css.good : rate >= 50 ? css.warn : css.bad}>{rate === null ? '—' : `${rate.toFixed(1)}%`}</span>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const icons = {
|
|
42
|
+
requests: 'M4 9h16M4 15h16M10 3 8 21M16 3l-2 18',
|
|
43
|
+
tokens: 'M4 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 0v8c0 2.2 3.6 4 8 4s8-1.8 8-4V8',
|
|
44
|
+
hit: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
|
|
45
|
+
speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
|
|
46
|
+
input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
|
|
47
|
+
output: 'M12 21V9M7 14l5-5 5 5M4 3h16',
|
|
48
|
+
read: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
|
|
49
|
+
write: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Stat({ tone, name, label, value, sub, title }: { tone: string; name: keyof typeof icons; label: string; value: string | JSX.Element; sub?: string; title?: string }): JSX.Element {
|
|
53
|
+
const icon = icons[name]
|
|
54
|
+
return <article className={`${css.stat} ${tone}`}>
|
|
55
|
+
{/* Oversized gradient watermark filling the card's empty right side. */}
|
|
56
|
+
<svg className={css.statMark} viewBox="0 0 24 24" aria-hidden="true">
|
|
57
|
+
<defs><linearGradient id={`usage-mark-${name}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity="0.3" /><stop offset="1" stopColor="currentColor" stopOpacity="0.03" /></linearGradient></defs>
|
|
58
|
+
<path d={icon} fill={`url(#usage-mark-${name})`} stroke={`url(#usage-mark-${name})`} strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
|
|
59
|
+
</svg>
|
|
60
|
+
<div className={css.statHead}><span className={css.statIcon}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span><span className={css.statLabel}>{label}</span></div>
|
|
61
|
+
<strong className={css.statValue} title={title}>{value}</strong>
|
|
62
|
+
{sub !== undefined && <span className={css.statSub}>{sub}</span>}
|
|
63
|
+
</article>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
67
|
+
const billed = billedTotal(detail.totals)
|
|
68
|
+
const failed = detail.records.filter(row => row.status === 'failed').length
|
|
69
|
+
const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
|
|
70
|
+
return <div className={css.stats}>
|
|
71
|
+
<div className={css.statRow}>
|
|
72
|
+
<Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
|
|
73
|
+
<Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
|
|
74
|
+
<Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
|
|
75
|
+
<Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className={css.statRow}>
|
|
78
|
+
<Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
|
|
79
|
+
<Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
|
|
80
|
+
<Stat tone={css.tonePurple} name="read" label={t('readFull')} value={formatCompact(detail.totals.cacheReadTokens)} title={formatTokens(detail.totals.cacheReadTokens)} sub={formatTokens(detail.totals.cacheReadTokens)} />
|
|
81
|
+
<Stat tone={css.tonePink} name="write" label={t('writeFull')} value={formatCompact(detail.totals.cacheWriteTokens)} title={formatTokens(detail.totals.cacheWriteTokens)} sub={formatTokens(detail.totals.cacheWriteTokens)} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Tables({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
87
|
+
// The by-model card labels rows with the model selector's display name, never
|
|
88
|
+
// the route id (which stays in the tooltip). When two rows end up sharing one
|
|
89
|
+
// display name, each of those rows additionally shows its provider in gray
|
|
90
|
+
// below the name so they stay tellable apart.
|
|
91
|
+
const nameCounts = new Map<string, number>()
|
|
92
|
+
for (const row of detail.models) {
|
|
93
|
+
const key = normalizeModelName(row.name?.trim() || row.model)
|
|
94
|
+
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1)
|
|
95
|
+
}
|
|
96
|
+
return <div className={css.mid}>
|
|
97
|
+
<section className={css.card}><header className={css.cardHead}><h2>{t('children')} <span className={css.count}>{detail.subagents.length}</span></h2></header>
|
|
98
|
+
<table className={`${css.table} ${css.childTable}`}><colgroup><col className={css.childName} /><col className={css.childRequests} /><col /><col /><col /><col /></colgroup>
|
|
99
|
+
<thead><tr><th>{t('session')}</th><th>{t('requests')}</th><th>{t('tokens')}</th><th>{t('hit')}</th><th>{t('first')}</th><th>{t('speed')}</th></tr></thead>
|
|
100
|
+
<tbody>{detail.subagents.map(row => <tr key={row.sessionId}><td><span className={css.ellipsis} title={row.name || row.sessionId}>{row.name || row.sessionId}</span></td><td>{formatTokens(row.requests)}</td><td><Token n={billedTotal(row.totals)} /></td><td><Hit totals={row.totals} /></td><td>{seconds(row.averageFirstTokenMs)}</td><td><span className={css.speedCell}>{row.tokensPerSecond === null ? '—' : <>{Math.round(row.tokensPerSecond)}<small>tok/s</small></>}</span></td></tr>)}</tbody>
|
|
101
|
+
</table>{detail.subagents.length === 0 && <p className={css.empty}>{t('noChildren')}</p>}
|
|
102
|
+
</section>
|
|
103
|
+
<section className={css.card}><header className={css.cardHead}><h2>{t('models')} <span className={css.count}>{detail.models.length}</span></h2></header>
|
|
104
|
+
<table className={`${css.table} ${css.modelTable}`}><colgroup><col className={css.modelName} /><col className={css.modelRequests} /><col /><col /><col /><col /><col className={css.modelHit} /></colgroup>
|
|
105
|
+
<thead><tr><th>{t('model')}</th><th>{t('requests')}</th><th>{t('input')}</th><th>{t('output')}</th><th>{t('read')}</th><th>{t('write')}</th><th>{t('hit')}</th></tr></thead>
|
|
106
|
+
<tbody>{detail.models.map(row => {
|
|
107
|
+
const name = row.name?.trim() || row.model
|
|
108
|
+
const providers = row.providers ?? []
|
|
109
|
+
const shared = (nameCounts.get(normalizeModelName(name)) ?? 0) > 1
|
|
110
|
+
return <tr key={row.model}>
|
|
111
|
+
<td><span className={css.ellipsis} title={row.model}>{name}</span>
|
|
112
|
+
{shared && providers.length > 0 ? <small className={css.modelProvider} title={providers.join(' / ')}>{providers.join(' / ')}</small> : null}
|
|
113
|
+
</td>
|
|
114
|
+
<td>{formatTokens(row.requests)}</td><td><Token n={row.totals.uncachedInputTokens} /></td><td><Token n={row.totals.outputTokens} /></td><td><Token n={row.totals.cacheReadTokens} /></td><td><Token n={row.totals.cacheWriteTokens} /></td><td><Hit totals={row.totals} /></td>
|
|
115
|
+
</tr>
|
|
116
|
+
})}</tbody>
|
|
117
|
+
</table>{detail.models.length === 0 && <p className={css.empty}>{t('noModels')}</p>}
|
|
118
|
+
</section>
|
|
119
|
+
</div>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
123
|
+
const [opened, setOpened] = useState<Set<string>>(() => new Set())
|
|
124
|
+
const [allOpen, setAllOpen] = useState(false)
|
|
125
|
+
const toggleAll = (): void => { setAllOpen(!allOpen); setOpened(new Set()) }
|
|
126
|
+
const toggle = (id: string): void => setOpened(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next })
|
|
127
|
+
const providerNames = new Map(detail.routes.map(route => [route.provider, route.name]))
|
|
128
|
+
// Fold by conversation turn: one collapsible group per session turn, holding
|
|
129
|
+
// every request of that turn. A group is titled by what the turn did (the
|
|
130
|
+
// turn's first real user prompt, turnName from the backend), never by the
|
|
131
|
+
// session name: the page header already names this session, so repeating it on
|
|
132
|
+
// every row would only crowd out the topic. A subagent's turn gets a compact
|
|
133
|
+
// "subagent" tag instead, with the full session name kept in its tooltip —
|
|
134
|
+
// that tag is the only thing distinguishing it from this session's own turns.
|
|
135
|
+
const groups: Array<{ key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }> = []
|
|
136
|
+
const index = new Map<string, { key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }>()
|
|
137
|
+
for (const record of detail.records) {
|
|
138
|
+
const key = `${record.sessionId}:${record.turn}`
|
|
139
|
+
let group = index.get(key)
|
|
140
|
+
if (!group) {
|
|
141
|
+
group = {
|
|
142
|
+
key,
|
|
143
|
+
turn: record.turn,
|
|
144
|
+
name: record.sessionName || record.sessionId,
|
|
145
|
+
topic: record.turnName || `${t('turn')} ${record.turn}`,
|
|
146
|
+
main: record.sessionId === detail.sessionId,
|
|
147
|
+
rows: [],
|
|
148
|
+
}
|
|
149
|
+
index.set(key, group); groups.push(group)
|
|
150
|
+
}
|
|
151
|
+
group.rows.push(record)
|
|
152
|
+
}
|
|
153
|
+
return <section className={css.card}><header className={css.cardHead}><h2>{t('records')} <span className={css.count}>{detail.records.length}</span></h2><button type="button" className={css.action} onClick={toggleAll}>{t(allOpen ? 'collapse' : 'expand')}</button></header>
|
|
154
|
+
<div className={css.turnList}>{groups.map(group => {
|
|
155
|
+
const open = allOpen !== opened.has(group.key)
|
|
156
|
+
const totals = sum(group.rows)
|
|
157
|
+
const billed = billedTotal(totals)
|
|
158
|
+
const running = group.rows.filter(row => row.status === 'running').length
|
|
159
|
+
const rates = group.rows.map(row => row.tokensPerSecond).filter((n): n is number => n !== null)
|
|
160
|
+
const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null
|
|
161
|
+
return <article key={group.key} className={css.turn}>
|
|
162
|
+
<button type="button" className={css.turnHead} onClick={() => toggle(group.key)} aria-expanded={open} aria-controls={`usage-turn-${encodeURIComponent(group.key)}`}>
|
|
163
|
+
<svg className={`${css.chevron} ${open ? css.rotated : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
|
|
164
|
+
<span className={css.turnTitle}>
|
|
165
|
+
{group.main ? null : <span className={css.turnAgent} title={group.name}>{t('children')}</span>}
|
|
166
|
+
<span className={css.turnTopic} title={group.turn === 0 ? t('compaction') : group.topic}>{group.turn === 0 ? t('compaction') : group.topic}</span>
|
|
167
|
+
</span>
|
|
168
|
+
<span className={css.turnSummary}>
|
|
169
|
+
<span>{group.rows.length} {t('reqTimes')} · <span className={css.number} title={formatTokens(billed)}>{tokShort(billed)}</span> {t('tokenWord')}{running > 0 ? ` · ${running} ${t('inProgress')}` : ''}</span>
|
|
170
|
+
<span>{t('avgCache')} {pctInt(cacheHitPercentOf(totals))}</span>
|
|
171
|
+
<span>{t('avgRate')} {avgRate === null ? '—' : avgRate} {t('perSec')}</span>
|
|
172
|
+
</span>
|
|
173
|
+
</button>
|
|
174
|
+
<div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
|
|
175
|
+
{group.rows.map(record => {
|
|
176
|
+
const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
|
|
177
|
+
const billed = billedTotal(record.totals)
|
|
178
|
+
const hit = cacheHitPercentOf(record.totals)
|
|
179
|
+
const meta: Array<{ text: string; title?: string }> = []
|
|
180
|
+
if (duration !== null) meta.push({ text: `${secText(duration)} ${t('sec')}` })
|
|
181
|
+
if (record.firstTokenMs !== null) meta.push({ text: `${t('firstWait')} ${secText(record.firstTokenMs)} ${t('sec')}` })
|
|
182
|
+
meta.push({ text: `${tokShort(billed)} ${t('tokenWord')}`, title: formatTokens(billed) })
|
|
183
|
+
if (hit !== null) meta.push({ text: `${t('cacheShort')} ${pctInt(hit)}` })
|
|
184
|
+
if (record.tokensPerSecond !== null) meta.push({ text: `${Math.round(record.tokensPerSecond)} ${t('perSec')}` })
|
|
185
|
+
const statusText = record.status === 'running' ? t('answering') : record.status === 'completed' ? '' : t(record.status)
|
|
186
|
+
return <div key={record.id} className={css.turnRequest}>
|
|
187
|
+
<span className={css.requestTime}>{clock(record.startedAt)}</span>
|
|
188
|
+
<div className={css.turnRequestHead}>
|
|
189
|
+
<span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
|
|
190
|
+
<span className={css.recordModel} title={`${record.provider} / ${record.model}`}><svg className={css.whale} width="13" height="13" viewBox="0 0 64 64" aria-hidden="true"><path d={whale} fill="currentColor" /></svg>{record.model || '—'}</span>
|
|
191
|
+
<span className={css.routeDot} />
|
|
192
|
+
<span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
|
|
193
|
+
{statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
|
|
194
|
+
{record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
|
|
195
|
+
</div>
|
|
196
|
+
{record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
|
|
197
|
+
</div>
|
|
198
|
+
})}
|
|
199
|
+
</div>
|
|
200
|
+
</article>
|
|
201
|
+
})}</div>{detail.records.length === 0 && <p className={css.empty}>{t('empty')}</p>}
|
|
202
|
+
</section>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
206
|
+
export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
207
|
+
return <><p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} /><Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p></>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
|
|
211
|
+
const { detail, loading, error, retry } = useSessionDetail(sessionId)
|
|
212
|
+
return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
|
|
213
|
+
{loading && <p className={css.empty} role="status">{t('loading')}</p>}
|
|
214
|
+
{error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
|
|
215
|
+
{detail && <SessionUsageContent detail={detail} t={t} />}
|
|
216
|
+
</div></div>
|
|
217
|
+
}
|