codedeck 0.7.0 → 0.7.1
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 +49 -34
- package/dist/cli/commands/claims.js +3 -2
- package/dist/cli/commands/claims.js.map +1 -1
- package/dist/cli/commands/doctor.js +8 -40
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/logs.js.map +1 -1
- package/dist/cli/commands/open.js +8 -9
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/ps.js +6 -6
- package/dist/cli/commands/ps.js.map +1 -1
- package/dist/cli/commands/review.js +36 -58
- package/dist/cli/commands/review.js.map +1 -1
- package/dist/cli/commands/run.js +27 -27
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/setup.js +96 -314
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +2 -1
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/ui.js +61 -0
- package/dist/cli/commands/ui.js.map +1 -0
- package/dist/cli/commands/usage.js +65 -42
- package/dist/cli/commands/usage.js.map +1 -1
- package/dist/cli/commands/wait.js +3 -2
- package/dist/cli/commands/wait.js.map +1 -1
- package/dist/cli/index.js +55 -39
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/web-launch.js +126 -0
- package/dist/cli/web-launch.js.map +1 -0
- package/dist/config/config.js +0 -70
- package/dist/config/config.js.map +1 -1
- package/dist/config/paths.js +1 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/setup.js +248 -0
- package/dist/config/setup.js.map +1 -0
- package/dist/config/web-host.js +31 -0
- package/dist/config/web-host.js.map +1 -0
- package/dist/config/web-port.js +24 -0
- package/dist/config/web-port.js.map +1 -0
- package/dist/core/claude-transcript.js +87 -0
- package/dist/core/claude-transcript.js.map +1 -1
- package/dist/core/errors.js +17 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/models.js +4 -1
- package/dist/core/models.js.map +1 -1
- package/dist/core/pricing.js +73 -50
- package/dist/core/pricing.js.map +1 -1
- package/dist/core/roles.js +6 -5
- package/dist/core/roles.js.map +1 -1
- package/dist/core/run-skills.js +114 -0
- package/dist/core/run-skills.js.map +1 -0
- package/dist/core/run-usage.js +4 -1
- package/dist/core/run-usage.js.map +1 -1
- package/dist/core/session.js +6 -16
- package/dist/core/session.js.map +1 -1
- package/dist/core/usage-query.js +41 -0
- package/dist/core/usage-query.js.map +1 -0
- package/dist/daemon/build-id.js +30 -0
- package/dist/daemon/build-id.js.map +1 -0
- package/dist/daemon/daemon.js +1116 -767
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/instance-lock.js +48 -0
- package/dist/daemon/instance-lock.js.map +1 -0
- package/dist/daemon/web-supervisor.js +251 -0
- package/dist/daemon/web-supervisor.js.map +1 -0
- package/dist/drivers/claude/driver.js +1 -0
- package/dist/drivers/claude/driver.js.map +1 -1
- package/dist/drivers/codex/driver.js +14 -3
- package/dist/drivers/codex/driver.js.map +1 -1
- package/dist/drivers/codex/host-skills.js +57 -0
- package/dist/drivers/codex/host-skills.js.map +1 -0
- package/dist/drivers/opencode/driver.js +6 -2
- package/dist/drivers/opencode/driver.js.map +1 -1
- package/dist/drivers/session-runtime.js +2 -0
- package/dist/drivers/session-runtime.js.map +1 -1
- package/dist/drivers/terminal.js +15 -0
- package/dist/drivers/terminal.js.map +1 -1
- package/dist/git/diff.js +29 -4
- package/dist/git/diff.js.map +1 -1
- package/dist/open/contract.js.map +1 -1
- package/dist/open/pty.js +330 -5
- package/dist/open/pty.js.map +1 -1
- package/dist/plugin/agents/general.md +33 -6
- package/dist/plugin/hooks/pane-ticker.ts +102 -0
- package/dist/plugin/hooks/register.tsx +33 -8
- package/dist/plugin/mods/agents/pane.ts +259 -136
- package/dist/plugin/mods/agents/types.ts +19 -3
- package/dist/plugin/statusline.sh +22 -34
- package/dist/store/database.js +5 -1
- package/dist/store/database.js.map +1 -1
- package/dist/store/sessions.js +55 -12
- package/dist/store/sessions.js.map +1 -1
- package/dist/utils/process.js +2 -16
- package/dist/utils/process.js.map +1 -1
- package/dist/web/brand.js +59 -0
- package/dist/web/brand.js.map +1 -0
- package/dist/web/child.js +67 -0
- package/dist/web/child.js.map +1 -0
- package/dist/web/home-page.js +48 -0
- package/dist/web/home-page.js.map +1 -0
- package/dist/web/review-page.js +12 -5
- package/dist/web/review-page.js.map +1 -1
- package/dist/web/security.js +170 -0
- package/dist/web/security.js.map +1 -0
- package/dist/web/server.js +198 -0
- package/dist/web/server.js.map +1 -0
- package/dist/web/setup-icons.js +37 -0
- package/dist/web/setup-icons.js.map +1 -0
- package/dist/web/setup-page.js +1795 -0
- package/dist/web/setup-page.js.map +1 -0
- package/dist/web/setup-routes.js +475 -0
- package/dist/web/setup-routes.js.map +1 -0
- package/dist/web/usage-page.js +609 -0
- package/dist/web/usage-page.js.map +1 -0
- package/dist/web/usage-routes.js +88 -0
- package/dist/web/usage-routes.js.map +1 -0
- package/dist/web/web-token.js +56 -0
- package/dist/web/web-token.js.map +1 -0
- package/package.json +1 -1
- package/dist/plugin/prompts/_partials/commit.md +0 -145
- package/dist/plugin/prompts/_partials/pr-writer.md +0 -186
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { BRAND_CSS, LOGO_FAVICON_HREF, renderTopBar } from "./brand.js";
|
|
2
|
+
export function normalizeUsageInterval(value) { return Math.max(1, Number(value) || 2); }
|
|
3
|
+
export function buildUsageApiUrl(filters) {
|
|
4
|
+
const params = [];
|
|
5
|
+
for (const name of ["period", "repo", "model", "agent", "since", "until"]) {
|
|
6
|
+
if (filters[name])
|
|
7
|
+
params.push(`${encodeURIComponent(name)}=${encodeURIComponent(filters[name])}`);
|
|
8
|
+
}
|
|
9
|
+
const query = params.join("&");
|
|
10
|
+
return query ? `/api/usage?${query}` : "/api/usage";
|
|
11
|
+
}
|
|
12
|
+
export function buildUsagePageSearch(state) {
|
|
13
|
+
const params = [];
|
|
14
|
+
for (const name of ["period", "repo", "model", "agent", "since", "until"]) {
|
|
15
|
+
if (state.filters[name])
|
|
16
|
+
params.push(`${encodeURIComponent(name)}=${encodeURIComponent(state.filters[name])}`);
|
|
17
|
+
}
|
|
18
|
+
if (state.by !== "day")
|
|
19
|
+
params.push(`by=${encodeURIComponent(state.by)}`);
|
|
20
|
+
const query = params.join("&");
|
|
21
|
+
return query ? `?${query}` : "";
|
|
22
|
+
}
|
|
23
|
+
export function formatCompact(value) {
|
|
24
|
+
const absolute = Math.abs(value);
|
|
25
|
+
if (absolute >= 1_000_000_000)
|
|
26
|
+
return `${(value / 1_000_000_000).toFixed(2)}B`;
|
|
27
|
+
if (absolute >= 1_000_000)
|
|
28
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
29
|
+
if (absolute >= 10_000)
|
|
30
|
+
return `${(value / 1_000).toFixed(0)}K`;
|
|
31
|
+
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
|
|
32
|
+
}
|
|
33
|
+
export function formatUsd(value) {
|
|
34
|
+
return `$${new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)}`;
|
|
35
|
+
}
|
|
36
|
+
export function formatAxisTick(value, currency = false) {
|
|
37
|
+
return currency
|
|
38
|
+
? `$${new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(value)}`
|
|
39
|
+
: formatCompact(value);
|
|
40
|
+
}
|
|
41
|
+
export function formatShare(value, base) {
|
|
42
|
+
return `${value.toFixed(1)}% of ${base}`;
|
|
43
|
+
}
|
|
44
|
+
export function formatCost(value, complete, sessionsWithoutCost) {
|
|
45
|
+
return `${complete ? "" : "≥ "}${formatUsd(value)}${complete ? "" : ` <span class="pill" title="${sessionsWithoutCost}">${formatCompact(sessionsWithoutCost)} unpriced</span>`}`;
|
|
46
|
+
}
|
|
47
|
+
export function previousWindow(period, now) {
|
|
48
|
+
const days = Number(period.replace(/d$/, ""));
|
|
49
|
+
if (![3, 7, 30].includes(days) || !/^[0-9]+d$/.test(period))
|
|
50
|
+
return undefined;
|
|
51
|
+
const currentStart = new Date(now);
|
|
52
|
+
currentStart.setDate(currentStart.getDate() - days);
|
|
53
|
+
const until = new Date(currentStart.getTime() - 1);
|
|
54
|
+
const since = new Date(until);
|
|
55
|
+
since.setDate(since.getDate() - days);
|
|
56
|
+
return { since: since.toISOString(), until: until.toISOString() };
|
|
57
|
+
}
|
|
58
|
+
export function computeDelta(current, previous, period) {
|
|
59
|
+
if (!["3d", "7d", "30d"].includes(period) || previous === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const difference = ((current - previous) / Math.abs(previous)) * 100;
|
|
62
|
+
return { direction: difference >= 0 ? "up" : "down", percent: Math.abs(difference) };
|
|
63
|
+
}
|
|
64
|
+
export function donutSlices(rows, limit = 6) {
|
|
65
|
+
const sorted = [...rows].sort((a, b) => b.costUsd - a.costUsd);
|
|
66
|
+
const top = sorted.slice(0, limit);
|
|
67
|
+
const remaining = sorted.slice(limit);
|
|
68
|
+
if (!remaining.length)
|
|
69
|
+
return top;
|
|
70
|
+
const other = remaining.reduce((sum, row) => ({
|
|
71
|
+
key: "other", label: "Other", sessionCount: sum.sessionCount + row.sessionCount,
|
|
72
|
+
inputTokens: sum.inputTokens + row.inputTokens, outputTokens: sum.outputTokens + row.outputTokens,
|
|
73
|
+
cachedTokens: sum.cachedTokens + row.cachedTokens, totalTokens: sum.totalTokens + row.totalTokens,
|
|
74
|
+
costUsd: sum.costUsd + row.costUsd, costComplete: sum.costComplete && row.costComplete, trend: [],
|
|
75
|
+
}), { key: "other", label: "Other", sessionCount: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, costUsd: 0, costComplete: true, trend: [] });
|
|
76
|
+
return [...top, other];
|
|
77
|
+
}
|
|
78
|
+
export function renderDonutSvg(rows) {
|
|
79
|
+
const slices = donutSlices(rows);
|
|
80
|
+
const total = slices.reduce((sum, row) => sum + row.costUsd, 0);
|
|
81
|
+
if (!slices.length || total === 0)
|
|
82
|
+
return '<p class="empty">No usage in this range</p>';
|
|
83
|
+
const palette = ["#3b82f6", "#93c5fd", "#1d4ed8", "#60a5fa", "#bfdbfe", "#1e3a8a", "#737373"];
|
|
84
|
+
const circumference = 2 * Math.PI * 54;
|
|
85
|
+
let offset = 0;
|
|
86
|
+
const segments = slices.map((row, index) => {
|
|
87
|
+
const length = circumference * row.costUsd / total;
|
|
88
|
+
const segment = `<circle class="donut-seg" cx="85" cy="85" r="54" stroke="${palette[index]}" stroke-dasharray="${length} ${circumference - length}" stroke-dashoffset="${-offset}" tabindex="0"><title>${escapeHtml(row.label ?? row.key)}: ${formatUsd(row.costUsd)}, ${formatShare(row.costUsd / total * 100, "spend")} (${row.costUsd / total * 100})</title></circle>`;
|
|
89
|
+
offset += length;
|
|
90
|
+
return segment;
|
|
91
|
+
}).join("");
|
|
92
|
+
const center = formatUsd(total);
|
|
93
|
+
return `<svg class="donut-svg" viewBox="0 0 170 170" role="img" aria-label="Spend by model"> <g transform="rotate(-90 85 85)">${segments}</g><text class="donut-center" x="85" y="82" style="font-size:${donutCenterFontSize(center)}px" title="${total}">${center}</text><text class="donut-sub" x="85" y="103">total spend</text></svg>`;
|
|
94
|
+
}
|
|
95
|
+
export function donutCenterFontSize(value) { return value.length >= 9 ? 10 : value.length >= 8 ? 11 : 13; }
|
|
96
|
+
export function renderModelLegend(rows) {
|
|
97
|
+
const models = donutSlices(rows);
|
|
98
|
+
const total = rows.reduce((sum, row) => sum + row.costUsd, 0);
|
|
99
|
+
return models.map((row) => `<div class="legend-row" title="${escapeHtml(row.label ?? row.key)}, ${row.costUsd}"><span class="model-name">${escapeHtml(row.label ?? row.key)}</span><strong>${formatUsd(row.costUsd)}</strong><small title="${total ? row.costUsd / total * 100 : 0}">${formatShare(total ? row.costUsd / total * 100 : 0, "spend")}</small></div>`).join("") || '<p class="empty">No usage in this range</p>';
|
|
100
|
+
}
|
|
101
|
+
export function renderSparklineSvg(values, label) {
|
|
102
|
+
if (!values.length)
|
|
103
|
+
return '<p class="empty">No usage in this range</p>';
|
|
104
|
+
const width = 280, height = 48, max = Math.max(...values, 1), min = Math.min(...values, 0), span = max - min || 1;
|
|
105
|
+
const safeLabel = escapeHtml(label);
|
|
106
|
+
if (values.length < 2) {
|
|
107
|
+
const y = (height / 2).toFixed(1);
|
|
108
|
+
return `<svg class="spark" viewBox="0 0 ${width} ${height}" role="img" aria-label="${safeLabel} trend"><path class="spark-line" d="M0 ${y} L${width} ${y}"/></svg>`;
|
|
109
|
+
}
|
|
110
|
+
const points = values.map((value, index) => `${index ? "L" : "M"}${(index * width / Math.max(values.length - 1, 1)).toFixed(1)} ${(height - 3 - (value - min) / span * 38).toFixed(1)}`);
|
|
111
|
+
const line = points.join(" ");
|
|
112
|
+
return `<svg class="spark" viewBox="0 0 ${width} ${height}" role="img" aria-label="${safeLabel} trend"><path class="spark-area" d="${line} L ${width} ${height} L 0 ${height} Z"/><path class="spark-line" d="${line}"/>${values.map((value, index) => `<circle cx="${(index * width / Math.max(values.length - 1, 1)).toFixed(1)}" cy="${(height - 3 - (value - min) / span * 38).toFixed(1)}" r="2.5"><title>${safeLabel}: ${value}</title></circle>`).join("")}</svg>`;
|
|
113
|
+
}
|
|
114
|
+
export function renderLineChartSvg(rows, metric = "costUsd", width = 880) {
|
|
115
|
+
if (!rows.length)
|
|
116
|
+
return '<p class="empty">No usage in this range</p>';
|
|
117
|
+
if (rows.length === 1) {
|
|
118
|
+
const row = rows[0];
|
|
119
|
+
const raw = row[metric] ?? 0;
|
|
120
|
+
const masked = metric === "costUsd" ? formatUsd(raw) : formatCompact(raw);
|
|
121
|
+
const center = width / 2;
|
|
122
|
+
return `<div class="single-day-chart"><svg class="line-chart" width="${width}" height="248" viewBox="0 0 ${width} 248" role="img" aria-label="${metric} over time"><circle class="chart-point" cx="${center}" cy="124" r="4"><title>${escapeHtml(row.label ?? row.key)}: ${masked}</title></circle><text class="axis" x="${center}" y="110" text-anchor="middle" title="${raw}">${masked}</text></svg><p class="single-day-caption">Single day. Pick a longer range for a trend.</p></div>`;
|
|
123
|
+
}
|
|
124
|
+
const height = 248, right = 14, top = 12, bottom = 30;
|
|
125
|
+
const values = rows.map((row) => row[metric] ?? 0), max = Math.max(...values, 1);
|
|
126
|
+
const longestTick = Math.max(...[0, .25, .5, .75, 1].map((part) => formatAxisTick(max * part, metric === "costUsd").length));
|
|
127
|
+
const left = Math.max(width < 500 ? 52 : 66, Math.ceil(longestTick * 6.7 + 8));
|
|
128
|
+
const x = (index) => left + index * (width - left - right) / Math.max(values.length - 1, 1);
|
|
129
|
+
const y = (value) => top + (height - top - bottom) * (1 - value / max);
|
|
130
|
+
const hourly = rows[0].key.length > 10;
|
|
131
|
+
const partial = rows[rows.length - 1].label?.endsWith("partial") ? 1 : 0;
|
|
132
|
+
const path = values.slice(0, values.length - partial).map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join(" ");
|
|
133
|
+
const partialPath = partial ? `<path class="chart-line chart-partial" d="M${x(values.length - 2)} ${y(values[values.length - 2])} L${x(values.length - 1)} ${y(values[values.length - 1])}"/>` : "";
|
|
134
|
+
const area = `${values.map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join(" ")} L ${x(values.length - 1)} ${height - bottom} L ${left} ${height - bottom} Z`;
|
|
135
|
+
const grid = [0, .25, .5, .75, 1].map((part) => {
|
|
136
|
+
const yy = y(max * part);
|
|
137
|
+
return `<line class="gridline" x1="${left}" y1="${yy}" x2="${width - right}" y2="${yy}"/><text class="axis" x="${left - 8}" y="${yy + 3}" text-anchor="end" title="${max * part}">${formatAxisTick(max * part, metric === "costUsd")}</text>`;
|
|
138
|
+
}).join("");
|
|
139
|
+
const points = rows.map((row, index) => `<circle class="chart-point" data-index="${index}" cx="${x(index)}" cy="${y(values[index])}" r="${hourly ? 0 : 4}" data-r="${hourly ? 0 : 4}" tabindex="0"><title>${escapeHtml(row.label ?? row.key)}: ${values[index]}</title></circle>`).join("");
|
|
140
|
+
const labels = hourly ? rows.map((row, index) => {
|
|
141
|
+
const hour = Number(row.key.slice(11));
|
|
142
|
+
if (hour === 0)
|
|
143
|
+
return `<line class="day-divider" x1="${x(index)}" y1="${top}" x2="${x(index)}" y2="${height - bottom}"/><text class="axis" x="${x(index) + 4}" y="${height - 7}">${escapeHtml(row.key.slice(5, 10))}</text>`;
|
|
144
|
+
return hour % 6 === 0 && rows.length <= 72 ? `<text class="axis axis-hour" x="${x(index)}" y="${height - 7}" text-anchor="middle">${hour}h</text>` : "";
|
|
145
|
+
}).join("") : rows.filter((_, index) => index % Math.max(1, Math.ceil(rows.length / 7)) === 0 || index === rows.length - 1).map((row) => {
|
|
146
|
+
const index = rows.indexOf(row);
|
|
147
|
+
const anchor = index === 0 && index !== rows.length - 1 ? "start" : index === rows.length - 1 && index !== 0 ? "end" : "middle";
|
|
148
|
+
return `<text class="axis" x="${x(index)}" y="${height - 7}" text-anchor="${anchor}" title="${escapeHtml(row.label ?? row.key)}">${escapeHtml(row.key.slice(5))}</text>`;
|
|
149
|
+
}).join("");
|
|
150
|
+
return `<svg class="line-chart" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${metric} over time"><defs><linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b82f6" stop-opacity=".2"/><stop offset="1" stop-color="#3b82f6" stop-opacity="0"/></linearGradient></defs>${grid}${labels}<path class="chart-area" d="${area}"/><path class="chart-line" d="${path}"/>${partialPath}<line class="chart-crosshair" data-crosshair x1="0" y1="${top}" x2="0" y2="${height - bottom}" visibility="hidden"/>${points}</svg>`;
|
|
151
|
+
}
|
|
152
|
+
/** Hourly rows (gaps zero-filled, current hour flagged partial) for ranges up to 7 days, daily rows otherwise. */
|
|
153
|
+
export function chartRows(data, now = new Date()) {
|
|
154
|
+
if (!data.byHour?.length || !data.byDay.length || data.byDay.length > 7)
|
|
155
|
+
return data.byDay;
|
|
156
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
157
|
+
const dayKey = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
158
|
+
const byKey = new Map(data.byHour.map((row) => [row.key, row]));
|
|
159
|
+
const [year, month, day] = data.byDay[0].key.split("-").map(Number);
|
|
160
|
+
const cursor = new Date(year, month - 1, day);
|
|
161
|
+
const lastDay = data.byDay[data.byDay.length - 1].key;
|
|
162
|
+
const currentKey = `${dayKey(now)} ${pad(now.getHours())}`;
|
|
163
|
+
const rows = [];
|
|
164
|
+
while (dayKey(cursor) <= lastDay) {
|
|
165
|
+
const key = `${dayKey(cursor)} ${pad(cursor.getHours())}`;
|
|
166
|
+
const partial = key === currentKey;
|
|
167
|
+
const label = `${key.slice(5, 10)} ${key.slice(11)}:00${partial ? " · partial" : ""}`;
|
|
168
|
+
rows.push({ ...(byKey.get(key) ?? { key, sessionCount: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, costUsd: 0, costComplete: true }), key, label });
|
|
169
|
+
if (partial)
|
|
170
|
+
break;
|
|
171
|
+
cursor.setHours(cursor.getHours() + 1);
|
|
172
|
+
}
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
export function createUsageChartView(initialSeries = "costUsd") {
|
|
176
|
+
let series = initialSeries;
|
|
177
|
+
return {
|
|
178
|
+
getSeries: () => series,
|
|
179
|
+
select: (next) => { series = next; },
|
|
180
|
+
render: (rows, width) => renderLineChartSvg(rows, series, width),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function formatChartTooltip(row, series) {
|
|
184
|
+
const value = row[series] ?? 0;
|
|
185
|
+
return {
|
|
186
|
+
text: `${row.label ?? row.key} · ${series === "costUsd" ? formatUsd(value) : formatCompact(value)}`,
|
|
187
|
+
title: String(value),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function attachChartInteractions(wrapper, rows, series) {
|
|
191
|
+
const points = wrapper.querySelectorAll(".chart-point");
|
|
192
|
+
const crosshair = wrapper.querySelector("[data-crosshair]");
|
|
193
|
+
const tooltip = wrapper.querySelector("[data-chart-tooltip]");
|
|
194
|
+
if (!points.length || !crosshair || !tooltip)
|
|
195
|
+
return;
|
|
196
|
+
const show = (event) => {
|
|
197
|
+
const bounds = wrapper.getBoundingClientRect();
|
|
198
|
+
const x = Math.min(bounds.width, Math.max(0, (event.clientX ?? bounds.left) - bounds.left));
|
|
199
|
+
const nearest = points.reduce((best, point) => {
|
|
200
|
+
const pointX = Number(point.getAttribute("cx") ?? 0);
|
|
201
|
+
return Math.abs(pointX - x) < Math.abs(Number(best.getAttribute("cx") ?? 0) - x) ? point : best;
|
|
202
|
+
}, points[0]);
|
|
203
|
+
const index = Number(nearest.getAttribute("data-index") ?? 0);
|
|
204
|
+
const pointX = Number(nearest.getAttribute("cx") ?? 0);
|
|
205
|
+
const row = rows[index];
|
|
206
|
+
if (!row)
|
|
207
|
+
return;
|
|
208
|
+
const formatted = formatChartTooltip(row, series);
|
|
209
|
+
points.forEach((point) => point.setAttribute("r", point === nearest ? "4" : point.getAttribute("data-r") ?? "4"));
|
|
210
|
+
crosshair.setAttribute("x1", String(pointX));
|
|
211
|
+
crosshair.setAttribute("x2", String(pointX));
|
|
212
|
+
crosshair.setAttribute("visibility", "visible");
|
|
213
|
+
tooltip.textContent = formatted.text;
|
|
214
|
+
tooltip.setAttribute("title", formatted.title);
|
|
215
|
+
tooltip.hidden = false;
|
|
216
|
+
tooltip.style.left = `${Math.min(bounds.width - 170, Math.max(0, pointX))}px`;
|
|
217
|
+
tooltip.style.top = `${Math.max(0, (event.clientY ?? bounds.top) - bounds.top - 38)}px`;
|
|
218
|
+
};
|
|
219
|
+
wrapper.addEventListener("pointermove", show);
|
|
220
|
+
wrapper.addEventListener("pointerdown", show);
|
|
221
|
+
wrapper.addEventListener("pointerleave", () => { points.forEach((point) => point.setAttribute("r", point.getAttribute("data-r") ?? "4")); crosshair.setAttribute("visibility", "hidden"); tooltip.hidden = true; });
|
|
222
|
+
}
|
|
223
|
+
export function toUsagePageData(result) {
|
|
224
|
+
return {
|
|
225
|
+
range: { ...result.range }, totals: { ...result.totals }, byDay: [...result.byDay], byHour: [...(result.byHour ?? [])],
|
|
226
|
+
byRepository: [...result.byRepository], byModel: [...result.byModel], byAgent: [...result.byAgent],
|
|
227
|
+
byRun: [...result.byRun], byOrigin: [...(result.byOrigin ?? [])],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
export function createUsagePageController(options) {
|
|
231
|
+
const breakdowns = ["day", "repo", "model", "agent", "run", "origin"];
|
|
232
|
+
const state = {
|
|
233
|
+
filters: {
|
|
234
|
+
period: options.initialFilters?.period ?? (options.initialFilters?.since ? "" : "today"),
|
|
235
|
+
repo: options.initialFilters?.repo ?? "", model: options.initialFilters?.model ?? "", agent: options.initialFilters?.agent ?? "",
|
|
236
|
+
since: options.initialFilters?.since ?? "", until: options.initialFilters?.until ?? "",
|
|
237
|
+
}, result: null, previous: null, error: undefined, loading: false,
|
|
238
|
+
selectedBreakdown: breakdowns.includes(options.initialBy) ? options.initialBy : "day",
|
|
239
|
+
breakdowns, intervalSeconds: normalizeUsageInterval(options.interval),
|
|
240
|
+
};
|
|
241
|
+
let timer;
|
|
242
|
+
let started = false;
|
|
243
|
+
let hasLoaded = false;
|
|
244
|
+
let requestSequence = 0;
|
|
245
|
+
const snapshot = () => ({ ...state, filters: { ...state.filters }, breakdowns: [...state.breakdowns] });
|
|
246
|
+
const publish = () => options.render(snapshot());
|
|
247
|
+
const query = async (url) => {
|
|
248
|
+
const response = await options.fetch(url);
|
|
249
|
+
let payload;
|
|
250
|
+
try {
|
|
251
|
+
payload = await response.json();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
payload = undefined;
|
|
255
|
+
}
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
const message = payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
|
258
|
+
? payload.error : `Usage query failed with HTTP ${response.status ?? 500}`;
|
|
259
|
+
throw new Error(message);
|
|
260
|
+
}
|
|
261
|
+
return toUsagePageData(payload);
|
|
262
|
+
};
|
|
263
|
+
const updateUrl = () => options.replaceUrl?.(buildUsagePageSearch({ filters: state.filters, by: state.selectedBreakdown }));
|
|
264
|
+
async function refresh(userInitiated = true) {
|
|
265
|
+
const sequence = ++requestSequence;
|
|
266
|
+
state.loading = userInitiated;
|
|
267
|
+
publish();
|
|
268
|
+
try {
|
|
269
|
+
const result = await query(buildUsageApiUrl(state.filters));
|
|
270
|
+
if (sequence !== requestSequence)
|
|
271
|
+
return snapshot();
|
|
272
|
+
state.result = result;
|
|
273
|
+
state.error = undefined;
|
|
274
|
+
hasLoaded = true;
|
|
275
|
+
const previous = previousWindow(state.filters.period, new Date(result.range.until));
|
|
276
|
+
const previousResult = previous ? await query(buildUsageApiUrl({ ...state.filters, period: "", ...previous })) : null;
|
|
277
|
+
if (sequence !== requestSequence)
|
|
278
|
+
return snapshot();
|
|
279
|
+
state.previous = previousResult;
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (sequence !== requestSequence)
|
|
283
|
+
return snapshot();
|
|
284
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
if (sequence === requestSequence) {
|
|
288
|
+
state.loading = false;
|
|
289
|
+
publish();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return snapshot();
|
|
293
|
+
}
|
|
294
|
+
async function setFilter(name, value) {
|
|
295
|
+
return setFilters({ [name]: String(value) });
|
|
296
|
+
}
|
|
297
|
+
async function setFilters(values) {
|
|
298
|
+
let changed = false;
|
|
299
|
+
for (const name of Object.keys(values)) {
|
|
300
|
+
if (!(name in state.filters) || state.filters[name] === String(values[name] ?? ""))
|
|
301
|
+
continue;
|
|
302
|
+
state.filters[name] = String(values[name] ?? "");
|
|
303
|
+
changed = true;
|
|
304
|
+
}
|
|
305
|
+
if (!changed)
|
|
306
|
+
return snapshot();
|
|
307
|
+
updateUrl();
|
|
308
|
+
return refresh(true);
|
|
309
|
+
}
|
|
310
|
+
function setBreakdown(name) {
|
|
311
|
+
if (!breakdowns.includes(name))
|
|
312
|
+
return;
|
|
313
|
+
state.selectedBreakdown = name;
|
|
314
|
+
updateUrl();
|
|
315
|
+
publish();
|
|
316
|
+
}
|
|
317
|
+
async function start() {
|
|
318
|
+
if (started)
|
|
319
|
+
return;
|
|
320
|
+
started = true;
|
|
321
|
+
await refresh(true);
|
|
322
|
+
timer = options.setInterval(() => { void refresh(false); }, state.intervalSeconds * 1000);
|
|
323
|
+
}
|
|
324
|
+
function stop() {
|
|
325
|
+
if (!started)
|
|
326
|
+
return;
|
|
327
|
+
started = false;
|
|
328
|
+
options.clearInterval(timer);
|
|
329
|
+
timer = undefined;
|
|
330
|
+
}
|
|
331
|
+
return { getState: snapshot, refresh, setFilter, setFilters, setBreakdown, start, stop };
|
|
332
|
+
}
|
|
333
|
+
export function startUsagePage(options, environment) {
|
|
334
|
+
const root = environment.document.getElementById("usage-root");
|
|
335
|
+
if (!root)
|
|
336
|
+
return;
|
|
337
|
+
const params = new URLSearchParams(environment.location?.search ?? "");
|
|
338
|
+
const filters = { ...options.filters };
|
|
339
|
+
for (const key of ["period", "repo", "model", "agent", "since", "until"]) {
|
|
340
|
+
if (params.has(key))
|
|
341
|
+
filters[key] = params.get(key) ?? "";
|
|
342
|
+
}
|
|
343
|
+
const requestedBy = params.get("by");
|
|
344
|
+
const initialBy = requestedBy ?? options.by;
|
|
345
|
+
const tableView = { sortKey: "costUsd", direction: -1, expanded: false, search: "" };
|
|
346
|
+
const chartView = createUsageChartView();
|
|
347
|
+
let resizeTimer;
|
|
348
|
+
const controller = createUsagePageController({
|
|
349
|
+
initialFilters: filters, initialBy, interval: options.interval,
|
|
350
|
+
fetch: environment.fetch, setInterval: environment.setInterval, clearInterval: environment.clearInterval,
|
|
351
|
+
replaceUrl: environment.replaceUrl,
|
|
352
|
+
render: (state) => renderUsageDashboard(root, state, environment.document, controller, tableView, chartView),
|
|
353
|
+
});
|
|
354
|
+
environment.window?.addEventListener("resize", () => {
|
|
355
|
+
if (resizeTimer !== undefined)
|
|
356
|
+
environment.clearTimeout?.(resizeTimer);
|
|
357
|
+
resizeTimer = environment.setTimeout?.(() => {
|
|
358
|
+
renderActiveUsageChart(root, controller.getState(), chartView);
|
|
359
|
+
resizeTimer = undefined;
|
|
360
|
+
}, 150);
|
|
361
|
+
});
|
|
362
|
+
const range = root.querySelector("[data-range]");
|
|
363
|
+
if (range)
|
|
364
|
+
range.value = controller.getState().filters.period;
|
|
365
|
+
for (const name of ["since", "until"]) {
|
|
366
|
+
const input = root.querySelector(`[data-filter="${name}"]`);
|
|
367
|
+
if (input)
|
|
368
|
+
input.value = controller.getState().filters[name];
|
|
369
|
+
}
|
|
370
|
+
const dateFields = root.querySelectorAll("[data-date-field]");
|
|
371
|
+
for (const field of dateFields)
|
|
372
|
+
field.hidden = Boolean(controller.getState().filters.period);
|
|
373
|
+
range?.addEventListener("change", (event) => {
|
|
374
|
+
const value = event.target?.value ?? "";
|
|
375
|
+
for (const field of dateFields)
|
|
376
|
+
field.hidden = Boolean(value);
|
|
377
|
+
void controller.setFilters(value ? { period: value, since: "", until: "" } : { period: value });
|
|
378
|
+
});
|
|
379
|
+
root.addEventListener("change", (event) => {
|
|
380
|
+
const target = event.target ?? null;
|
|
381
|
+
const name = target?.dataset.filter;
|
|
382
|
+
if (name)
|
|
383
|
+
void controller.setFilter(name, target?.value ?? "");
|
|
384
|
+
});
|
|
385
|
+
root.querySelector("[data-add-filter]")?.addEventListener("click", () => {
|
|
386
|
+
const menu = root.querySelector("[data-filter-menu]");
|
|
387
|
+
if (menu)
|
|
388
|
+
menu.hidden = !menu.hidden;
|
|
389
|
+
});
|
|
390
|
+
for (const button of root.querySelectorAll("[data-filter-kind]"))
|
|
391
|
+
button.addEventListener("click", () => {
|
|
392
|
+
const kind = button.dataset.filterKind;
|
|
393
|
+
const menu = root.querySelector("[data-filter-menu]");
|
|
394
|
+
const result = controller.getState().result;
|
|
395
|
+
if (!kind || !menu || !result)
|
|
396
|
+
return;
|
|
397
|
+
const groups = { repo: result.byRepository, model: result.byModel, agent: result.byAgent };
|
|
398
|
+
const field = environment.document.createElement("select");
|
|
399
|
+
field.dataset.filterChoice = kind;
|
|
400
|
+
field.innerHTML = renderUsageFilterOptions(groups[kind], kind);
|
|
401
|
+
menu.replaceChildren(field);
|
|
402
|
+
field.addEventListener("change", (event) => {
|
|
403
|
+
const value = event.target?.value ?? "";
|
|
404
|
+
if (value)
|
|
405
|
+
void controller.setFilter(kind, value);
|
|
406
|
+
menu.hidden = true;
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
root.querySelector("[data-live]")?.addEventListener("click", (event) => {
|
|
410
|
+
const target = event.target?.closest("[data-live]");
|
|
411
|
+
if (!target)
|
|
412
|
+
return;
|
|
413
|
+
target.className = target.className.includes(" on") ? "live" : "live on";
|
|
414
|
+
if (target.className.includes(" on"))
|
|
415
|
+
void controller.start();
|
|
416
|
+
else
|
|
417
|
+
controller.stop();
|
|
418
|
+
});
|
|
419
|
+
root.querySelector("[data-dismiss-error]")?.addEventListener("click", () => {
|
|
420
|
+
const banner = root.querySelector("[data-error]");
|
|
421
|
+
if (banner)
|
|
422
|
+
banner.hidden = true;
|
|
423
|
+
});
|
|
424
|
+
root.querySelector("[data-copy]")?.addEventListener("click", () => {
|
|
425
|
+
environment.copyToClipboard?.(environment.location?.href ?? "");
|
|
426
|
+
});
|
|
427
|
+
root.querySelector("[data-export]")?.addEventListener("click", () => {
|
|
428
|
+
const result = controller.getState().result;
|
|
429
|
+
if (!result)
|
|
430
|
+
return;
|
|
431
|
+
const rows = selectedRows(result, controller.getState().selectedBreakdown);
|
|
432
|
+
const csv = ["name,sessions,spend,tokens", ...rows.map((row) => [csvQuote(row.label ?? row.key), row.sessionCount, row.costUsd, row.totalTokens].join(","))].join("\n");
|
|
433
|
+
const anchor = environment.document.createElement("a");
|
|
434
|
+
anchor.setAttribute("href", `data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`);
|
|
435
|
+
anchor.setAttribute("download", `usage-${controller.getState().filters.period}.csv`);
|
|
436
|
+
anchor.click();
|
|
437
|
+
});
|
|
438
|
+
environment.document.addEventListener("keydown", (event) => {
|
|
439
|
+
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(environment.document.activeElement?.tagName ?? "")) {
|
|
440
|
+
event.preventDefault?.();
|
|
441
|
+
root.querySelector("[data-search]")?.focus();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
void controller.start();
|
|
445
|
+
return controller;
|
|
446
|
+
}
|
|
447
|
+
export function filterAndSortUsageRows(rows, search, sortKey, direction) {
|
|
448
|
+
const matches = rows.filter((row) => (row.label ?? row.key).toLowerCase().includes(search.toLowerCase()));
|
|
449
|
+
const key = sortKey === "share" ? "costUsd" : sortKey;
|
|
450
|
+
return matches.sort((left, right) => {
|
|
451
|
+
const first = key === "key" ? left.label ?? left.key : left[key];
|
|
452
|
+
const second = key === "key" ? right.label ?? right.key : right[key];
|
|
453
|
+
return (typeof first === "number" && typeof second === "number" ? first - second : String(first).localeCompare(String(second))) * direction;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
export function captureUsageSearchFocus(document, input) {
|
|
457
|
+
if (!input || document.activeElement !== input)
|
|
458
|
+
return { focused: false };
|
|
459
|
+
return { focused: true, value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd };
|
|
460
|
+
}
|
|
461
|
+
export function restoreUsageSearchFocus(input, focus) {
|
|
462
|
+
if (!input || !focus.focused)
|
|
463
|
+
return;
|
|
464
|
+
if (focus.value !== undefined)
|
|
465
|
+
input.value = focus.value;
|
|
466
|
+
input.focus();
|
|
467
|
+
if (focus.selectionStart != null && focus.selectionEnd != null)
|
|
468
|
+
input.setSelectionRange?.(focus.selectionStart, focus.selectionEnd);
|
|
469
|
+
}
|
|
470
|
+
export function csvQuote(value) { return `"${value.replaceAll('"', '""')}"`; }
|
|
471
|
+
export function renderUsageFilterOptions(rows, kind) {
|
|
472
|
+
return `<option value="">Select ${kind}</option>${rows.map((row) => `<option value="${escapeHtml(row.key)}">${escapeHtml(row.label ?? row.key)}</option>`).join("")}`;
|
|
473
|
+
}
|
|
474
|
+
export function renderUsageTableRows(rows, tableSpend, expanded) {
|
|
475
|
+
return rows.map((row, index) => {
|
|
476
|
+
const label = row.label ?? row.key;
|
|
477
|
+
const share = tableSpend ? row.costUsd / tableSpend * 100 : 0;
|
|
478
|
+
return `<tr${index >= 10 && !expanded ? " hidden" : ""}><td title="${escapeHtml(label)}">${escapeHtml(label)}</td><td title="${row.sessionCount}">${formatCompact(row.sessionCount)}</td><td title="${row.costUsd}">${row.costComplete ? "" : "≥ "}${formatUsd(row.costUsd)}</td><td title="${row.totalTokens}">${formatCompact(row.totalTokens)}</td><td title="${share}"><span class="share"><i style="width:${share}%"></i></span>${formatShare(share, "spend")}</td></tr>`;
|
|
479
|
+
}).join("");
|
|
480
|
+
}
|
|
481
|
+
function renderUsageDashboard(root, state, document, controller, tableView, chartView) {
|
|
482
|
+
const loading = root.querySelector("[data-loading]");
|
|
483
|
+
if (loading) {
|
|
484
|
+
loading.textContent = state.loading ? "Loading usage…" : "";
|
|
485
|
+
loading.hidden = !state.loading;
|
|
486
|
+
}
|
|
487
|
+
const error = root.querySelector("[data-error]");
|
|
488
|
+
if (error) {
|
|
489
|
+
error.hidden = !state.error;
|
|
490
|
+
const message = error.querySelector("span");
|
|
491
|
+
if (message)
|
|
492
|
+
message.textContent = state.error ?? "";
|
|
493
|
+
}
|
|
494
|
+
const content = root.querySelector("[data-dashboard]");
|
|
495
|
+
if (!content)
|
|
496
|
+
return;
|
|
497
|
+
const priorSearch = content.querySelector("[data-search]");
|
|
498
|
+
const focus = captureUsageSearchFocus(document, priorSearch);
|
|
499
|
+
for (const button of root.querySelectorAll("[data-breakdown]"))
|
|
500
|
+
button.setAttribute("aria-pressed", String(button.dataset.breakdown === state.selectedBreakdown));
|
|
501
|
+
if (!state.result)
|
|
502
|
+
return;
|
|
503
|
+
const data = state.result;
|
|
504
|
+
const rows = chartRows(data);
|
|
505
|
+
const totals = data.totals;
|
|
506
|
+
const chips = root.querySelector("[data-chips]");
|
|
507
|
+
if (chips) {
|
|
508
|
+
const activeFilters = ["repo", "model", "agent"].filter((name) => state.filters[name]);
|
|
509
|
+
chips.innerHTML = activeFilters.map((name) => `<span class="filter-chip">${name}: ${escapeHtml(state.filters[name])}<button data-remove-filter="${name}" aria-label="Remove ${name} filter">×</button></span>`).join("");
|
|
510
|
+
chips.querySelectorAll("[data-remove-filter]").forEach((button) => button.addEventListener("click", () => {
|
|
511
|
+
void controller.setFilter(button.dataset.removeFilter, "");
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
const cache = totals.cachedTokens / (totals.inputTokens + totals.cachedTokens || 1) * 100;
|
|
515
|
+
const kpis = [
|
|
516
|
+
["Spend", formatCost(totals.costUsd, totals.costComplete, totals.sessionsWithoutCost), totals.costUsd, "spend"],
|
|
517
|
+
["Sessions", escapeHtml(formatCompact(totals.sessionCount)), totals.sessionCount, "sessions"],
|
|
518
|
+
["Total tokens", escapeHtml(formatCompact(totals.totalTokens)), totals.totalTokens, "tokens"],
|
|
519
|
+
["Cache hit rate", `${cache.toFixed(1)}%`, cache, "cache"],
|
|
520
|
+
];
|
|
521
|
+
const previous = state.previous?.totals;
|
|
522
|
+
const periodLabel = state.filters.period;
|
|
523
|
+
const kpiHtml = kpis.map(([label, value, current, key], index) => {
|
|
524
|
+
const previousValue = key === "spend" ? previous?.costUsd : key === "sessions" ? previous?.sessionCount : key === "tokens" ? previous?.totalTokens : previous ? previous.cachedTokens / (previous.inputTokens + previous.cachedTokens || 1) * 100 : undefined;
|
|
525
|
+
const delta = previousValue === undefined ? undefined : computeDelta(current, previousValue, periodLabel);
|
|
526
|
+
const deltaHtml = delta ? `<p class="delta">${delta.direction === "up" ? "↗" : "↘"} ${delta.percent.toFixed(1)}% <span>vs previous ${periodLabel}</span></p>` : "";
|
|
527
|
+
const raw = key === "spend" ? formatUsd(current) : key === "cache" ? `${current.toFixed(2)}%` : new Intl.NumberFormat("en-US").format(current);
|
|
528
|
+
return `<article class="card kpi"><div class="kpi-heading">${label}<span class="range-tag">${escapeHtml(periodLabel || "custom")}</span></div><strong class="kpi-value" title="${raw}">${value}</strong>${deltaHtml}${key === "cache" ? '<span class="kpi-note">Share of input + cached tokens</span>' : ""}${renderSparklineSvg(rows.map((row) => key === "spend" ? row.costUsd : key === "sessions" ? row.sessionCount : key === "tokens" ? row.totalTokens : row.cachedTokens / (row.inputTokens + row.cachedTokens || 1) * 100), label)}</article>`;
|
|
529
|
+
}).join("");
|
|
530
|
+
const modelLegend = renderModelLegend(data.byModel);
|
|
531
|
+
const repoMax = Math.max(...data.byRepository.map((row) => row.costUsd), 1);
|
|
532
|
+
const repoRows = [...data.byRepository].sort((a, b) => b.costUsd - a.costUsd).map((row) => `<div class="rank-row" title="${escapeHtml(row.label ?? row.key)}: ${formatUsd(row.costUsd)}"><span>${escapeHtml(row.label ?? row.key)}</span><i><b style="width:${row.costUsd / repoMax * 100}%"></b></i><strong>${formatUsd(row.costUsd)}</strong></div>`).join("") || '<p class="empty">No usage in this range</p>';
|
|
533
|
+
const outcomes = [["Completed", totals.completedSessionCount, "#3b82f6"], ["Failed", totals.failedSessionCount, "#e5484d"], ["Active", totals.activeSessionCount, "#93c5fd"], ["Other", Math.max(0, totals.sessionCount - totals.completedSessionCount - totals.failedSessionCount - totals.activeSessionCount), "#404040"]];
|
|
534
|
+
const outcomeBar = outcomes.map(([name, count, color]) => `<i style="width:${totals.sessionCount ? count / totals.sessionCount * 100 : 0}%;background:${color}" title="${name}: ${formatCompact(count)}, ${formatShare(totals.sessionCount ? count / totals.sessionCount * 100 : 0, "sessions")}"></i>`).join("");
|
|
535
|
+
const outcomeLegend = outcomes.map(([name, count]) => { const share = totals.sessionCount ? count / totals.sessionCount * 100 : 0; return `<div><span>${name}</span><strong title="${count}">${formatCompact(count)}</strong><small title="${share}">${formatShare(share, "sessions")}</small></div>`; }).join("");
|
|
536
|
+
const harness = data.byAgent.map((row) => { const spendShare = totals.costUsd ? row.costUsd / totals.costUsd * 100 : 0; const sessionShare = totals.sessionCount ? row.sessionCount / totals.sessionCount * 100 : 0; return `<div class="harness-row"><strong>${escapeHtml(row.label ?? row.key)}</strong><span class="track"><i style="width:${spendShare}%"></i></span><small title="${spendShare}">${formatShare(spendShare, "spend")}</small><span class="track"><i style="width:${sessionShare}%"></i></span><small title="${sessionShare}">${formatShare(sessionShare, "sessions")}</small></div>`; }).join("") || '<p class="empty">No usage in this range</p>';
|
|
537
|
+
const origins = data.byOrigin.map((row) => { const sessionShare = totals.sessionCount ? row.sessionCount / totals.sessionCount * 100 : 0; const spendShare = totals.costUsd ? row.costUsd / totals.costUsd * 100 : 0; return `<div class="origin-row"><strong>${escapeHtml(row.label ?? row.key)}</strong><span title="${sessionShare}">${formatShare(sessionShare, "sessions")}</span><span title="${spendShare}">${formatShare(spendShare, "spend")}</span></div>`; }).join("") || '<p class="empty">No usage in this range</p>';
|
|
538
|
+
const sourceRows = selectedRows(data, state.selectedBreakdown);
|
|
539
|
+
const tableSpend = sourceRows.reduce((sum, row) => sum + row.costUsd, 0);
|
|
540
|
+
const allRows = filterAndSortUsageRows(sourceRows, tableView.search, tableView.sortKey, tableView.direction);
|
|
541
|
+
const table = allRows.length ? `<div class="table-scroll"><table><thead><tr><th><button data-sort="key">Name${tableView.sortKey === "key" ? (tableView.direction < 0 ? " ↓" : " ↑") : ""}</button></th><th><button data-sort="sessionCount">Sessions${tableView.sortKey === "sessionCount" ? (tableView.direction < 0 ? " ↓" : " ↑") : ""}</button></th><th><button data-sort="costUsd">Spend${tableView.sortKey === "costUsd" ? (tableView.direction < 0 ? " ↓" : " ↑") : ""}</button></th><th><button data-sort="totalTokens">Tokens${tableView.sortKey === "totalTokens" ? (tableView.direction < 0 ? " ↓" : " ↑") : ""}</button></th><th><button data-sort="share">Share of spend${tableView.sortKey === "share" ? (tableView.direction < 0 ? " ↓" : " ↑") : ""}</button></th></tr></thead><tbody>${renderUsageTableRows(allRows, tableSpend, tableView.expanded)}</tbody></table></div>` : '<p class="empty">No usage in this range</p>';
|
|
542
|
+
content.innerHTML = `<section class="kpis">${kpiHtml}</section><section class="card chart-card"><div class="card-heading"><h2>Spend over time</h2><div class="series-switch">${["costUsd", "totalTokens", "sessionCount"].map((series) => `<button data-series="${series}" aria-pressed="${chartView.getSeries() === series}">${series === "costUsd" ? "Spend" : series === "totalTokens" ? "Tokens" : "Sessions"}</button>`).join("")}</div></div><div class="chart-wrap"><div data-chart-svg>${renderLineChartSvg(rows, chartView.getSeries(), 880)}</div><div class="chart-tooltip" data-chart-tooltip hidden></div></div></section><section class="two-col"><article class="card"><div class="card-heading"><h2>Spend by model</h2><span>${escapeHtml(periodLabel || "custom")}</span></div><div class="donut-layout">${renderDonutSvg(data.byModel)}<div class="model-legend">${modelLegend}</div></div></article><article class="card"><div class="card-heading"><h2>Spend by repository</h2><span>${escapeHtml(periodLabel || "custom")}</span></div><div class="rank-list">${repoRows}</div></article></section><section class="three-col"><article class="card"><div class="card-heading"><h2>Sessions by outcome</h2></div><div class="outcome-bar">${outcomeBar}</div><div class="outcome-legend">${outcomeLegend}</div></article><article class="card"><div class="card-heading"><h2>Harness</h2><span>Spend and sessions</span></div><div class="harness-list">${harness}</div></article><article class="card"><div class="card-heading"><h2>Origin</h2><span>Worker and orchestrator</span></div><div class="origin-list">${origins}</div></article></section><section class="card table-card"><div class="card-heading"><h2>Breakdown</h2><input data-search type="search" placeholder="Search rows" aria-label="Search rows" value="${escapeHtml(tableView.search)}"></div><nav class="tabs" aria-label="Usage breakdowns">${state.breakdowns.map((name) => `<button data-breakdown="${name}" aria-pressed="${name === state.selectedBreakdown}">${name === "repo" ? "Repository" : name[0].toUpperCase() + name.slice(1)}</button>`).join("")}</nav>${allRows.length > 10 ? `<button class="show-all" data-show-all>${tableView.expanded ? "Show top 10" : `Show all ${allRows.length}`}</button>` : ""}${table}</section>`;
|
|
543
|
+
restoreUsageSearchFocus(content.querySelector("[data-search]"), focus);
|
|
544
|
+
renderActiveUsageChart(root, state, chartView);
|
|
545
|
+
content.querySelectorAll("[data-breakdown]").forEach((button) => button.addEventListener("click", () => controller.setBreakdown(button.dataset.breakdown)));
|
|
546
|
+
content.querySelectorAll("[data-series]").forEach((button) => button.addEventListener("click", () => {
|
|
547
|
+
chartView.select(button.dataset.series);
|
|
548
|
+
renderActiveUsageChart(root, state, chartView);
|
|
549
|
+
content.querySelectorAll("[data-series]").forEach((seriesButton) => seriesButton.setAttribute("aria-pressed", String(seriesButton.dataset.series === chartView.getSeries())));
|
|
550
|
+
}));
|
|
551
|
+
const search = content.querySelector("[data-search]");
|
|
552
|
+
search?.addEventListener("input", () => {
|
|
553
|
+
tableView.search = search.value ?? "";
|
|
554
|
+
renderUsageDashboard(root, state, document, controller, tableView, chartView);
|
|
555
|
+
});
|
|
556
|
+
content.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
|
557
|
+
const key = button.dataset.sort ?? "costUsd";
|
|
558
|
+
tableView.direction = tableView.sortKey === key ? tableView.direction * -1 : -1;
|
|
559
|
+
tableView.sortKey = key;
|
|
560
|
+
renderUsageDashboard(root, state, document, controller, tableView, chartView);
|
|
561
|
+
}));
|
|
562
|
+
content.querySelector("[data-show-all]")?.addEventListener("click", () => {
|
|
563
|
+
tableView.expanded = !tableView.expanded;
|
|
564
|
+
renderUsageDashboard(root, state, document, controller, tableView, chartView);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
function renderActiveUsageChart(root, state, chartView) {
|
|
568
|
+
const wrapper = root.querySelector(".chart-wrap");
|
|
569
|
+
const target = wrapper?.querySelector("[data-chart-svg]");
|
|
570
|
+
if (!wrapper || !target || !state.result)
|
|
571
|
+
return;
|
|
572
|
+
const width = wrapper.clientWidth || 880;
|
|
573
|
+
const rows = chartRows(state.result);
|
|
574
|
+
target.innerHTML = chartView.render(rows, width);
|
|
575
|
+
attachChartInteractions(wrapper, rows, chartView.getSeries());
|
|
576
|
+
}
|
|
577
|
+
function selectedRows(data, by) {
|
|
578
|
+
const groups = { day: data.byDay, repo: data.byRepository, model: data.byModel, agent: data.byAgent, run: data.byRun, origin: data.byOrigin };
|
|
579
|
+
return [...groups[by]].sort((a, b) => b.costUsd - a.costUsd);
|
|
580
|
+
}
|
|
581
|
+
function escapeHtml(value) { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char); }
|
|
582
|
+
export function renderUsagePage(options = {}) {
|
|
583
|
+
const pageOptions = { by: options.by ?? "day", interval: options.interval ?? 2, filters: options.filters ?? {} };
|
|
584
|
+
const serializedOptions = JSON.stringify(pageOptions).replaceAll("<", "\\u003c");
|
|
585
|
+
const behaviorSource = [normalizeUsageInterval, buildUsageApiUrl, buildUsagePageSearch, formatCompact, formatUsd, formatAxisTick, formatShare, formatCost, previousWindow, computeDelta, donutSlices, donutCenterFontSize, renderModelLegend, renderDonutSvg, renderSparklineSvg, renderLineChartSvg, chartRows, createUsageChartView, formatChartTooltip, attachChartInteractions, toUsagePageData, createUsagePageController, startUsagePage, filterAndSortUsageRows, captureUsageSearchFocus, restoreUsageSearchFocus, csvQuote, renderUsageFilterOptions, renderUsageTableRows, renderUsageDashboard, renderActiveUsageChart, selectedRows, escapeHtml]
|
|
586
|
+
.map((behavior) => Function.prototype.toString.call(behavior)).join("\n\n");
|
|
587
|
+
const topbar = renderTopBar({ pages: options.pages ?? [{ label: "Home", path: "/" }, { label: "Review", path: "/review" }, { label: "Setup", path: "/setup" }, { label: "Usage", path: "/usage" }], activePath: "/usage", title: "Usage" });
|
|
588
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CodeDeck usage</title><link rel="icon" href="${LOGO_FAVICON_HREF}"><style>${BRAND_CSS}
|
|
589
|
+
${USAGE_CSS}
|
|
590
|
+
</style></head><body>${topbar}<main id="usage-root"><header class="usage-header"><h1>Usage</h1><div class="usage-actions"><label class="range-picker" aria-label="Range"><select data-range data-filter="period"><option value="today">Today</option><option value="3d">Last 3 days</option><option value="7d">Last 7 days</option><option value="30d">Last 30 days</option><option value="all">All time</option><option value="">Custom</option></select><span aria-hidden="true">⌄</span></label><label class="date-picker" data-date-field>Since<input data-filter="since" type="date"></label><label class="date-picker" data-date-field>Until<input data-filter="until" type="date"></label><button class="button ghost-button" data-copy>Copy link</button><button class="button ghost-button" data-export>Export CSV</button></div></header><section class="filterbar"><div class="filter-tools"><button class="button" data-add-filter>Add filter</button><div data-filter-menu hidden><button data-filter-kind="repo" data-filter="repo">Repository</button><button data-filter-kind="model" data-filter="model">Model</button><button data-filter-kind="agent" data-filter="agent">Agent</button></div><div data-chips></div><button data-live class="live on">● Live refresh</button></div></section><div data-loading role="status" hidden></div><div data-error class="error" role="alert" hidden><span></span><button data-dismiss-error>Dismiss</button></div><div data-dashboard class="dashboard"></div></main><script>
|
|
591
|
+
${behaviorSource}
|
|
592
|
+
const usageScript=document.currentScript;
|
|
593
|
+
const usageEnvironment={fetch:(...args)=>globalThis.fetch(...args),setInterval:(...args)=>globalThis.setInterval(...args),clearInterval:(...args)=>globalThis.clearInterval(...args),setTimeout:(...args)=>globalThis.setTimeout(...args),clearTimeout:(...args)=>globalThis.clearTimeout(...args),document:globalThis.document,location:globalThis.location,window:globalThis,replaceUrl:(search)=>globalThis.history.replaceState(null,"",globalThis.location.pathname+search),copyToClipboard:(text)=>globalThis.navigator.clipboard.writeText(text)};
|
|
594
|
+
startUsagePage(${serializedOptions},usageEnvironment);
|
|
595
|
+
</script></body></html>`;
|
|
596
|
+
}
|
|
597
|
+
export const USAGE_CSS = `
|
|
598
|
+
.topbar{height:52px;display:flex;align-items:center;gap:24px;padding:0 28px;border-bottom:1px solid var(--border);background:var(--bg)}.brand{display:flex;align-items:center;gap:8px;color:var(--text);text-decoration:none;font-weight:600;font-size:15px;letter-spacing:-.3px}.brand svg{display:block}.topbar-title{font-size:13px;color:var(--text-muted);border-left:1px solid var(--border-strong);padding-left:18px}.topbar nav{display:flex;gap:20px;margin-left:auto}.topbar nav a{color:var(--text-faint);font-size:12px;text-decoration:none}.topbar nav a.active{color:var(--text);font-weight:550}
|
|
599
|
+
main{max-width:1440px;margin:0 auto;padding:24px 30px 60px}.usage-header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.usage-header h1{margin:0;font-size:21px;font-weight:600;letter-spacing:-.3px}.usage-actions{display:flex;align-items:center;gap:8px}.range-picker{position:relative;display:flex;align-items:center}.range-picker select{appearance:none;padding-right:28px!important;min-width:122px!important}.range-picker span{position:absolute;right:10px;color:var(--text-faint);pointer-events:none}.date-picker{display:flex;align-items:center;gap:8px;color:var(--text-muted);font-size:11px}.date-picker input{height:34px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:12px}.ghost-button{background:transparent}.filterbar{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}.filterbar label{display:grid;gap:6px;color:var(--text-muted);font-size:11px}.filterbar select,.filterbar input,.table-card input,.range-picker select{height:34px;min-width:130px;padding:0 10px;border:1px solid var(--border-strong);border-radius:6px;background:#0a0a0a;color:var(--text);font-size:12px}.filter-tools{display:flex;gap:7px;align-items:center;width:100%;position:relative}.filter-tools [data-live]{margin-left:auto}.button,.filter-tools button,.show-all{border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text-muted);padding:8px 10px;font-size:11px;cursor:pointer}.filter-tools button:hover,.show-all:hover{background:var(--surface-hover);color:var(--text)}[data-filter-menu]{position:absolute;z-index:4;top:38px;left:0;padding:6px;background:var(--surface-raised);border:1px solid var(--border-strong);border-radius:7px}[data-filter-menu] button{display:block;width:100%;text-align:left}.live{color:var(--success)!important}.live:not(.on){color:var(--text-faint)!important}.pill{display:inline-block;border:1px solid var(--border-strong);border-radius:99px;background:var(--surface-raised);color:var(--text-muted);font-size:10px;padding:3px 7px;white-space:nowrap}
|
|
600
|
+
.filter-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border-strong);border-radius:99px;background:var(--surface-raised);color:var(--text-muted);font-size:10px;padding:4px 7px}.filter-chip button{border:0!important;padding:0!important;background:transparent!important;color:var(--text-faint)!important}
|
|
601
|
+
.error{display:flex;justify-content:space-between;align-items:center;margin:0 0 14px;padding:10px 12px;border:1px solid #65363a;border-radius:6px;background:#1c0d0e;color:#f2a5a7;font-size:12px}[hidden]{display:none!important}[data-loading]{color:var(--text-muted);font-size:12px;margin:8px 0}.dashboard{display:grid;gap:14px}.card{min-width:0;border:1px solid var(--border);border-radius:8px;background:var(--surface);padding:16px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.kpi{position:relative;min-height:142px;padding-bottom:34px;overflow:hidden}.kpi-heading{display:flex;align-items:center;gap:8px;color:var(--text-muted);font-size:11px}.range-tag{margin-left:auto;font:10px var(--font-mono);color:var(--text-faint)}.kpi-value{display:block;margin-top:14px;font-size:25px;letter-spacing:-.7px;font-weight:550;font-variant-numeric:tabular-nums}.delta{margin:4px 0 0;color:var(--blue-2);font-size:10px}.delta span,.kpi-note{color:var(--text-faint)}.kpi-note{font-size:10px}.spark{position:absolute;left:0;right:0;bottom:0;width:100%;height:38px;overflow:visible}.spark-area{fill:url(#chart-fill)}.spark-line{fill:none;stroke:var(--blue-chart);stroke-width:1.6}.spark circle{fill:var(--blue-chart)}
|
|
602
|
+
th button{border:0;background:transparent;color:var(--text-faint);padding:0;font:inherit;cursor:pointer}
|
|
603
|
+
.card-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.card-heading h2{margin:0;font-size:13px;font-weight:550}.card-heading>span{font-size:10px;color:var(--text-faint);font-family:var(--font-mono)}.series-switch,.tabs{display:flex;gap:4px}.series-switch button,.tabs button{border:1px solid transparent;border-radius:5px;background:transparent;color:var(--text-faint);padding:5px 8px;font-size:10px;cursor:pointer}.series-switch button:focus,.series-switch button:hover,.tabs button[aria-pressed="true"]{border-color:var(--border-strong);background:var(--surface-raised);color:var(--text)}.chart-wrap{width:100%;overflow:hidden;position:relative}.line-chart{display:block;width:100%;height:auto}.single-day-caption{margin:0;color:var(--text-faint);font-size:11px;text-align:center}.chart-crosshair{stroke:#bfdbfe;stroke-width:1;stroke-dasharray:3 3;pointer-events:none}.chart-tooltip{position:absolute;z-index:2;max-width:170px;padding:7px 9px;border:1px solid var(--border-strong);border-radius:6px;background:#111;color:var(--text);font:11px var(--font-mono);pointer-events:none;white-space:nowrap}.gridline{stroke:var(--grid);stroke-dasharray:3 4}.axis{font:11px var(--font-mono);fill:var(--text-faint)}.chart-area{fill:url(#chart-fill)}.chart-line{fill:none;stroke:var(--blue-chart);stroke-width:2}.chart-partial{stroke-dasharray:4 4}.day-divider{stroke:var(--border-strong)}.axis-hour{opacity:.6}.chart-point{fill:var(--blue-chart);stroke:var(--bg);stroke-width:2}.two-col,.three-col{display:grid;gap:12px;align-items:stretch}.two-col{grid-template-columns:1fr 1fr}.three-col{grid-template-columns:1fr 1.1fr 1fr}.donut-layout{display:grid;grid-template-columns:minmax(130px, .85fr) 1.15fr;align-items:center;gap:12px}.donut-svg{width:100%;max-height:210px;overflow:visible}.donut-seg{fill:none;stroke-width:22;cursor:pointer}.donut-center{text-anchor:middle;font:600 13px var(--font-mono);fill:var(--text)}.donut-sub{text-anchor:middle;font:10px var(--font-sans);fill:var(--text-faint)}.model-legend{display:grid;align-content:start;gap:4px;min-width:0}.legend-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:10px}.legend-row .model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:10px var(--font-mono);color:var(--text-muted)}.legend-row strong{font:11px var(--font-mono)}.legend-row small{grid-column:1/-1;color:var(--text-faint);font-size:10px}.rank-list{max-height:246px;overflow:auto}.rank-row{display:grid;grid-template-columns:minmax(80px,1.1fr) minmax(50px,1fr) auto;align-items:center;gap:9px;padding:7px 0;border-bottom:1px solid var(--border);font-size:10px}.rank-row>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted)}.rank-row>strong{font:10px var(--font-mono)}.rank-row>i,.track{height:4px;background:var(--surface-raised);border-radius:5px;overflow:hidden}.rank-row>i>b,.track>i{display:block;height:100%;background:var(--blue-chart);border-radius:5px}
|
|
604
|
+
.outcome-bar{display:flex;height:9px;border-radius:8px;overflow:hidden;background:var(--surface-raised)}.outcome-bar i{min-width:0}.outcome-legend{display:grid;gap:8px;margin-top:14px}.outcome-legend>div{display:grid;grid-template-columns:1fr auto;gap:3px 10px;font-size:10px;color:var(--text-muted)}.outcome-legend strong{font:11px var(--font-mono);color:var(--text)}.outcome-legend small{grid-column:1/-1;color:var(--text-faint);font-size:10px}.harness-list,.origin-list{display:grid;gap:12px}.harness-row{display:grid;grid-template-columns:minmax(56px,.7fr) minmax(38px,1fr) auto minmax(38px,1fr) auto;gap:8px;align-items:center;font-size:10px}.harness-row strong,.origin-row strong{font:10px var(--font-mono);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.harness-row small{font-size:10px;color:var(--text-faint);white-space:nowrap}.origin-row{display:grid;grid-template-columns:1fr auto auto;gap:12px;border-bottom:1px solid var(--border);padding:8px 0;font-size:10px}.origin-row span{color:var(--text-muted);white-space:nowrap}.empty{margin:20px 0;color:var(--text-faint);font-size:11px;text-align:center}.table-card{padding-bottom:10px}.table-card .card-heading{margin-bottom:8px}.table-card input{height:29px;min-width:160px}.tabs{border-bottom:1px solid var(--border);margin-bottom:5px}.tabs button{padding:8px 10px;border-radius:0}.tabs button[aria-pressed="true"]{border:0;border-bottom:2px solid var(--blue);background:transparent}.table-scroll{width:100%;overflow:auto}table{width:100%;border-collapse:collapse;font-size:11px}th{text-align:left;color:var(--text-faint);font-weight:450;border-bottom:1px solid var(--border)}th,td{padding:10px 9px;white-space:nowrap}td{border-bottom:1px solid var(--border);color:var(--text-muted)}td:first-child{max-width:280px;overflow:hidden;text-overflow:ellipsis;color:var(--text)}td:nth-child(2),td:nth-child(3),td:nth-child(4){font-family:var(--font-mono);font-variant-numeric:tabular-nums}.share{display:inline-block;width:65px;height:4px;margin-right:8px;background:var(--surface-raised);vertical-align:middle}.share i{display:block;height:100%;background:var(--blue-chart)}.show-all{margin:6px 0}
|
|
605
|
+
@media(max-width:900px){main{padding:18px 18px 40px}.three-col{grid-template-columns:repeat(2,minmax(0,1fr))}.three-col>.card:last-child{grid-column:1/-1}}
|
|
606
|
+
@media(max-width:600px){.usage-header{align-items:flex-start;flex-direction:column}.usage-actions{width:100%;flex-wrap:wrap}.range-picker{flex:1}.range-picker select{width:100%}.usage-actions .ghost-button{flex:1}.topbar{padding:0 14px;gap:12px}.topbar-title{padding-left:10px}.topbar nav{gap:10px}.topbar nav a{font-size:10px}main{padding:14px 12px 36px}.filterbar{align-items:center;display:flex;gap:8px}.filter-tools{margin:0;display:flex;flex-wrap:wrap}.filter-tools button{padding:8px}.kpis{grid-template-columns:1fr;gap:8px}.kpi{min-height:128px;padding:12px 10px 32px}.kpi-value{font-size:20px}.two-col,.three-col{grid-template-columns:1fr}.three-col>.card:last-child{grid-column:auto}.card{padding:13px}.donut-layout{grid-template-columns:1fr;gap:6px}.donut-svg{width:180px;height:180px;max-height:none;justify-self:center}.donut-center{font-size:13px}.rank-list{max-height:230px}.harness-row{grid-template-columns:minmax(55px,.7fr) minmax(30px,1fr) auto minmax(30px,1fr) auto;gap:5px}.harness-row small{font-size:10px}.axis{font-size:11px}table{font-size:10px}th,td{padding:9px 6px}td:first-child{max-width:110px}.table-card input{min-width:120px}.series-switch button{padding:5px 4px;font-size:10px}}
|
|
607
|
+
`;
|
|
608
|
+
export const USAGE_PAGE = renderUsagePage();
|
|
609
|
+
//# sourceMappingURL=usage-page.js.map
|