diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts
new file mode 100644
index 0000000000..32dd457f5e
--- /dev/null
+++ b/ui/src/ui/views/usage-metrics.ts
@@ -0,0 +1,615 @@
+import { html } from "lit";
+import { UsageSessionEntry, UsageTotals, UsageAggregates } from "./usageTypes.ts";
+
+const CHARS_PER_TOKEN = 4;
+
+function charsToTokens(chars: number): number {
+ return Math.round(chars / CHARS_PER_TOKEN);
+}
+
+function formatTokens(n: number): string {
+ if (n >= 1_000_000) {
+ return `${(n / 1_000_000).toFixed(1)}M`;
+ }
+ if (n >= 1_000) {
+ return `${(n / 1_000).toFixed(1)}K`;
+ }
+ return String(n);
+}
+
+function formatHourLabel(hour: number): string {
+ const date = new Date();
+ date.setHours(hour, 0, 0, 0);
+ return date.toLocaleTimeString(undefined, { hour: "numeric" });
+}
+
+function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" | "utc") {
+ const hourErrors = Array.from({ length: 24 }, () => 0);
+ const hourMsgs = Array.from({ length: 24 }, () => 0);
+
+ for (const session of sessions) {
+ const usage = session.usage;
+ if (!usage?.messageCounts || usage.messageCounts.total === 0) {
+ continue;
+ }
+ const start = usage.firstActivity ?? session.updatedAt;
+ const end = usage.lastActivity ?? session.updatedAt;
+ if (!start || !end) {
+ continue;
+ }
+ const startMs = Math.min(start, end);
+ const endMs = Math.max(start, end);
+ const durationMs = Math.max(endMs - startMs, 1);
+ const totalMinutes = durationMs / 60000;
+
+ let cursor = startMs;
+ while (cursor < endMs) {
+ const date = new Date(cursor);
+ const hour = getZonedHour(date, timeZone);
+ const nextHour = setToHourEnd(date, timeZone);
+ const nextMs = Math.min(nextHour.getTime(), endMs);
+ const minutes = Math.max((nextMs - cursor) / 60000, 0);
+ const share = minutes / totalMinutes;
+ hourErrors[hour] += usage.messageCounts.errors * share;
+ hourMsgs[hour] += usage.messageCounts.total * share;
+ cursor = nextMs + 1;
+ }
+ }
+
+ return hourMsgs
+ .map((msgs, hour) => {
+ const errors = hourErrors[hour];
+ const rate = msgs > 0 ? errors / msgs : 0;
+ return {
+ hour,
+ rate,
+ errors,
+ msgs,
+ };
+ })
+ .filter((entry) => entry.msgs > 0 && entry.errors > 0)
+ .toSorted((a, b) => b.rate - a.rate)
+ .slice(0, 5)
+ .map((entry) => ({
+ label: formatHourLabel(entry.hour),
+ value: `${(entry.rate * 100).toFixed(2)}%`,
+ sub: `${Math.round(entry.errors)} errors · ${Math.round(entry.msgs)} msgs`,
+ }));
+}
+
+type UsageMosaicStats = {
+ hasData: boolean;
+ totalTokens: number;
+ hourTotals: number[];
+ weekdayTotals: Array<{ label: string; tokens: number }>;
+};
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+function getZonedHour(date: Date, zone: "local" | "utc"): number {
+ return zone === "utc" ? date.getUTCHours() : date.getHours();
+}
+
+function getZonedWeekday(date: Date, zone: "local" | "utc"): number {
+ return zone === "utc" ? date.getUTCDay() : date.getDay();
+}
+
+function setToHourEnd(date: Date, zone: "local" | "utc"): Date {
+ const next = new Date(date);
+ if (zone === "utc") {
+ next.setUTCMinutes(59, 59, 999);
+ } else {
+ next.setMinutes(59, 59, 999);
+ }
+ return next;
+}
+
+function buildUsageMosaicStats(
+ sessions: UsageSessionEntry[],
+ timeZone: "local" | "utc",
+): UsageMosaicStats {
+ const hourTotals = Array.from({ length: 24 }, () => 0);
+ const weekdayTotals = Array.from({ length: 7 }, () => 0);
+ let totalTokens = 0;
+ let hasData = false;
+
+ for (const session of sessions) {
+ const usage = session.usage;
+ if (!usage || !usage.totalTokens || usage.totalTokens <= 0) {
+ continue;
+ }
+ totalTokens += usage.totalTokens;
+
+ const start = usage.firstActivity ?? session.updatedAt;
+ const end = usage.lastActivity ?? session.updatedAt;
+ if (!start || !end) {
+ continue;
+ }
+ hasData = true;
+
+ const startMs = Math.min(start, end);
+ const endMs = Math.max(start, end);
+ const durationMs = Math.max(endMs - startMs, 1);
+ const totalMinutes = durationMs / 60000;
+
+ let cursor = startMs;
+ while (cursor < endMs) {
+ const date = new Date(cursor);
+ const hour = getZonedHour(date, timeZone);
+ const weekday = getZonedWeekday(date, timeZone);
+ const nextHour = setToHourEnd(date, timeZone);
+ const nextMs = Math.min(nextHour.getTime(), endMs);
+ const minutes = Math.max((nextMs - cursor) / 60000, 0);
+ const share = minutes / totalMinutes;
+ hourTotals[hour] += usage.totalTokens * share;
+ weekdayTotals[weekday] += usage.totalTokens * share;
+ cursor = nextMs + 1;
+ }
+ }
+
+ const weekdayLabels = WEEKDAYS.map((label, index) => ({
+ label,
+ tokens: weekdayTotals[index],
+ }));
+
+ return {
+ hasData,
+ totalTokens,
+ hourTotals,
+ weekdayTotals: weekdayLabels,
+ };
+}
+
+function renderUsageMosaic(
+ sessions: UsageSessionEntry[],
+ timeZone: "local" | "utc",
+ selectedHours: number[],
+ onSelectHour: (hour: number, shiftKey: boolean) => void,
+) {
+ const stats = buildUsageMosaicStats(sessions, timeZone);
+ if (!stats.hasData) {
+ return html`
+
+
+
No timeline data yet.
+
+ `;
+ }
+
+ const maxHour = Math.max(...stats.hourTotals, 1);
+ const maxWeekday = Math.max(...stats.weekdayTotals.map((d) => d.tokens), 1);
+
+ return html`
+
+
+
+
+
Day of Week
+
+ ${stats.weekdayTotals.map((part) => {
+ const intensity = Math.min(part.tokens / maxWeekday, 1);
+ const bg =
+ part.tokens > 0 ? `rgba(255, 77, 77, ${0.12 + intensity * 0.6})` : "transparent";
+ return html`
+
+
${part.label}
+
${formatTokens(part.tokens)}
+
+ `;
+ })}
+
+
+
+
+ Hours
+ 0 → 23
+
+
+ ${stats.hourTotals.map((value, hour) => {
+ const intensity = Math.min(value / maxHour, 1);
+ const bg = value > 0 ? `rgba(255, 77, 77, ${0.08 + intensity * 0.7})` : "transparent";
+ const title = `${hour}:00 · ${formatTokens(value)} tokens`;
+ const border = intensity > 0.7 ? "rgba(255, 77, 77, 0.6)" : "rgba(255, 77, 77, 0.2)";
+ const selected = selectedHours.includes(hour);
+ return html`
+
onSelectHour(hour, e.shiftKey)}
+ >
+ `;
+ })}
+
+
+ Midnight
+ 4am
+ 8am
+ Noon
+ 4pm
+ 8pm
+
+
+
+ Low → High token density
+
+
+
+
+ `;
+}
+
+function formatCost(n: number, decimals = 2): string {
+ return `$${n.toFixed(decimals)}`;
+}
+
+function formatIsoDate(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
+}
+
+function parseYmdDate(dateStr: string): Date | null {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
+ if (!match) {
+ return null;
+ }
+ const [, y, m, d] = match;
+ const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
+ return Number.isNaN(date.valueOf()) ? null : date;
+}
+
+function formatDayLabel(dateStr: string): string {
+ const date = parseYmdDate(dateStr);
+ if (!date) {
+ return dateStr;
+ }
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+function formatFullDate(dateStr: string): string {
+ const date = parseYmdDate(dateStr);
+ if (!date) {
+ return dateStr;
+ }
+ return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
+}
+
+const emptyUsageTotals = (): UsageTotals => ({
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ totalCost: 0,
+ inputCost: 0,
+ outputCost: 0,
+ cacheReadCost: 0,
+ cacheWriteCost: 0,
+ missingCostEntries: 0,
+});
+
+const mergeUsageTotals = (target: UsageTotals, source: Partial) => {
+ target.input += source.input ?? 0;
+ target.output += source.output ?? 0;
+ target.cacheRead += source.cacheRead ?? 0;
+ target.cacheWrite += source.cacheWrite ?? 0;
+ target.totalTokens += source.totalTokens ?? 0;
+ target.totalCost += source.totalCost ?? 0;
+ target.inputCost += source.inputCost ?? 0;
+ target.outputCost += source.outputCost ?? 0;
+ target.cacheReadCost += source.cacheReadCost ?? 0;
+ target.cacheWriteCost += source.cacheWriteCost ?? 0;
+ target.missingCostEntries += source.missingCostEntries ?? 0;
+};
+
+const buildAggregatesFromSessions = (
+ sessions: UsageSessionEntry[],
+ fallback?: UsageAggregates | null,
+): UsageAggregates => {
+ if (sessions.length === 0) {
+ return (
+ fallback ?? {
+ messages: { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 },
+ tools: { totalCalls: 0, uniqueTools: 0, tools: [] },
+ byModel: [],
+ byProvider: [],
+ byAgent: [],
+ byChannel: [],
+ daily: [],
+ }
+ );
+ }
+
+ const messages = { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 };
+ const toolMap = new Map();
+ const modelMap = new Map<
+ string,
+ { provider?: string; model?: string; count: number; totals: UsageTotals }
+ >();
+ const providerMap = new Map<
+ string,
+ { provider?: string; model?: string; count: number; totals: UsageTotals }
+ >();
+ const agentMap = new Map();
+ const channelMap = new Map();
+ const dailyMap = new Map<
+ string,
+ {
+ date: string;
+ tokens: number;
+ cost: number;
+ messages: number;
+ toolCalls: number;
+ errors: number;
+ }
+ >();
+ const dailyLatencyMap = new Map<
+ string,
+ { date: string; count: number; sum: number; min: number; max: number; p95Max: number }
+ >();
+ const modelDailyMap = new Map<
+ string,
+ { date: string; provider?: string; model?: string; tokens: number; cost: number; count: number }
+ >();
+ const latencyTotals = { count: 0, sum: 0, min: Number.POSITIVE_INFINITY, max: 0, p95Max: 0 };
+
+ for (const session of sessions) {
+ const usage = session.usage;
+ if (!usage) {
+ continue;
+ }
+ if (usage.messageCounts) {
+ messages.total += usage.messageCounts.total;
+ messages.user += usage.messageCounts.user;
+ messages.assistant += usage.messageCounts.assistant;
+ messages.toolCalls += usage.messageCounts.toolCalls;
+ messages.toolResults += usage.messageCounts.toolResults;
+ messages.errors += usage.messageCounts.errors;
+ }
+
+ if (usage.toolUsage) {
+ for (const tool of usage.toolUsage.tools) {
+ toolMap.set(tool.name, (toolMap.get(tool.name) ?? 0) + tool.count);
+ }
+ }
+
+ if (usage.modelUsage) {
+ for (const entry of usage.modelUsage) {
+ const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
+ const modelExisting = modelMap.get(modelKey) ?? {
+ provider: entry.provider,
+ model: entry.model,
+ count: 0,
+ totals: emptyUsageTotals(),
+ };
+ modelExisting.count += entry.count;
+ mergeUsageTotals(modelExisting.totals, entry.totals);
+ modelMap.set(modelKey, modelExisting);
+
+ const providerKey = entry.provider ?? "unknown";
+ const providerExisting = providerMap.get(providerKey) ?? {
+ provider: entry.provider,
+ model: undefined,
+ count: 0,
+ totals: emptyUsageTotals(),
+ };
+ providerExisting.count += entry.count;
+ mergeUsageTotals(providerExisting.totals, entry.totals);
+ providerMap.set(providerKey, providerExisting);
+ }
+ }
+
+ if (usage.latency) {
+ const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
+ if (count > 0) {
+ latencyTotals.count += count;
+ latencyTotals.sum += avgMs * count;
+ latencyTotals.min = Math.min(latencyTotals.min, minMs);
+ latencyTotals.max = Math.max(latencyTotals.max, maxMs);
+ latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
+ }
+ }
+
+ if (session.agentId) {
+ const totals = agentMap.get(session.agentId) ?? emptyUsageTotals();
+ mergeUsageTotals(totals, usage);
+ agentMap.set(session.agentId, totals);
+ }
+ if (session.channel) {
+ const totals = channelMap.get(session.channel) ?? emptyUsageTotals();
+ mergeUsageTotals(totals, usage);
+ channelMap.set(session.channel, totals);
+ }
+
+ for (const day of usage.dailyBreakdown ?? []) {
+ const daily = dailyMap.get(day.date) ?? {
+ date: day.date,
+ tokens: 0,
+ cost: 0,
+ messages: 0,
+ toolCalls: 0,
+ errors: 0,
+ };
+ daily.tokens += day.tokens;
+ daily.cost += day.cost;
+ dailyMap.set(day.date, daily);
+ }
+ for (const day of usage.dailyMessageCounts ?? []) {
+ const daily = dailyMap.get(day.date) ?? {
+ date: day.date,
+ tokens: 0,
+ cost: 0,
+ messages: 0,
+ toolCalls: 0,
+ errors: 0,
+ };
+ daily.messages += day.total;
+ daily.toolCalls += day.toolCalls;
+ daily.errors += day.errors;
+ dailyMap.set(day.date, daily);
+ }
+ for (const day of usage.dailyLatency ?? []) {
+ const existing = dailyLatencyMap.get(day.date) ?? {
+ date: day.date,
+ count: 0,
+ sum: 0,
+ min: Number.POSITIVE_INFINITY,
+ max: 0,
+ p95Max: 0,
+ };
+ existing.count += day.count;
+ existing.sum += day.avgMs * day.count;
+ existing.min = Math.min(existing.min, day.minMs);
+ existing.max = Math.max(existing.max, day.maxMs);
+ existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
+ dailyLatencyMap.set(day.date, existing);
+ }
+ for (const day of usage.dailyModelUsage ?? []) {
+ const key = `${day.date}::${day.provider ?? "unknown"}::${day.model ?? "unknown"}`;
+ const existing = modelDailyMap.get(key) ?? {
+ date: day.date,
+ provider: day.provider,
+ model: day.model,
+ tokens: 0,
+ cost: 0,
+ count: 0,
+ };
+ existing.tokens += day.tokens;
+ existing.cost += day.cost;
+ existing.count += day.count;
+ modelDailyMap.set(key, existing);
+ }
+ }
+
+ return {
+ messages,
+ tools: {
+ totalCalls: Array.from(toolMap.values()).reduce((sum, count) => sum + count, 0),
+ uniqueTools: toolMap.size,
+ tools: Array.from(toolMap.entries())
+ .map(([name, count]) => ({ name, count }))
+ .toSorted((a, b) => b.count - a.count),
+ },
+ byModel: Array.from(modelMap.values()).toSorted(
+ (a, b) => b.totals.totalCost - a.totals.totalCost,
+ ),
+ byProvider: Array.from(providerMap.values()).toSorted(
+ (a, b) => b.totals.totalCost - a.totals.totalCost,
+ ),
+ byAgent: Array.from(agentMap.entries())
+ .map(([agentId, totals]) => ({ agentId, totals }))
+ .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
+ byChannel: Array.from(channelMap.entries())
+ .map(([channel, totals]) => ({ channel, totals }))
+ .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
+ latency:
+ latencyTotals.count > 0
+ ? {
+ count: latencyTotals.count,
+ avgMs: latencyTotals.sum / latencyTotals.count,
+ minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min,
+ maxMs: latencyTotals.max,
+ p95Ms: latencyTotals.p95Max,
+ }
+ : undefined,
+ dailyLatency: Array.from(dailyLatencyMap.values())
+ .map((entry) => ({
+ date: entry.date,
+ count: entry.count,
+ avgMs: entry.count ? entry.sum / entry.count : 0,
+ minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min,
+ maxMs: entry.max,
+ p95Ms: entry.p95Max,
+ }))
+ .toSorted((a, b) => a.date.localeCompare(b.date)),
+ modelDaily: Array.from(modelDailyMap.values()).toSorted(
+ (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost,
+ ),
+ daily: Array.from(dailyMap.values()).toSorted((a, b) => a.date.localeCompare(b.date)),
+ };
+};
+
+type UsageInsightStats = {
+ durationSumMs: number;
+ durationCount: number;
+ avgDurationMs: number;
+ throughputTokensPerMin?: number;
+ throughputCostPerMin?: number;
+ errorRate: number;
+ peakErrorDay?: { date: string; errors: number; messages: number; rate: number };
+};
+
+const buildUsageInsightStats = (
+ sessions: UsageSessionEntry[],
+ totals: UsageTotals | null,
+ aggregates: UsageAggregates,
+): UsageInsightStats => {
+ let durationSumMs = 0;
+ let durationCount = 0;
+ for (const session of sessions) {
+ const duration = session.usage?.durationMs ?? 0;
+ if (duration > 0) {
+ durationSumMs += duration;
+ durationCount += 1;
+ }
+ }
+
+ const avgDurationMs = durationCount ? durationSumMs / durationCount : 0;
+ const throughputTokensPerMin =
+ totals && durationSumMs > 0 ? totals.totalTokens / (durationSumMs / 60000) : undefined;
+ const throughputCostPerMin =
+ totals && durationSumMs > 0 ? totals.totalCost / (durationSumMs / 60000) : undefined;
+
+ const errorRate = aggregates.messages.total
+ ? aggregates.messages.errors / aggregates.messages.total
+ : 0;
+ const peakErrorDay = aggregates.daily
+ .filter((day) => day.messages > 0 && day.errors > 0)
+ .map((day) => ({
+ date: day.date,
+ errors: day.errors,
+ messages: day.messages,
+ rate: day.errors / day.messages,
+ }))
+ .toSorted((a, b) => b.rate - a.rate || b.errors - a.errors)[0];
+
+ return {
+ durationSumMs,
+ durationCount,
+ avgDurationMs,
+ throughputTokensPerMin,
+ throughputCostPerMin,
+ errorRate,
+ peakErrorDay,
+ };
+};
+
+export type { UsageInsightStats };
+export {
+ buildAggregatesFromSessions,
+ buildPeakErrorHours,
+ buildUsageInsightStats,
+ charsToTokens,
+ formatCost,
+ formatDayLabel,
+ formatFullDate,
+ formatHourLabel,
+ formatIsoDate,
+ formatTokens,
+ getZonedHour,
+ renderUsageMosaic,
+ setToHourEnd,
+};
diff --git a/ui/src/ui/views/usage-query.ts b/ui/src/ui/views/usage-query.ts
new file mode 100644
index 0000000000..94dc927a56
--- /dev/null
+++ b/ui/src/ui/views/usage-query.ts
@@ -0,0 +1,277 @@
+import { extractQueryTerms } from "../usage-helpers.ts";
+import { CostDailyEntry, UsageAggregates, UsageSessionEntry } from "./usageTypes.ts";
+
+function downloadTextFile(filename: string, content: string, type = "text/plain") {
+ const blob = new Blob([content], { type: `${type};charset=utf-8` });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function csvEscape(value: string): string {
+ if (/[",\n]/.test(value)) {
+ return `"${value.replaceAll('"', '""')}"`;
+ }
+ return value;
+}
+
+function toCsvRow(values: Array): string {
+ return values
+ .map((value) => {
+ if (value === undefined || value === null) {
+ return "";
+ }
+ return csvEscape(String(value));
+ })
+ .join(",");
+}
+
+const buildSessionsCsv = (sessions: UsageSessionEntry[]): string => {
+ const rows = [
+ toCsvRow([
+ "key",
+ "label",
+ "agentId",
+ "channel",
+ "provider",
+ "model",
+ "updatedAt",
+ "durationMs",
+ "messages",
+ "errors",
+ "toolCalls",
+ "inputTokens",
+ "outputTokens",
+ "cacheReadTokens",
+ "cacheWriteTokens",
+ "totalTokens",
+ "totalCost",
+ ]),
+ ];
+
+ for (const session of sessions) {
+ const usage = session.usage;
+ rows.push(
+ toCsvRow([
+ session.key,
+ session.label ?? "",
+ session.agentId ?? "",
+ session.channel ?? "",
+ session.modelProvider ?? session.providerOverride ?? "",
+ session.model ?? session.modelOverride ?? "",
+ session.updatedAt ? new Date(session.updatedAt).toISOString() : "",
+ usage?.durationMs ?? "",
+ usage?.messageCounts?.total ?? "",
+ usage?.messageCounts?.errors ?? "",
+ usage?.messageCounts?.toolCalls ?? "",
+ usage?.input ?? "",
+ usage?.output ?? "",
+ usage?.cacheRead ?? "",
+ usage?.cacheWrite ?? "",
+ usage?.totalTokens ?? "",
+ usage?.totalCost ?? "",
+ ]),
+ );
+ }
+
+ return rows.join("\n");
+};
+
+const buildDailyCsv = (daily: CostDailyEntry[]): string => {
+ const rows = [
+ toCsvRow([
+ "date",
+ "inputTokens",
+ "outputTokens",
+ "cacheReadTokens",
+ "cacheWriteTokens",
+ "totalTokens",
+ "inputCost",
+ "outputCost",
+ "cacheReadCost",
+ "cacheWriteCost",
+ "totalCost",
+ ]),
+ ];
+
+ for (const day of daily) {
+ rows.push(
+ toCsvRow([
+ day.date,
+ day.input,
+ day.output,
+ day.cacheRead,
+ day.cacheWrite,
+ day.totalTokens,
+ day.inputCost ?? "",
+ day.outputCost ?? "",
+ day.cacheReadCost ?? "",
+ day.cacheWriteCost ?? "",
+ day.totalCost,
+ ]),
+ );
+ }
+
+ return rows.join("\n");
+};
+
+type QuerySuggestion = {
+ label: string;
+ value: string;
+};
+
+const buildQuerySuggestions = (
+ query: string,
+ sessions: UsageSessionEntry[],
+ aggregates?: UsageAggregates | null,
+): QuerySuggestion[] => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return [];
+ }
+ const tokens = trimmed.length ? trimmed.split(/\s+/) : [];
+ const lastToken = tokens.length ? tokens[tokens.length - 1] : "";
+ const [rawKey, rawValue] = lastToken.includes(":")
+ ? [lastToken.slice(0, lastToken.indexOf(":")), lastToken.slice(lastToken.indexOf(":") + 1)]
+ : ["", ""];
+
+ const key = rawKey.toLowerCase();
+ const value = rawValue.toLowerCase();
+
+ const unique = (items: Array): string[] => {
+ const set = new Set();
+ for (const item of items) {
+ if (item) {
+ set.add(item);
+ }
+ }
+ return Array.from(set);
+ };
+
+ const agents = unique(sessions.map((s) => s.agentId)).slice(0, 6);
+ const channels = unique(sessions.map((s) => s.channel)).slice(0, 6);
+ const providers = unique([
+ ...sessions.map((s) => s.modelProvider),
+ ...sessions.map((s) => s.providerOverride),
+ ...(aggregates?.byProvider.map((p) => p.provider) ?? []),
+ ]).slice(0, 6);
+ const models = unique([
+ ...sessions.map((s) => s.model),
+ ...(aggregates?.byModel.map((m) => m.model) ?? []),
+ ]).slice(0, 6);
+ const tools = unique(aggregates?.tools.tools.map((t) => t.name) ?? []).slice(0, 6);
+
+ if (!key) {
+ return [
+ { label: "agent:", value: "agent:" },
+ { label: "channel:", value: "channel:" },
+ { label: "provider:", value: "provider:" },
+ { label: "model:", value: "model:" },
+ { label: "tool:", value: "tool:" },
+ { label: "has:errors", value: "has:errors" },
+ { label: "has:tools", value: "has:tools" },
+ { label: "minTokens:", value: "minTokens:" },
+ { label: "maxCost:", value: "maxCost:" },
+ ];
+ }
+
+ const suggestions: QuerySuggestion[] = [];
+ const addValues = (prefix: string, values: string[]) => {
+ for (const val of values) {
+ if (!value || val.toLowerCase().includes(value)) {
+ suggestions.push({ label: `${prefix}:${val}`, value: `${prefix}:${val}` });
+ }
+ }
+ };
+
+ switch (key) {
+ case "agent":
+ addValues("agent", agents);
+ break;
+ case "channel":
+ addValues("channel", channels);
+ break;
+ case "provider":
+ addValues("provider", providers);
+ break;
+ case "model":
+ addValues("model", models);
+ break;
+ case "tool":
+ addValues("tool", tools);
+ break;
+ case "has":
+ ["errors", "tools", "context", "usage", "model", "provider"].forEach((entry) => {
+ if (!value || entry.includes(value)) {
+ suggestions.push({ label: `has:${entry}`, value: `has:${entry}` });
+ }
+ });
+ break;
+ default:
+ break;
+ }
+
+ return suggestions;
+};
+
+const applySuggestionToQuery = (query: string, suggestion: string): string => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return `${suggestion} `;
+ }
+ const tokens = trimmed.split(/\s+/);
+ tokens[tokens.length - 1] = suggestion;
+ return `${tokens.join(" ")} `;
+};
+
+const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
+
+const addQueryToken = (query: string, token: string): string => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return `${token} `;
+ }
+ const tokens = trimmed.split(/\s+/);
+ const last = tokens[tokens.length - 1] ?? "";
+ const tokenKey = token.includes(":") ? token.split(":")[0] : null;
+ const lastKey = last.includes(":") ? last.split(":")[0] : null;
+ if (last.endsWith(":") && tokenKey && lastKey === tokenKey) {
+ tokens[tokens.length - 1] = token;
+ return `${tokens.join(" ")} `;
+ }
+ if (tokens.includes(token)) {
+ return `${tokens.join(" ")} `;
+ }
+ return `${tokens.join(" ")} ${token} `;
+};
+
+const removeQueryToken = (query: string, token: string): string => {
+ const tokens = query.trim().split(/\s+/).filter(Boolean);
+ const next = tokens.filter((entry) => entry !== token);
+ return next.length ? `${next.join(" ")} ` : "";
+};
+
+const setQueryTokensForKey = (query: string, key: string, values: string[]): string => {
+ const normalizedKey = normalizeQueryText(key);
+ const tokens = extractQueryTerms(query)
+ .filter((term) => normalizeQueryText(term.key ?? "") !== normalizedKey)
+ .map((term) => term.raw);
+ const next = [...tokens, ...values.map((value) => `${key}:${value}`)];
+ return next.length ? `${next.join(" ")} ` : "";
+};
+
+export type { QuerySuggestion };
+export {
+ addQueryToken,
+ applySuggestionToQuery,
+ buildDailyCsv,
+ buildQuerySuggestions,
+ buildSessionsCsv,
+ downloadTextFile,
+ normalizeQueryText,
+ removeQueryToken,
+ setQueryTokensForKey,
+};
diff --git a/ui/src/ui/views/usage-render-details.ts b/ui/src/ui/views/usage-render-details.ts
new file mode 100644
index 0000000000..a429b2bbd9
--- /dev/null
+++ b/ui/src/ui/views/usage-render-details.ts
@@ -0,0 +1,745 @@
+import { html, svg, nothing } from "lit";
+import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
+import { parseToolSummary } from "../usage-helpers.ts";
+import { charsToTokens, formatCost, formatTokens } from "./usage-metrics.ts";
+import { renderInsightList } from "./usage-render-overview.ts";
+import {
+ SessionLogEntry,
+ SessionLogRole,
+ TimeSeriesPoint,
+ UsageSessionEntry,
+} from "./usageTypes.ts";
+
+function pct(part: number, total: number): number {
+ if (!total || total <= 0) {
+ return 0;
+ }
+ return (part / total) * 100;
+}
+
+function renderEmptyDetailState() {
+ return nothing;
+}
+
+function renderSessionSummary(session: UsageSessionEntry) {
+ const usage = session.usage;
+ if (!usage) {
+ return html`
+ No usage data for this session.
+ `;
+ }
+
+ const formatTs = (ts?: number): string => (ts ? new Date(ts).toLocaleString() : "—");
+
+ const badges: string[] = [];
+ if (session.channel) {
+ badges.push(`channel:${session.channel}`);
+ }
+ if (session.agentId) {
+ badges.push(`agent:${session.agentId}`);
+ }
+ if (session.modelProvider || session.providerOverride) {
+ badges.push(`provider:${session.modelProvider ?? session.providerOverride}`);
+ }
+ if (session.model) {
+ badges.push(`model:${session.model}`);
+ }
+
+ const toolItems =
+ usage.toolUsage?.tools.slice(0, 6).map((tool) => ({
+ label: tool.name,
+ value: `${tool.count}`,
+ sub: "calls",
+ })) ?? [];
+ const modelItems =
+ usage.modelUsage?.slice(0, 6).map((entry) => ({
+ label: entry.model ?? "unknown",
+ value: formatCost(entry.totals.totalCost),
+ sub: formatTokens(entry.totals.totalTokens),
+ })) ?? [];
+
+ return html`
+ ${badges.length > 0 ? html`${badges.map((b) => html`${b}`)}
` : nothing}
+
+
+
Messages
+
${usage.messageCounts?.total ?? 0}
+
${usage.messageCounts?.user ?? 0} user · ${usage.messageCounts?.assistant ?? 0} assistant
+
+
+
Tool Calls
+
${usage.toolUsage?.totalCalls ?? 0}
+
${usage.toolUsage?.uniqueTools ?? 0} tools
+
+
+
Errors
+
${usage.messageCounts?.errors ?? 0}
+
${usage.messageCounts?.toolResults ?? 0} tool results
+
+
+
Duration
+
${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}
+
${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}
+
+
+
+ ${renderInsightList("Top Tools", toolItems, "No tool calls")}
+ ${renderInsightList("Model Mix", modelItems, "No model data")}
+
+ `;
+}
+
+function renderSessionDetailPanel(
+ session: UsageSessionEntry,
+ timeSeries: { points: TimeSeriesPoint[] } | null,
+ timeSeriesLoading: boolean,
+ timeSeriesMode: "cumulative" | "per-turn",
+ onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void,
+ timeSeriesBreakdownMode: "total" | "by-type",
+ onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void,
+ startDate: string,
+ endDate: string,
+ selectedDays: string[],
+ sessionLogs: SessionLogEntry[] | null,
+ sessionLogsLoading: boolean,
+ sessionLogsExpanded: boolean,
+ onToggleSessionLogsExpanded: () => void,
+ logFilters: {
+ roles: SessionLogRole[];
+ tools: string[];
+ hasTools: boolean;
+ query: string;
+ },
+ onLogFilterRolesChange: (next: SessionLogRole[]) => void,
+ onLogFilterToolsChange: (next: string[]) => void,
+ onLogFilterHasToolsChange: (next: boolean) => void,
+ onLogFilterQueryChange: (next: string) => void,
+ onLogFilterClear: () => void,
+ contextExpanded: boolean,
+ onToggleContextExpanded: () => void,
+ onClose: () => void,
+) {
+ const label = session.label || session.key;
+ const displayLabel = label.length > 50 ? label.slice(0, 50) + "…" : label;
+ const usage = session.usage;
+
+ return html`
+
+
+
+ ${renderSessionSummary(session)}
+
+ ${renderTimeSeriesCompact(
+ timeSeries,
+ timeSeriesLoading,
+ timeSeriesMode,
+ onTimeSeriesModeChange,
+ timeSeriesBreakdownMode,
+ onTimeSeriesBreakdownChange,
+ startDate,
+ endDate,
+ selectedDays,
+ )}
+
+
+ ${renderSessionLogsCompact(
+ sessionLogs,
+ sessionLogsLoading,
+ sessionLogsExpanded,
+ onToggleSessionLogsExpanded,
+ logFilters,
+ onLogFilterRolesChange,
+ onLogFilterToolsChange,
+ onLogFilterHasToolsChange,
+ onLogFilterQueryChange,
+ onLogFilterClear,
+ )}
+ ${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)}
+
+
+
+ `;
+}
+
+function renderTimeSeriesCompact(
+ timeSeries: { points: TimeSeriesPoint[] } | null,
+ loading: boolean,
+ mode: "cumulative" | "per-turn",
+ onModeChange: (mode: "cumulative" | "per-turn") => void,
+ breakdownMode: "total" | "by-type",
+ onBreakdownChange: (mode: "total" | "by-type") => void,
+ startDate?: string,
+ endDate?: string,
+ selectedDays?: string[],
+) {
+ if (loading) {
+ return html`
+
+ `;
+ }
+ if (!timeSeries || timeSeries.points.length < 2) {
+ return html`
+
+ `;
+ }
+
+ // Filter and recalculate (same logic as main function)
+ let points = timeSeries.points;
+ if (startDate || endDate || (selectedDays && selectedDays.length > 0)) {
+ const startTs = startDate ? new Date(startDate + "T00:00:00").getTime() : 0;
+ const endTs = endDate ? new Date(endDate + "T23:59:59").getTime() : Infinity;
+ points = timeSeries.points.filter((p) => {
+ if (p.timestamp < startTs || p.timestamp > endTs) {
+ return false;
+ }
+ if (selectedDays && selectedDays.length > 0) {
+ const d = new Date(p.timestamp);
+ const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ return selectedDays.includes(dateStr);
+ }
+ return true;
+ });
+ }
+ if (points.length < 2) {
+ return html`
+
+ `;
+ }
+ let cumTokens = 0,
+ cumCost = 0;
+ let sumOutput = 0;
+ let sumInput = 0;
+ let sumCacheRead = 0;
+ let sumCacheWrite = 0;
+ points = points.map((p) => {
+ cumTokens += p.totalTokens;
+ cumCost += p.cost;
+ sumOutput += p.output;
+ sumInput += p.input;
+ sumCacheRead += p.cacheRead;
+ sumCacheWrite += p.cacheWrite;
+ return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost };
+ });
+
+ const width = 400,
+ height = 80;
+ const padding = { top: 16, right: 10, bottom: 20, left: 40 };
+ const chartWidth = width - padding.left - padding.right;
+ const chartHeight = height - padding.top - padding.bottom;
+ const isCumulative = mode === "cumulative";
+ const breakdownByType = mode === "per-turn" && breakdownMode === "by-type";
+ const totalTypeTokens = sumOutput + sumInput + sumCacheRead + sumCacheWrite;
+ const barTotals = points.map((p) =>
+ isCumulative
+ ? p.cumulativeTokens
+ : breakdownByType
+ ? p.input + p.output + p.cacheRead + p.cacheWrite
+ : p.totalTokens,
+ );
+ const maxValue = Math.max(...barTotals, 1);
+ const barWidth = Math.max(2, Math.min(8, (chartWidth / points.length) * 0.7));
+ const barGap = Math.max(1, (chartWidth - barWidth * points.length) / (points.length - 1 || 1));
+
+ return html`
+
+
+
+
${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}
+ ${
+ breakdownByType
+ ? html`
+
+
Tokens by Type
+
+
+
+ Output ${formatTokens(sumOutput)}
+
+
+ Input ${formatTokens(sumInput)}
+
+
+ Cache Write ${formatTokens(sumCacheWrite)}
+
+
+ Cache Read ${formatTokens(sumCacheRead)}
+
+
+
Total: ${formatTokens(totalTypeTokens)}
+
+ `
+ : nothing
+ }
+
+ `;
+}
+
+function renderContextPanel(
+ contextWeight: UsageSessionEntry["contextWeight"],
+ usage: UsageSessionEntry["usage"],
+ expanded: boolean,
+ onToggleExpanded: () => void,
+) {
+ if (!contextWeight) {
+ return html`
+
+ `;
+ }
+ const systemTokens = charsToTokens(contextWeight.systemPrompt.chars);
+ const skillsTokens = charsToTokens(contextWeight.skills.promptChars);
+ const toolsTokens = charsToTokens(
+ contextWeight.tools.listChars + contextWeight.tools.schemaChars,
+ );
+ const filesTokens = charsToTokens(
+ contextWeight.injectedWorkspaceFiles.reduce((sum, f) => sum + f.injectedChars, 0),
+ );
+ const totalContextTokens = systemTokens + skillsTokens + toolsTokens + filesTokens;
+
+ let contextPct = "";
+ if (usage && usage.totalTokens > 0) {
+ const inputTokens = usage.input + usage.cacheRead;
+ if (inputTokens > 0) {
+ contextPct = `~${Math.min((totalContextTokens / inputTokens) * 100, 100).toFixed(0)}% of input`;
+ }
+ }
+
+ const skillsList = contextWeight.skills.entries.toSorted((a, b) => b.blockChars - a.blockChars);
+ const toolsList = contextWeight.tools.entries.toSorted(
+ (a, b) => b.summaryChars + b.schemaChars - (a.summaryChars + a.schemaChars),
+ );
+ const filesList = contextWeight.injectedWorkspaceFiles.toSorted(
+ (a, b) => b.injectedChars - a.injectedChars,
+ );
+ const defaultLimit = 4;
+ const showAll = expanded;
+ const skillsTop = showAll ? skillsList : skillsList.slice(0, defaultLimit);
+ const toolsTop = showAll ? toolsList : toolsList.slice(0, defaultLimit);
+ const filesTop = showAll ? filesList : filesList.slice(0, defaultLimit);
+ const hasMore =
+ skillsList.length > defaultLimit ||
+ toolsList.length > defaultLimit ||
+ filesList.length > defaultLimit;
+
+ return html`
+
+
+
${contextPct || "Base context per message"}
+
+
+ Sys ~${formatTokens(systemTokens)}
+ Skills ~${formatTokens(skillsTokens)}
+ Tools ~${formatTokens(toolsTokens)}
+ Files ~${formatTokens(filesTokens)}
+
+
Total: ~${formatTokens(totalContextTokens)}
+
+ ${
+ skillsList.length > 0
+ ? (() => {
+ const more = skillsList.length - skillsTop.length;
+ return html`
+
+
Skills (${skillsList.length})
+
+ ${skillsTop.map(
+ (s) => html`
+
+ ${s.name}
+ ~${formatTokens(charsToTokens(s.blockChars))}
+
+ `,
+ )}
+
+ ${
+ more > 0
+ ? html`
+${more} more
`
+ : nothing
+ }
+
+ `;
+ })()
+ : nothing
+ }
+ ${
+ toolsList.length > 0
+ ? (() => {
+ const more = toolsList.length - toolsTop.length;
+ return html`
+
+
Tools (${toolsList.length})
+
+ ${toolsTop.map(
+ (t) => html`
+
+ ${t.name}
+ ~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))}
+
+ `,
+ )}
+
+ ${
+ more > 0
+ ? html`
+${more} more
`
+ : nothing
+ }
+
+ `;
+ })()
+ : nothing
+ }
+ ${
+ filesList.length > 0
+ ? (() => {
+ const more = filesList.length - filesTop.length;
+ return html`
+
+
Files (${filesList.length})
+
+ ${filesTop.map(
+ (f) => html`
+
+ ${f.name}
+ ~${formatTokens(charsToTokens(f.injectedChars))}
+
+ `,
+ )}
+
+ ${
+ more > 0
+ ? html`
+${more} more
`
+ : nothing
+ }
+
+ `;
+ })()
+ : nothing
+ }
+
+
+ `;
+}
+
+function renderSessionLogsCompact(
+ logs: SessionLogEntry[] | null,
+ loading: boolean,
+ expandedAll: boolean,
+ onToggleExpandedAll: () => void,
+ filters: {
+ roles: SessionLogRole[];
+ tools: string[];
+ hasTools: boolean;
+ query: string;
+ },
+ onFilterRolesChange: (next: SessionLogRole[]) => void,
+ onFilterToolsChange: (next: string[]) => void,
+ onFilterHasToolsChange: (next: boolean) => void,
+ onFilterQueryChange: (next: string) => void,
+ onFilterClear: () => void,
+) {
+ if (loading) {
+ return html`
+
+ `;
+ }
+ if (!logs || logs.length === 0) {
+ return html`
+
+ `;
+ }
+
+ const normalizedQuery = filters.query.trim().toLowerCase();
+ const entries = logs.map((log) => {
+ const toolInfo = parseToolSummary(log.content);
+ const cleanContent = toolInfo.cleanContent || log.content;
+ return { log, toolInfo, cleanContent };
+ });
+ const toolOptions = Array.from(
+ new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))),
+ ).toSorted((a, b) => a.localeCompare(b));
+ const filteredEntries = entries.filter((entry) => {
+ if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) {
+ return false;
+ }
+ if (filters.hasTools && entry.toolInfo.tools.length === 0) {
+ return false;
+ }
+ if (filters.tools.length > 0) {
+ const matchesTool = entry.toolInfo.tools.some(([name]) => filters.tools.includes(name));
+ if (!matchesTool) {
+ return false;
+ }
+ }
+ if (normalizedQuery) {
+ const haystack = entry.cleanContent.toLowerCase();
+ if (!haystack.includes(normalizedQuery)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ const displayedCount =
+ filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery
+ ? `${filteredEntries.length} of ${logs.length}`
+ : `${logs.length}`;
+
+ const roleSelected = new Set(filters.roles);
+ const toolSelected = new Set(filters.tools);
+
+ return html`
+
+
+
+
+
+
+ onFilterQueryChange((event.target as HTMLInputElement).value)}
+ />
+
+
+
+ ${filteredEntries.map((entry) => {
+ const { log, toolInfo, cleanContent } = entry;
+ const roleClass = log.role === "user" ? "user" : "assistant";
+ const roleLabel =
+ log.role === "user" ? "You" : log.role === "assistant" ? "Assistant" : "Tool";
+ return html`
+
+
+ ${roleLabel}
+ ${new Date(log.timestamp).toLocaleString()}
+ ${log.tokens ? html`${formatTokens(log.tokens)}` : nothing}
+
+
${cleanContent}
+ ${
+ toolInfo.tools.length > 0
+ ? html`
+
+ ${toolInfo.summary}
+
+ ${toolInfo.tools.map(
+ ([name, count]) => html`
+ ${name} × ${count}
+ `,
+ )}
+
+
+ `
+ : nothing
+ }
+
+ `;
+ })}
+ ${
+ filteredEntries.length === 0
+ ? html`
+
No messages match the filters.
+ `
+ : nothing
+ }
+
+
+ `;
+}
+
+export {
+ renderContextPanel,
+ renderEmptyDetailState,
+ renderSessionDetailPanel,
+ renderSessionLogsCompact,
+ renderSessionSummary,
+ renderTimeSeriesCompact,
+};
diff --git a/ui/src/ui/views/usage-render-overview.ts b/ui/src/ui/views/usage-render-overview.ts
new file mode 100644
index 0000000000..8a65216e7b
--- /dev/null
+++ b/ui/src/ui/views/usage-render-overview.ts
@@ -0,0 +1,855 @@
+import { html, nothing } from "lit";
+import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
+import {
+ formatCost,
+ formatDayLabel,
+ formatFullDate,
+ formatTokens,
+ UsageInsightStats,
+} from "./usage-metrics.ts";
+import {
+ UsageAggregates,
+ UsageColumnId,
+ UsageSessionEntry,
+ UsageTotals,
+ CostDailyEntry,
+} from "./usageTypes.ts";
+
+function pct(part: number, total: number): number {
+ if (total === 0) {
+ return 0;
+ }
+ return (part / total) * 100;
+}
+
+function getCostBreakdown(totals: UsageTotals) {
+ // Use actual costs from API data (already aggregated in backend)
+ const totalCost = totals.totalCost || 0;
+
+ return {
+ input: {
+ tokens: totals.input,
+ cost: totals.inputCost || 0,
+ pct: pct(totals.inputCost || 0, totalCost),
+ },
+ output: {
+ tokens: totals.output,
+ cost: totals.outputCost || 0,
+ pct: pct(totals.outputCost || 0, totalCost),
+ },
+ cacheRead: {
+ tokens: totals.cacheRead,
+ cost: totals.cacheReadCost || 0,
+ pct: pct(totals.cacheReadCost || 0, totalCost),
+ },
+ cacheWrite: {
+ tokens: totals.cacheWrite,
+ cost: totals.cacheWriteCost || 0,
+ pct: pct(totals.cacheWriteCost || 0, totalCost),
+ },
+ totalCost,
+ };
+}
+
+function renderFilterChips(
+ selectedDays: string[],
+ selectedHours: number[],
+ selectedSessions: string[],
+ sessions: UsageSessionEntry[],
+ onClearDays: () => void,
+ onClearHours: () => void,
+ onClearSessions: () => void,
+ onClearFilters: () => void,
+) {
+ const hasFilters =
+ selectedDays.length > 0 || selectedHours.length > 0 || selectedSessions.length > 0;
+ if (!hasFilters) {
+ return nothing;
+ }
+
+ const selectedSession =
+ selectedSessions.length === 1 ? sessions.find((s) => s.key === selectedSessions[0]) : null;
+ const sessionsLabel = selectedSession
+ ? (selectedSession.label || selectedSession.key).slice(0, 20) +
+ ((selectedSession.label || selectedSession.key).length > 20 ? "…" : "")
+ : selectedSessions.length === 1
+ ? selectedSessions[0].slice(0, 8) + "…"
+ : `${selectedSessions.length} sessions`;
+ const sessionsFullName = selectedSession
+ ? selectedSession.label || selectedSession.key
+ : selectedSessions.length === 1
+ ? selectedSessions[0]
+ : selectedSessions.join(", ");
+
+ const daysLabel = selectedDays.length === 1 ? selectedDays[0] : `${selectedDays.length} days`;
+ const hoursLabel =
+ selectedHours.length === 1 ? `${selectedHours[0]}:00` : `${selectedHours.length} hours`;
+
+ return html`
+
+ ${
+ selectedDays.length > 0
+ ? html`
+
+ Days: ${daysLabel}
+
+
+ `
+ : nothing
+ }
+ ${
+ selectedHours.length > 0
+ ? html`
+
+ Hours: ${hoursLabel}
+
+
+ `
+ : nothing
+ }
+ ${
+ selectedSessions.length > 0
+ ? html`
+
+ Session: ${sessionsLabel}
+
+
+ `
+ : nothing
+ }
+ ${
+ (selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0
+ ? html`
+
+ `
+ : nothing
+ }
+
+ `;
+}
+
+function renderDailyChartCompact(
+ daily: CostDailyEntry[],
+ selectedDays: string[],
+ chartMode: "tokens" | "cost",
+ dailyChartMode: "total" | "by-type",
+ onDailyChartModeChange: (mode: "total" | "by-type") => void,
+ onSelectDay: (day: string, shiftKey: boolean) => void,
+) {
+ if (!daily.length) {
+ return html`
+
+
Daily Usage
+
No data
+
+ `;
+ }
+
+ const isTokenMode = chartMode === "tokens";
+ const values = daily.map((d) => (isTokenMode ? d.totalTokens : d.totalCost));
+ const maxValue = Math.max(...values, isTokenMode ? 1 : 0.0001);
+
+ // Calculate bar width based on number of days
+ const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32;
+ const showTotals = daily.length <= 14;
+
+ return html`
+
+
+
+
+ ${daily.map((d, idx) => {
+ const value = values[idx];
+ const heightPct = (value / maxValue) * 100;
+ const isSelected = selectedDays.includes(d.date);
+ const label = formatDayLabel(d.date);
+ // Shorter label for many days (just day number)
+ const shortLabel = daily.length > 20 ? String(parseInt(d.date.slice(8), 10)) : label;
+ const labelStyle = daily.length > 20 ? "font-size: 8px" : "";
+ const segments =
+ dailyChartMode === "by-type"
+ ? isTokenMode
+ ? [
+ { value: d.output, class: "output" },
+ { value: d.input, class: "input" },
+ { value: d.cacheWrite, class: "cache-write" },
+ { value: d.cacheRead, class: "cache-read" },
+ ]
+ : [
+ { value: d.outputCost ?? 0, class: "output" },
+ { value: d.inputCost ?? 0, class: "input" },
+ { value: d.cacheWriteCost ?? 0, class: "cache-write" },
+ { value: d.cacheReadCost ?? 0, class: "cache-read" },
+ ]
+ : [];
+ const breakdownLines =
+ dailyChartMode === "by-type"
+ ? isTokenMode
+ ? [
+ `Output ${formatTokens(d.output)}`,
+ `Input ${formatTokens(d.input)}`,
+ `Cache write ${formatTokens(d.cacheWrite)}`,
+ `Cache read ${formatTokens(d.cacheRead)}`,
+ ]
+ : [
+ `Output ${formatCost(d.outputCost ?? 0)}`,
+ `Input ${formatCost(d.inputCost ?? 0)}`,
+ `Cache write ${formatCost(d.cacheWriteCost ?? 0)}`,
+ `Cache read ${formatCost(d.cacheReadCost ?? 0)}`,
+ ]
+ : [];
+ const totalLabel = isTokenMode ? formatTokens(d.totalTokens) : formatCost(d.totalCost);
+ return html`
+
onSelectDay(d.date, e.shiftKey)}
+ >
+ ${
+ dailyChartMode === "by-type"
+ ? html`
+
+ ${(() => {
+ const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
+ return segments.map(
+ (seg) => html`
+
+ `,
+ );
+ })()}
+
+ `
+ : html`
+
+ `
+ }
+ ${showTotals ? html`
${totalLabel}
` : nothing}
+
${shortLabel}
+
+
+ `;
+ })}
+
+
+
+ `;
+}
+
+function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost") {
+ const breakdown = getCostBreakdown(totals);
+ const isTokenMode = mode === "tokens";
+ const totalTokens = totals.totalTokens || 1;
+ const tokenPcts = {
+ output: pct(totals.output, totalTokens),
+ input: pct(totals.input, totalTokens),
+ cacheWrite: pct(totals.cacheWrite, totalTokens),
+ cacheRead: pct(totals.cacheRead, totalTokens),
+ };
+
+ return html`
+
+
+
+
+ Output ${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)}
+ Input ${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)}
+ Cache Write ${isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)}
+ Cache Read ${isTokenMode ? formatTokens(totals.cacheRead) : formatCost(breakdown.cacheRead.cost)}
+
+
+ Total: ${isTokenMode ? formatTokens(totals.totalTokens) : formatCost(totals.totalCost)}
+
+
+ `;
+}
+
+function renderInsightList(
+ title: string,
+ items: Array<{ label: string; value: string; sub?: string }>,
+ emptyLabel: string,
+) {
+ return html`
+
+
${title}
+ ${
+ items.length === 0
+ ? html`
${emptyLabel}
`
+ : html`
+
+ ${items.map(
+ (item) => html`
+
+ ${item.label}
+
+ ${item.value}
+ ${item.sub ? html`${item.sub}` : nothing}
+
+
+ `,
+ )}
+
+ `
+ }
+
+ `;
+}
+
+function renderPeakErrorList(
+ title: string,
+ items: Array<{ label: string; value: string; sub?: string }>,
+ emptyLabel: string,
+) {
+ return html`
+
+
${title}
+ ${
+ items.length === 0
+ ? html`
${emptyLabel}
`
+ : html`
+
+ ${items.map(
+ (item) => html`
+
+
${item.label}
+
${item.value}
+ ${item.sub ? html`
${item.sub}
` : nothing}
+
+ `,
+ )}
+
+ `
+ }
+
+ `;
+}
+
+function renderUsageInsights(
+ totals: UsageTotals | null,
+ aggregates: UsageAggregates,
+ stats: UsageInsightStats,
+ showCostHint: boolean,
+ errorHours: Array<{ label: string; value: string; sub?: string }>,
+ sessionCount: number,
+ totalSessions: number,
+) {
+ if (!totals) {
+ return nothing;
+ }
+
+ const avgTokens = aggregates.messages.total
+ ? Math.round(totals.totalTokens / aggregates.messages.total)
+ : 0;
+ const avgCost = aggregates.messages.total ? totals.totalCost / aggregates.messages.total : 0;
+ const cacheBase = totals.input + totals.cacheRead;
+ const cacheHitRate = cacheBase > 0 ? totals.cacheRead / cacheBase : 0;
+ const cacheHitLabel = cacheBase > 0 ? `${(cacheHitRate * 100).toFixed(1)}%` : "—";
+ const errorRatePct = stats.errorRate * 100;
+ const throughputLabel =
+ stats.throughputTokensPerMin !== undefined
+ ? `${formatTokens(Math.round(stats.throughputTokensPerMin))} tok/min`
+ : "—";
+ const throughputCostLabel =
+ stats.throughputCostPerMin !== undefined
+ ? `${formatCost(stats.throughputCostPerMin, 4)} / min`
+ : "—";
+ const avgDurationLabel =
+ stats.durationCount > 0
+ ? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—")
+ : "—";
+ const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
+ const errorHint = "Error rate = errors / total messages. Lower is better.";
+ const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
+ const tokensHint = "Average tokens per message in this range.";
+ const costHint = showCostHint
+ ? "Average cost per message when providers report costs. Cost data is missing for some or all sessions in this range."
+ : "Average cost per message when providers report costs.";
+
+ const errorDays = aggregates.daily
+ .filter((day) => day.messages > 0 && day.errors > 0)
+ .map((day) => {
+ const rate = day.errors / day.messages;
+ return {
+ label: formatDayLabel(day.date),
+ value: `${(rate * 100).toFixed(2)}%`,
+ sub: `${day.errors} errors · ${day.messages} msgs · ${formatTokens(day.tokens)}`,
+ rate,
+ };
+ })
+ .toSorted((a, b) => b.rate - a.rate)
+ .slice(0, 5)
+ .map(({ rate: _rate, ...rest }) => rest);
+
+ const topModels = aggregates.byModel.slice(0, 5).map((entry) => ({
+ label: entry.model ?? "unknown",
+ value: formatCost(entry.totals.totalCost),
+ sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`,
+ }));
+ const topProviders = aggregates.byProvider.slice(0, 5).map((entry) => ({
+ label: entry.provider ?? "unknown",
+ value: formatCost(entry.totals.totalCost),
+ sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`,
+ }));
+ const topTools = aggregates.tools.tools.slice(0, 6).map((tool) => ({
+ label: tool.name,
+ value: `${tool.count}`,
+ sub: "calls",
+ }));
+ const topAgents = aggregates.byAgent.slice(0, 5).map((entry) => ({
+ label: entry.agentId,
+ value: formatCost(entry.totals.totalCost),
+ sub: formatTokens(entry.totals.totalTokens),
+ }));
+ const topChannels = aggregates.byChannel.slice(0, 5).map((entry) => ({
+ label: entry.channel,
+ value: formatCost(entry.totals.totalCost),
+ sub: formatTokens(entry.totals.totalTokens),
+ }));
+
+ return html`
+
+ Usage Overview
+
+
+
+ Messages
+ ?
+
+
${aggregates.messages.total}
+
+ ${aggregates.messages.user} user · ${aggregates.messages.assistant} assistant
+
+
+
+
+ Tool Calls
+ ?
+
+
${aggregates.tools.totalCalls}
+
${aggregates.tools.uniqueTools} tools used
+
+
+
+ Errors
+ ?
+
+
${aggregates.messages.errors}
+
${aggregates.messages.toolResults} tool results
+
+
+
+ Avg Tokens / Msg
+ ?
+
+
${formatTokens(avgTokens)}
+
Across ${aggregates.messages.total || 0} messages
+
+
+
+ Avg Cost / Msg
+ ?
+
+
${formatCost(avgCost, 4)}
+
${formatCost(totals.totalCost)} total
+
+
+
+ Sessions
+ ?
+
+
${sessionCount}
+
of ${totalSessions} in range
+
+
+
+ Throughput
+ ?
+
+
${throughputLabel}
+
${throughputCostLabel}
+
+
+
+ Error Rate
+ ?
+
+
1 ? "warn" : "good"}">${errorRatePct.toFixed(2)}%
+
+ ${aggregates.messages.errors} errors · ${avgDurationLabel} avg session
+
+
+
+
+ Cache Hit Rate
+ ?
+
+
0.3 ? "warn" : "bad"}">${cacheHitLabel}
+
+ ${formatTokens(totals.cacheRead)} cached · ${formatTokens(cacheBase)} prompt
+
+
+
+
+ ${renderInsightList("Top Models", topModels, "No model data")}
+ ${renderInsightList("Top Providers", topProviders, "No provider data")}
+ ${renderInsightList("Top Tools", topTools, "No tool calls")}
+ ${renderInsightList("Top Agents", topAgents, "No agent data")}
+ ${renderInsightList("Top Channels", topChannels, "No channel data")}
+ ${renderPeakErrorList("Peak Error Days", errorDays, "No error data")}
+ ${renderPeakErrorList("Peak Error Hours", errorHours, "No error data")}
+
+
+ `;
+}
+
+function renderSessionsCard(
+ sessions: UsageSessionEntry[],
+ selectedSessions: string[],
+ selectedDays: string[],
+ isTokenMode: boolean,
+ sessionSort: "tokens" | "cost" | "recent" | "messages" | "errors",
+ sessionSortDir: "asc" | "desc",
+ recentSessions: string[],
+ sessionsTab: "all" | "recent",
+ onSelectSession: (key: string, shiftKey: boolean) => void,
+ onSessionSortChange: (sort: "tokens" | "cost" | "recent" | "messages" | "errors") => void,
+ onSessionSortDirChange: (dir: "asc" | "desc") => void,
+ onSessionsTabChange: (tab: "all" | "recent") => void,
+ visibleColumns: UsageColumnId[],
+ totalSessions: number,
+ onClearSessions: () => void,
+) {
+ const showColumn = (id: UsageColumnId) => visibleColumns.includes(id);
+ const formatSessionListLabel = (s: UsageSessionEntry): string => {
+ const raw = s.label || s.key;
+ // Agent session keys often include a token query param; remove it for readability.
+ if (raw.startsWith("agent:") && raw.includes("?token=")) {
+ return raw.slice(0, raw.indexOf("?token="));
+ }
+ return raw;
+ };
+ const copySessionName = async (s: UsageSessionEntry) => {
+ const text = formatSessionListLabel(s);
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ // Best effort; clipboard can fail on insecure contexts or denied permission.
+ }
+ };
+
+ const buildSessionMeta = (s: UsageSessionEntry): string[] => {
+ const parts: string[] = [];
+ if (showColumn("channel") && s.channel) {
+ parts.push(`channel:${s.channel}`);
+ }
+ if (showColumn("agent") && s.agentId) {
+ parts.push(`agent:${s.agentId}`);
+ }
+ if (showColumn("provider") && (s.modelProvider || s.providerOverride)) {
+ parts.push(`provider:${s.modelProvider ?? s.providerOverride}`);
+ }
+ if (showColumn("model") && s.model) {
+ parts.push(`model:${s.model}`);
+ }
+ if (showColumn("messages") && s.usage?.messageCounts) {
+ parts.push(`msgs:${s.usage.messageCounts.total}`);
+ }
+ if (showColumn("tools") && s.usage?.toolUsage) {
+ parts.push(`tools:${s.usage.toolUsage.totalCalls}`);
+ }
+ if (showColumn("errors") && s.usage?.messageCounts) {
+ parts.push(`errors:${s.usage.messageCounts.errors}`);
+ }
+ if (showColumn("duration") && s.usage?.durationMs) {
+ parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
+ }
+ return parts;
+ };
+
+ // Helper to get session value (filtered by days if selected)
+ const getSessionValue = (s: UsageSessionEntry): number => {
+ const usage = s.usage;
+ if (!usage) {
+ return 0;
+ }
+
+ // If days are selected and session has daily breakdown, compute filtered total
+ if (selectedDays.length > 0 && usage.dailyBreakdown && usage.dailyBreakdown.length > 0) {
+ const filteredDays = usage.dailyBreakdown.filter((d) => selectedDays.includes(d.date));
+ return isTokenMode
+ ? filteredDays.reduce((sum, d) => sum + d.tokens, 0)
+ : filteredDays.reduce((sum, d) => sum + d.cost, 0);
+ }
+
+ // Otherwise use total
+ return isTokenMode ? (usage.totalTokens ?? 0) : (usage.totalCost ?? 0);
+ };
+
+ const sortedSessions = [...sessions].toSorted((a, b) => {
+ switch (sessionSort) {
+ case "recent":
+ return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
+ case "messages":
+ return (b.usage?.messageCounts?.total ?? 0) - (a.usage?.messageCounts?.total ?? 0);
+ case "errors":
+ return (b.usage?.messageCounts?.errors ?? 0) - (a.usage?.messageCounts?.errors ?? 0);
+ case "cost":
+ return getSessionValue(b) - getSessionValue(a);
+ case "tokens":
+ default:
+ return getSessionValue(b) - getSessionValue(a);
+ }
+ });
+ const sortedWithDir = sessionSortDir === "asc" ? sortedSessions.toReversed() : sortedSessions;
+
+ const totalValue = sortedWithDir.reduce((sum, session) => sum + getSessionValue(session), 0);
+ const avgValue = sortedWithDir.length ? totalValue / sortedWithDir.length : 0;
+ const totalErrors = sortedWithDir.reduce(
+ (sum, session) => sum + (session.usage?.messageCounts?.errors ?? 0),
+ 0,
+ );
+
+ const selectedSet = new Set(selectedSessions);
+ const selectedEntries = sortedWithDir.filter((s) => selectedSet.has(s.key));
+ const selectedCount = selectedEntries.length;
+ const sessionMap = new Map(sortedWithDir.map((s) => [s.key, s]));
+ const recentEntries = recentSessions
+ .map((key) => sessionMap.get(key))
+ .filter((entry): entry is UsageSessionEntry => Boolean(entry));
+
+ return html`
+
+
+
+ ${
+ sessionsTab === "recent"
+ ? recentEntries.length === 0
+ ? html`
+
No recent sessions
+ `
+ : html`
+
+ ${recentEntries.map((s) => {
+ const value = getSessionValue(s);
+ const isSelected = selectedSet.has(s.key);
+ const displayLabel = formatSessionListLabel(s);
+ const meta = buildSessionMeta(s);
+ return html`
+
onSelectSession(s.key, e.shiftKey)}
+ title="${s.key}"
+ >
+
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
+
+
+
+
+
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `;
+ })}
+
+ `
+ : sessions.length === 0
+ ? html`
+
No sessions in range
+ `
+ : html`
+
+ ${sortedWithDir.slice(0, 50).map((s) => {
+ const value = getSessionValue(s);
+ const isSelected = selectedSessions.includes(s.key);
+ const displayLabel = formatSessionListLabel(s);
+ const meta = buildSessionMeta(s);
+
+ return html`
+
onSelectSession(s.key, e.shiftKey)}
+ title="${s.key}"
+ >
+
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
+
+
+
+
+
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `;
+ })}
+ ${sessions.length > 50 ? html`
+${sessions.length - 50} more
` : nothing}
+
+ `
+ }
+ ${
+ selectedCount > 1
+ ? html`
+
+
Selected (${selectedCount})
+
+ ${selectedEntries.map((s) => {
+ const value = getSessionValue(s);
+ const displayLabel = formatSessionListLabel(s);
+ const meta = buildSessionMeta(s);
+ return html`
+
onSelectSession(s.key, e.shiftKey)}
+ title="${s.key}"
+ >
+
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
+
+
+
+
+
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `;
+ })}
+
+
+ `
+ : nothing
+ }
+
+ `;
+}
+
+export {
+ renderCostBreakdownCompact,
+ renderDailyChartCompact,
+ renderFilterChips,
+ renderInsightList,
+ renderPeakErrorList,
+ renderSessionsCard,
+ renderUsageInsights,
+};
diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts
index b6a0ec60f2..303bd15258 100644
--- a/ui/src/ui/views/usage.ts
+++ b/ui/src/ui/views/usage.ts
@@ -1,2427 +1,47 @@
-import { html, svg, nothing } from "lit";
-import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
-import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
+import { html, nothing } from "lit";
+import { extractQueryTerms, filterSessionsByQuery } from "../usage-helpers.ts";
+import {
+ buildAggregatesFromSessions,
+ buildPeakErrorHours,
+ buildUsageInsightStats,
+ formatCost,
+ formatIsoDate,
+ formatTokens,
+ getZonedHour,
+ renderUsageMosaic,
+ setToHourEnd,
+} from "./usage-metrics.ts";
+import {
+ addQueryToken,
+ applySuggestionToQuery,
+ buildDailyCsv,
+ buildQuerySuggestions,
+ buildSessionsCsv,
+ downloadTextFile,
+ normalizeQueryText,
+ removeQueryToken,
+ setQueryTokensForKey,
+} from "./usage-query.ts";
+import { renderEmptyDetailState, renderSessionDetailPanel } from "./usage-render-details.ts";
+import {
+ renderCostBreakdownCompact,
+ renderDailyChartCompact,
+ renderFilterChips,
+ renderSessionsCard,
+ renderUsageInsights,
+} from "./usage-render-overview.ts";
import { usageStylesString } from "./usageStyles.ts";
import {
- UsageSessionEntry,
- UsageTotals,
- UsageAggregates,
- CostDailyEntry,
- UsageColumnId,
- TimeSeriesPoint,
SessionLogEntry,
SessionLogRole,
+ UsageColumnId,
UsageProps,
+ UsageSessionEntry,
+ UsageTotals,
} from "./usageTypes.ts";
export type { UsageColumnId, SessionLogEntry, SessionLogRole };
-// ~4 chars per token is a rough approximation
-const CHARS_PER_TOKEN = 4;
-
-function charsToTokens(chars: number): number {
- return Math.round(chars / CHARS_PER_TOKEN);
-}
-
-function formatTokens(n: number): string {
- if (n >= 1_000_000) {
- return `${(n / 1_000_000).toFixed(1)}M`;
- }
- if (n >= 1_000) {
- return `${(n / 1_000).toFixed(1)}K`;
- }
- return String(n);
-}
-
-function formatHourLabel(hour: number): string {
- const date = new Date();
- date.setHours(hour, 0, 0, 0);
- return date.toLocaleTimeString(undefined, { hour: "numeric" });
-}
-
-function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" | "utc") {
- const hourErrors = Array.from({ length: 24 }, () => 0);
- const hourMsgs = Array.from({ length: 24 }, () => 0);
-
- for (const session of sessions) {
- const usage = session.usage;
- if (!usage?.messageCounts || usage.messageCounts.total === 0) {
- continue;
- }
- const start = usage.firstActivity ?? session.updatedAt;
- const end = usage.lastActivity ?? session.updatedAt;
- if (!start || !end) {
- continue;
- }
- const startMs = Math.min(start, end);
- const endMs = Math.max(start, end);
- const durationMs = Math.max(endMs - startMs, 1);
- const totalMinutes = durationMs / 60000;
-
- let cursor = startMs;
- while (cursor < endMs) {
- const date = new Date(cursor);
- const hour = getZonedHour(date, timeZone);
- const nextHour = setToHourEnd(date, timeZone);
- const nextMs = Math.min(nextHour.getTime(), endMs);
- const minutes = Math.max((nextMs - cursor) / 60000, 0);
- const share = minutes / totalMinutes;
- hourErrors[hour] += usage.messageCounts.errors * share;
- hourMsgs[hour] += usage.messageCounts.total * share;
- cursor = nextMs + 1;
- }
- }
-
- return hourMsgs
- .map((msgs, hour) => {
- const errors = hourErrors[hour];
- const rate = msgs > 0 ? errors / msgs : 0;
- return {
- hour,
- rate,
- errors,
- msgs,
- };
- })
- .filter((entry) => entry.msgs > 0 && entry.errors > 0)
- .toSorted((a, b) => b.rate - a.rate)
- .slice(0, 5)
- .map((entry) => ({
- label: formatHourLabel(entry.hour),
- value: `${(entry.rate * 100).toFixed(2)}%`,
- sub: `${Math.round(entry.errors)} errors · ${Math.round(entry.msgs)} msgs`,
- }));
-}
-
-type UsageMosaicStats = {
- hasData: boolean;
- totalTokens: number;
- hourTotals: number[];
- weekdayTotals: Array<{ label: string; tokens: number }>;
-};
-
-const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
-
-function getZonedHour(date: Date, zone: "local" | "utc"): number {
- return zone === "utc" ? date.getUTCHours() : date.getHours();
-}
-
-function getZonedWeekday(date: Date, zone: "local" | "utc"): number {
- return zone === "utc" ? date.getUTCDay() : date.getDay();
-}
-
-function setToHourEnd(date: Date, zone: "local" | "utc"): Date {
- const next = new Date(date);
- if (zone === "utc") {
- next.setUTCMinutes(59, 59, 999);
- } else {
- next.setMinutes(59, 59, 999);
- }
- return next;
-}
-
-function buildUsageMosaicStats(
- sessions: UsageSessionEntry[],
- timeZone: "local" | "utc",
-): UsageMosaicStats {
- const hourTotals = Array.from({ length: 24 }, () => 0);
- const weekdayTotals = Array.from({ length: 7 }, () => 0);
- let totalTokens = 0;
- let hasData = false;
-
- for (const session of sessions) {
- const usage = session.usage;
- if (!usage || !usage.totalTokens || usage.totalTokens <= 0) {
- continue;
- }
- totalTokens += usage.totalTokens;
-
- const start = usage.firstActivity ?? session.updatedAt;
- const end = usage.lastActivity ?? session.updatedAt;
- if (!start || !end) {
- continue;
- }
- hasData = true;
-
- const startMs = Math.min(start, end);
- const endMs = Math.max(start, end);
- const durationMs = Math.max(endMs - startMs, 1);
- const totalMinutes = durationMs / 60000;
-
- let cursor = startMs;
- while (cursor < endMs) {
- const date = new Date(cursor);
- const hour = getZonedHour(date, timeZone);
- const weekday = getZonedWeekday(date, timeZone);
- const nextHour = setToHourEnd(date, timeZone);
- const nextMs = Math.min(nextHour.getTime(), endMs);
- const minutes = Math.max((nextMs - cursor) / 60000, 0);
- const share = minutes / totalMinutes;
- hourTotals[hour] += usage.totalTokens * share;
- weekdayTotals[weekday] += usage.totalTokens * share;
- cursor = nextMs + 1;
- }
- }
-
- const weekdayLabels = WEEKDAYS.map((label, index) => ({
- label,
- tokens: weekdayTotals[index],
- }));
-
- return {
- hasData,
- totalTokens,
- hourTotals,
- weekdayTotals: weekdayLabels,
- };
-}
-
-function renderUsageMosaic(
- sessions: UsageSessionEntry[],
- timeZone: "local" | "utc",
- selectedHours: number[],
- onSelectHour: (hour: number, shiftKey: boolean) => void,
-) {
- const stats = buildUsageMosaicStats(sessions, timeZone);
- if (!stats.hasData) {
- return html`
-
-
-
No timeline data yet.
-
- `;
- }
-
- const maxHour = Math.max(...stats.hourTotals, 1);
- const maxWeekday = Math.max(...stats.weekdayTotals.map((d) => d.tokens), 1);
-
- return html`
-
-
-
-
-
Day of Week
-
- ${stats.weekdayTotals.map((part) => {
- const intensity = Math.min(part.tokens / maxWeekday, 1);
- const bg =
- part.tokens > 0 ? `rgba(255, 77, 77, ${0.12 + intensity * 0.6})` : "transparent";
- return html`
-
-
${part.label}
-
${formatTokens(part.tokens)}
-
- `;
- })}
-
-
-
-
- Hours
- 0 → 23
-
-
- ${stats.hourTotals.map((value, hour) => {
- const intensity = Math.min(value / maxHour, 1);
- const bg = value > 0 ? `rgba(255, 77, 77, ${0.08 + intensity * 0.7})` : "transparent";
- const title = `${hour}:00 · ${formatTokens(value)} tokens`;
- const border = intensity > 0.7 ? "rgba(255, 77, 77, 0.6)" : "rgba(255, 77, 77, 0.2)";
- const selected = selectedHours.includes(hour);
- return html`
-
onSelectHour(hour, e.shiftKey)}
- >
- `;
- })}
-
-
- Midnight
- 4am
- 8am
- Noon
- 4pm
- 8pm
-
-
-
- Low → High token density
-
-
-
-
- `;
-}
-
-function formatCost(n: number, decimals = 2): string {
- return `$${n.toFixed(decimals)}`;
-}
-
-function formatIsoDate(date: Date): string {
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
-}
-
-function parseYmdDate(dateStr: string): Date | null {
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
- if (!match) {
- return null;
- }
- const [, y, m, d] = match;
- const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
- return Number.isNaN(date.valueOf()) ? null : date;
-}
-
-function formatDayLabel(dateStr: string): string {
- const date = parseYmdDate(dateStr);
- if (!date) {
- return dateStr;
- }
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
-}
-
-function formatFullDate(dateStr: string): string {
- const date = parseYmdDate(dateStr);
- if (!date) {
- return dateStr;
- }
- return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
-}
-
-function downloadTextFile(filename: string, content: string, type = "text/plain") {
- const blob = new Blob([content], { type });
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = filename;
- anchor.click();
- URL.revokeObjectURL(url);
-}
-
-function csvEscape(value: string): string {
- if (value.includes('"') || value.includes(",") || value.includes("\n")) {
- return `"${value.replace(/"/g, '""')}"`;
- }
- return value;
-}
-
-function toCsvRow(values: Array): string {
- return values
- .map((val) => {
- if (val === undefined || val === null) {
- return "";
- }
- return csvEscape(String(val));
- })
- .join(",");
-}
-
-const emptyUsageTotals = (): UsageTotals => ({
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 0,
- totalCost: 0,
- inputCost: 0,
- outputCost: 0,
- cacheReadCost: 0,
- cacheWriteCost: 0,
- missingCostEntries: 0,
-});
-
-const mergeUsageTotals = (target: UsageTotals, source: Partial) => {
- target.input += source.input ?? 0;
- target.output += source.output ?? 0;
- target.cacheRead += source.cacheRead ?? 0;
- target.cacheWrite += source.cacheWrite ?? 0;
- target.totalTokens += source.totalTokens ?? 0;
- target.totalCost += source.totalCost ?? 0;
- target.inputCost += source.inputCost ?? 0;
- target.outputCost += source.outputCost ?? 0;
- target.cacheReadCost += source.cacheReadCost ?? 0;
- target.cacheWriteCost += source.cacheWriteCost ?? 0;
- target.missingCostEntries += source.missingCostEntries ?? 0;
-};
-
-const buildAggregatesFromSessions = (
- sessions: UsageSessionEntry[],
- fallback?: UsageAggregates | null,
-): UsageAggregates => {
- if (sessions.length === 0) {
- return (
- fallback ?? {
- messages: { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 },
- tools: { totalCalls: 0, uniqueTools: 0, tools: [] },
- byModel: [],
- byProvider: [],
- byAgent: [],
- byChannel: [],
- daily: [],
- }
- );
- }
-
- const messages = { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 };
- const toolMap = new Map();
- const modelMap = new Map<
- string,
- { provider?: string; model?: string; count: number; totals: UsageTotals }
- >();
- const providerMap = new Map<
- string,
- { provider?: string; model?: string; count: number; totals: UsageTotals }
- >();
- const agentMap = new Map();
- const channelMap = new Map();
- const dailyMap = new Map<
- string,
- {
- date: string;
- tokens: number;
- cost: number;
- messages: number;
- toolCalls: number;
- errors: number;
- }
- >();
- const dailyLatencyMap = new Map<
- string,
- { date: string; count: number; sum: number; min: number; max: number; p95Max: number }
- >();
- const modelDailyMap = new Map<
- string,
- { date: string; provider?: string; model?: string; tokens: number; cost: number; count: number }
- >();
- const latencyTotals = { count: 0, sum: 0, min: Number.POSITIVE_INFINITY, max: 0, p95Max: 0 };
-
- for (const session of sessions) {
- const usage = session.usage;
- if (!usage) {
- continue;
- }
- if (usage.messageCounts) {
- messages.total += usage.messageCounts.total;
- messages.user += usage.messageCounts.user;
- messages.assistant += usage.messageCounts.assistant;
- messages.toolCalls += usage.messageCounts.toolCalls;
- messages.toolResults += usage.messageCounts.toolResults;
- messages.errors += usage.messageCounts.errors;
- }
-
- if (usage.toolUsage) {
- for (const tool of usage.toolUsage.tools) {
- toolMap.set(tool.name, (toolMap.get(tool.name) ?? 0) + tool.count);
- }
- }
-
- if (usage.modelUsage) {
- for (const entry of usage.modelUsage) {
- const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
- const modelExisting = modelMap.get(modelKey) ?? {
- provider: entry.provider,
- model: entry.model,
- count: 0,
- totals: emptyUsageTotals(),
- };
- modelExisting.count += entry.count;
- mergeUsageTotals(modelExisting.totals, entry.totals);
- modelMap.set(modelKey, modelExisting);
-
- const providerKey = entry.provider ?? "unknown";
- const providerExisting = providerMap.get(providerKey) ?? {
- provider: entry.provider,
- model: undefined,
- count: 0,
- totals: emptyUsageTotals(),
- };
- providerExisting.count += entry.count;
- mergeUsageTotals(providerExisting.totals, entry.totals);
- providerMap.set(providerKey, providerExisting);
- }
- }
-
- if (usage.latency) {
- const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
- if (count > 0) {
- latencyTotals.count += count;
- latencyTotals.sum += avgMs * count;
- latencyTotals.min = Math.min(latencyTotals.min, minMs);
- latencyTotals.max = Math.max(latencyTotals.max, maxMs);
- latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
- }
- }
-
- if (session.agentId) {
- const totals = agentMap.get(session.agentId) ?? emptyUsageTotals();
- mergeUsageTotals(totals, usage);
- agentMap.set(session.agentId, totals);
- }
- if (session.channel) {
- const totals = channelMap.get(session.channel) ?? emptyUsageTotals();
- mergeUsageTotals(totals, usage);
- channelMap.set(session.channel, totals);
- }
-
- for (const day of usage.dailyBreakdown ?? []) {
- const daily = dailyMap.get(day.date) ?? {
- date: day.date,
- tokens: 0,
- cost: 0,
- messages: 0,
- toolCalls: 0,
- errors: 0,
- };
- daily.tokens += day.tokens;
- daily.cost += day.cost;
- dailyMap.set(day.date, daily);
- }
- for (const day of usage.dailyMessageCounts ?? []) {
- const daily = dailyMap.get(day.date) ?? {
- date: day.date,
- tokens: 0,
- cost: 0,
- messages: 0,
- toolCalls: 0,
- errors: 0,
- };
- daily.messages += day.total;
- daily.toolCalls += day.toolCalls;
- daily.errors += day.errors;
- dailyMap.set(day.date, daily);
- }
- for (const day of usage.dailyLatency ?? []) {
- const existing = dailyLatencyMap.get(day.date) ?? {
- date: day.date,
- count: 0,
- sum: 0,
- min: Number.POSITIVE_INFINITY,
- max: 0,
- p95Max: 0,
- };
- existing.count += day.count;
- existing.sum += day.avgMs * day.count;
- existing.min = Math.min(existing.min, day.minMs);
- existing.max = Math.max(existing.max, day.maxMs);
- existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
- dailyLatencyMap.set(day.date, existing);
- }
- for (const day of usage.dailyModelUsage ?? []) {
- const key = `${day.date}::${day.provider ?? "unknown"}::${day.model ?? "unknown"}`;
- const existing = modelDailyMap.get(key) ?? {
- date: day.date,
- provider: day.provider,
- model: day.model,
- tokens: 0,
- cost: 0,
- count: 0,
- };
- existing.tokens += day.tokens;
- existing.cost += day.cost;
- existing.count += day.count;
- modelDailyMap.set(key, existing);
- }
- }
-
- return {
- messages,
- tools: {
- totalCalls: Array.from(toolMap.values()).reduce((sum, count) => sum + count, 0),
- uniqueTools: toolMap.size,
- tools: Array.from(toolMap.entries())
- .map(([name, count]) => ({ name, count }))
- .toSorted((a, b) => b.count - a.count),
- },
- byModel: Array.from(modelMap.values()).toSorted(
- (a, b) => b.totals.totalCost - a.totals.totalCost,
- ),
- byProvider: Array.from(providerMap.values()).toSorted(
- (a, b) => b.totals.totalCost - a.totals.totalCost,
- ),
- byAgent: Array.from(agentMap.entries())
- .map(([agentId, totals]) => ({ agentId, totals }))
- .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
- byChannel: Array.from(channelMap.entries())
- .map(([channel, totals]) => ({ channel, totals }))
- .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
- latency:
- latencyTotals.count > 0
- ? {
- count: latencyTotals.count,
- avgMs: latencyTotals.sum / latencyTotals.count,
- minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min,
- maxMs: latencyTotals.max,
- p95Ms: latencyTotals.p95Max,
- }
- : undefined,
- dailyLatency: Array.from(dailyLatencyMap.values())
- .map((entry) => ({
- date: entry.date,
- count: entry.count,
- avgMs: entry.count ? entry.sum / entry.count : 0,
- minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min,
- maxMs: entry.max,
- p95Ms: entry.p95Max,
- }))
- .toSorted((a, b) => a.date.localeCompare(b.date)),
- modelDaily: Array.from(modelDailyMap.values()).toSorted(
- (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost,
- ),
- daily: Array.from(dailyMap.values()).toSorted((a, b) => a.date.localeCompare(b.date)),
- };
-};
-
-type UsageInsightStats = {
- durationSumMs: number;
- durationCount: number;
- avgDurationMs: number;
- throughputTokensPerMin?: number;
- throughputCostPerMin?: number;
- errorRate: number;
- peakErrorDay?: { date: string; errors: number; messages: number; rate: number };
-};
-
-const buildUsageInsightStats = (
- sessions: UsageSessionEntry[],
- totals: UsageTotals | null,
- aggregates: UsageAggregates,
-): UsageInsightStats => {
- let durationSumMs = 0;
- let durationCount = 0;
- for (const session of sessions) {
- const duration = session.usage?.durationMs ?? 0;
- if (duration > 0) {
- durationSumMs += duration;
- durationCount += 1;
- }
- }
-
- const avgDurationMs = durationCount ? durationSumMs / durationCount : 0;
- const throughputTokensPerMin =
- totals && durationSumMs > 0 ? totals.totalTokens / (durationSumMs / 60000) : undefined;
- const throughputCostPerMin =
- totals && durationSumMs > 0 ? totals.totalCost / (durationSumMs / 60000) : undefined;
-
- const errorRate = aggregates.messages.total
- ? aggregates.messages.errors / aggregates.messages.total
- : 0;
- const peakErrorDay = aggregates.daily
- .filter((day) => day.messages > 0 && day.errors > 0)
- .map((day) => ({
- date: day.date,
- errors: day.errors,
- messages: day.messages,
- rate: day.errors / day.messages,
- }))
- .toSorted((a, b) => b.rate - a.rate || b.errors - a.errors)[0];
-
- return {
- durationSumMs,
- durationCount,
- avgDurationMs,
- throughputTokensPerMin,
- throughputCostPerMin,
- errorRate,
- peakErrorDay,
- };
-};
-
-const buildSessionsCsv = (sessions: UsageSessionEntry[]): string => {
- const rows = [
- toCsvRow([
- "key",
- "label",
- "agentId",
- "channel",
- "provider",
- "model",
- "updatedAt",
- "durationMs",
- "messages",
- "errors",
- "toolCalls",
- "inputTokens",
- "outputTokens",
- "cacheReadTokens",
- "cacheWriteTokens",
- "totalTokens",
- "totalCost",
- ]),
- ];
-
- for (const session of sessions) {
- const usage = session.usage;
- rows.push(
- toCsvRow([
- session.key,
- session.label ?? "",
- session.agentId ?? "",
- session.channel ?? "",
- session.modelProvider ?? session.providerOverride ?? "",
- session.model ?? session.modelOverride ?? "",
- session.updatedAt ? new Date(session.updatedAt).toISOString() : "",
- usage?.durationMs ?? "",
- usage?.messageCounts?.total ?? "",
- usage?.messageCounts?.errors ?? "",
- usage?.messageCounts?.toolCalls ?? "",
- usage?.input ?? "",
- usage?.output ?? "",
- usage?.cacheRead ?? "",
- usage?.cacheWrite ?? "",
- usage?.totalTokens ?? "",
- usage?.totalCost ?? "",
- ]),
- );
- }
-
- return rows.join("\n");
-};
-
-const buildDailyCsv = (daily: CostDailyEntry[]): string => {
- const rows = [
- toCsvRow([
- "date",
- "inputTokens",
- "outputTokens",
- "cacheReadTokens",
- "cacheWriteTokens",
- "totalTokens",
- "inputCost",
- "outputCost",
- "cacheReadCost",
- "cacheWriteCost",
- "totalCost",
- ]),
- ];
-
- for (const day of daily) {
- rows.push(
- toCsvRow([
- day.date,
- day.input,
- day.output,
- day.cacheRead,
- day.cacheWrite,
- day.totalTokens,
- day.inputCost ?? "",
- day.outputCost ?? "",
- day.cacheReadCost ?? "",
- day.cacheWriteCost ?? "",
- day.totalCost,
- ]),
- );
- }
-
- return rows.join("\n");
-};
-
-type QuerySuggestion = {
- label: string;
- value: string;
-};
-
-const buildQuerySuggestions = (
- query: string,
- sessions: UsageSessionEntry[],
- aggregates?: UsageAggregates | null,
-): QuerySuggestion[] => {
- const trimmed = query.trim();
- if (!trimmed) {
- return [];
- }
- const tokens = trimmed.length ? trimmed.split(/\s+/) : [];
- const lastToken = tokens.length ? tokens[tokens.length - 1] : "";
- const [rawKey, rawValue] = lastToken.includes(":")
- ? [lastToken.slice(0, lastToken.indexOf(":")), lastToken.slice(lastToken.indexOf(":") + 1)]
- : ["", ""];
-
- const key = rawKey.toLowerCase();
- const value = rawValue.toLowerCase();
-
- const unique = (items: Array): string[] => {
- const set = new Set();
- for (const item of items) {
- if (item) {
- set.add(item);
- }
- }
- return Array.from(set);
- };
-
- const agents = unique(sessions.map((s) => s.agentId)).slice(0, 6);
- const channels = unique(sessions.map((s) => s.channel)).slice(0, 6);
- const providers = unique([
- ...sessions.map((s) => s.modelProvider),
- ...sessions.map((s) => s.providerOverride),
- ...(aggregates?.byProvider.map((p) => p.provider) ?? []),
- ]).slice(0, 6);
- const models = unique([
- ...sessions.map((s) => s.model),
- ...(aggregates?.byModel.map((m) => m.model) ?? []),
- ]).slice(0, 6);
- const tools = unique(aggregates?.tools.tools.map((t) => t.name) ?? []).slice(0, 6);
-
- if (!key) {
- return [
- { label: "agent:", value: "agent:" },
- { label: "channel:", value: "channel:" },
- { label: "provider:", value: "provider:" },
- { label: "model:", value: "model:" },
- { label: "tool:", value: "tool:" },
- { label: "has:errors", value: "has:errors" },
- { label: "has:tools", value: "has:tools" },
- { label: "minTokens:", value: "minTokens:" },
- { label: "maxCost:", value: "maxCost:" },
- ];
- }
-
- const suggestions: QuerySuggestion[] = [];
- const addValues = (prefix: string, values: string[]) => {
- for (const val of values) {
- if (!value || val.toLowerCase().includes(value)) {
- suggestions.push({ label: `${prefix}:${val}`, value: `${prefix}:${val}` });
- }
- }
- };
-
- switch (key) {
- case "agent":
- addValues("agent", agents);
- break;
- case "channel":
- addValues("channel", channels);
- break;
- case "provider":
- addValues("provider", providers);
- break;
- case "model":
- addValues("model", models);
- break;
- case "tool":
- addValues("tool", tools);
- break;
- case "has":
- ["errors", "tools", "context", "usage", "model", "provider"].forEach((entry) => {
- if (!value || entry.includes(value)) {
- suggestions.push({ label: `has:${entry}`, value: `has:${entry}` });
- }
- });
- break;
- default:
- break;
- }
-
- return suggestions;
-};
-
-const applySuggestionToQuery = (query: string, suggestion: string): string => {
- const trimmed = query.trim();
- if (!trimmed) {
- return `${suggestion} `;
- }
- const tokens = trimmed.split(/\s+/);
- tokens[tokens.length - 1] = suggestion;
- return `${tokens.join(" ")} `;
-};
-
-const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
-
-const addQueryToken = (query: string, token: string): string => {
- const trimmed = query.trim();
- if (!trimmed) {
- return `${token} `;
- }
- const tokens = trimmed.split(/\s+/);
- const last = tokens[tokens.length - 1] ?? "";
- const tokenKey = token.includes(":") ? token.split(":")[0] : null;
- const lastKey = last.includes(":") ? last.split(":")[0] : null;
- if (last.endsWith(":") && tokenKey && lastKey === tokenKey) {
- tokens[tokens.length - 1] = token;
- return `${tokens.join(" ")} `;
- }
- if (tokens.includes(token)) {
- return `${tokens.join(" ")} `;
- }
- return `${tokens.join(" ")} ${token} `;
-};
-
-const removeQueryToken = (query: string, token: string): string => {
- const tokens = query.trim().split(/\s+/).filter(Boolean);
- const next = tokens.filter((entry) => entry !== token);
- return next.length ? `${next.join(" ")} ` : "";
-};
-
-const setQueryTokensForKey = (query: string, key: string, values: string[]): string => {
- const normalizedKey = normalizeQueryText(key);
- const tokens = extractQueryTerms(query)
- .filter((term) => normalizeQueryText(term.key ?? "") !== normalizedKey)
- .map((term) => term.raw);
- const next = [...tokens, ...values.map((value) => `${key}:${value}`)];
- return next.length ? `${next.join(" ")} ` : "";
-};
-
-function pct(part: number, total: number): number {
- if (total === 0) {
- return 0;
- }
- return (part / total) * 100;
-}
-
-function getCostBreakdown(totals: UsageTotals) {
- // Use actual costs from API data (already aggregated in backend)
- const totalCost = totals.totalCost || 0;
-
- return {
- input: {
- tokens: totals.input,
- cost: totals.inputCost || 0,
- pct: pct(totals.inputCost || 0, totalCost),
- },
- output: {
- tokens: totals.output,
- cost: totals.outputCost || 0,
- pct: pct(totals.outputCost || 0, totalCost),
- },
- cacheRead: {
- tokens: totals.cacheRead,
- cost: totals.cacheReadCost || 0,
- pct: pct(totals.cacheReadCost || 0, totalCost),
- },
- cacheWrite: {
- tokens: totals.cacheWrite,
- cost: totals.cacheWriteCost || 0,
- pct: pct(totals.cacheWriteCost || 0, totalCost),
- },
- totalCost,
- };
-}
-
-function renderFilterChips(
- selectedDays: string[],
- selectedHours: number[],
- selectedSessions: string[],
- sessions: UsageSessionEntry[],
- onClearDays: () => void,
- onClearHours: () => void,
- onClearSessions: () => void,
- onClearFilters: () => void,
-) {
- const hasFilters =
- selectedDays.length > 0 || selectedHours.length > 0 || selectedSessions.length > 0;
- if (!hasFilters) {
- return nothing;
- }
-
- const selectedSession =
- selectedSessions.length === 1 ? sessions.find((s) => s.key === selectedSessions[0]) : null;
- const sessionsLabel = selectedSession
- ? (selectedSession.label || selectedSession.key).slice(0, 20) +
- ((selectedSession.label || selectedSession.key).length > 20 ? "…" : "")
- : selectedSessions.length === 1
- ? selectedSessions[0].slice(0, 8) + "…"
- : `${selectedSessions.length} sessions`;
- const sessionsFullName = selectedSession
- ? selectedSession.label || selectedSession.key
- : selectedSessions.length === 1
- ? selectedSessions[0]
- : selectedSessions.join(", ");
-
- const daysLabel = selectedDays.length === 1 ? selectedDays[0] : `${selectedDays.length} days`;
- const hoursLabel =
- selectedHours.length === 1 ? `${selectedHours[0]}:00` : `${selectedHours.length} hours`;
-
- return html`
-
- ${
- selectedDays.length > 0
- ? html`
-
- Days: ${daysLabel}
-
-
- `
- : nothing
- }
- ${
- selectedHours.length > 0
- ? html`
-
- Hours: ${hoursLabel}
-
-
- `
- : nothing
- }
- ${
- selectedSessions.length > 0
- ? html`
-
- Session: ${sessionsLabel}
-
-
- `
- : nothing
- }
- ${
- (selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0
- ? html`
-
- `
- : nothing
- }
-
- `;
-}
-
-function renderDailyChartCompact(
- daily: CostDailyEntry[],
- selectedDays: string[],
- chartMode: "tokens" | "cost",
- dailyChartMode: "total" | "by-type",
- onDailyChartModeChange: (mode: "total" | "by-type") => void,
- onSelectDay: (day: string, shiftKey: boolean) => void,
-) {
- if (!daily.length) {
- return html`
-
-
Daily Usage
-
No data
-
- `;
- }
-
- const isTokenMode = chartMode === "tokens";
- const values = daily.map((d) => (isTokenMode ? d.totalTokens : d.totalCost));
- const maxValue = Math.max(...values, isTokenMode ? 1 : 0.0001);
-
- // Calculate bar width based on number of days
- const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32;
- const showTotals = daily.length <= 14;
-
- return html`
-
-
-
-
- ${daily.map((d, idx) => {
- const value = values[idx];
- const heightPct = (value / maxValue) * 100;
- const isSelected = selectedDays.includes(d.date);
- const label = formatDayLabel(d.date);
- // Shorter label for many days (just day number)
- const shortLabel = daily.length > 20 ? String(parseInt(d.date.slice(8), 10)) : label;
- const labelStyle = daily.length > 20 ? "font-size: 8px" : "";
- const segments =
- dailyChartMode === "by-type"
- ? isTokenMode
- ? [
- { value: d.output, class: "output" },
- { value: d.input, class: "input" },
- { value: d.cacheWrite, class: "cache-write" },
- { value: d.cacheRead, class: "cache-read" },
- ]
- : [
- { value: d.outputCost ?? 0, class: "output" },
- { value: d.inputCost ?? 0, class: "input" },
- { value: d.cacheWriteCost ?? 0, class: "cache-write" },
- { value: d.cacheReadCost ?? 0, class: "cache-read" },
- ]
- : [];
- const breakdownLines =
- dailyChartMode === "by-type"
- ? isTokenMode
- ? [
- `Output ${formatTokens(d.output)}`,
- `Input ${formatTokens(d.input)}`,
- `Cache write ${formatTokens(d.cacheWrite)}`,
- `Cache read ${formatTokens(d.cacheRead)}`,
- ]
- : [
- `Output ${formatCost(d.outputCost ?? 0)}`,
- `Input ${formatCost(d.inputCost ?? 0)}`,
- `Cache write ${formatCost(d.cacheWriteCost ?? 0)}`,
- `Cache read ${formatCost(d.cacheReadCost ?? 0)}`,
- ]
- : [];
- const totalLabel = isTokenMode ? formatTokens(d.totalTokens) : formatCost(d.totalCost);
- return html`
-
onSelectDay(d.date, e.shiftKey)}
- >
- ${
- dailyChartMode === "by-type"
- ? html`
-
- ${(() => {
- const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
- return segments.map(
- (seg) => html`
-
- `,
- );
- })()}
-
- `
- : html`
-
- `
- }
- ${showTotals ? html`
${totalLabel}
` : nothing}
-
${shortLabel}
-
-
- `;
- })}
-
-
-
- `;
-}
-
-function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost") {
- const breakdown = getCostBreakdown(totals);
- const isTokenMode = mode === "tokens";
- const totalTokens = totals.totalTokens || 1;
- const tokenPcts = {
- output: pct(totals.output, totalTokens),
- input: pct(totals.input, totalTokens),
- cacheWrite: pct(totals.cacheWrite, totalTokens),
- cacheRead: pct(totals.cacheRead, totalTokens),
- };
-
- return html`
-
-
-
-
- Output ${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)}
- Input ${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)}
- Cache Write ${isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)}
- Cache Read ${isTokenMode ? formatTokens(totals.cacheRead) : formatCost(breakdown.cacheRead.cost)}
-
-
- Total: ${isTokenMode ? formatTokens(totals.totalTokens) : formatCost(totals.totalCost)}
-
-
- `;
-}
-
-function renderInsightList(
- title: string,
- items: Array<{ label: string; value: string; sub?: string }>,
- emptyLabel: string,
-) {
- return html`
-
-
${title}
- ${
- items.length === 0
- ? html`
${emptyLabel}
`
- : html`
-
- ${items.map(
- (item) => html`
-
- ${item.label}
-
- ${item.value}
- ${item.sub ? html`${item.sub}` : nothing}
-
-
- `,
- )}
-
- `
- }
-
- `;
-}
-
-function renderPeakErrorList(
- title: string,
- items: Array<{ label: string; value: string; sub?: string }>,
- emptyLabel: string,
-) {
- return html`
-
-
${title}
- ${
- items.length === 0
- ? html`
${emptyLabel}
`
- : html`
-
- ${items.map(
- (item) => html`
-
-
${item.label}
-
${item.value}
- ${item.sub ? html`
${item.sub}
` : nothing}
-
- `,
- )}
-
- `
- }
-
- `;
-}
-
-function renderUsageInsights(
- totals: UsageTotals | null,
- aggregates: UsageAggregates,
- stats: UsageInsightStats,
- showCostHint: boolean,
- errorHours: Array<{ label: string; value: string; sub?: string }>,
- sessionCount: number,
- totalSessions: number,
-) {
- if (!totals) {
- return nothing;
- }
-
- const avgTokens = aggregates.messages.total
- ? Math.round(totals.totalTokens / aggregates.messages.total)
- : 0;
- const avgCost = aggregates.messages.total ? totals.totalCost / aggregates.messages.total : 0;
- const cacheBase = totals.input + totals.cacheRead;
- const cacheHitRate = cacheBase > 0 ? totals.cacheRead / cacheBase : 0;
- const cacheHitLabel = cacheBase > 0 ? `${(cacheHitRate * 100).toFixed(1)}%` : "—";
- const errorRatePct = stats.errorRate * 100;
- const throughputLabel =
- stats.throughputTokensPerMin !== undefined
- ? `${formatTokens(Math.round(stats.throughputTokensPerMin))} tok/min`
- : "—";
- const throughputCostLabel =
- stats.throughputCostPerMin !== undefined
- ? `${formatCost(stats.throughputCostPerMin, 4)} / min`
- : "—";
- const avgDurationLabel =
- stats.durationCount > 0
- ? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—")
- : "—";
- const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
- const errorHint = "Error rate = errors / total messages. Lower is better.";
- const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
- const tokensHint = "Average tokens per message in this range.";
- const costHint = showCostHint
- ? "Average cost per message when providers report costs. Cost data is missing for some or all sessions in this range."
- : "Average cost per message when providers report costs.";
-
- const errorDays = aggregates.daily
- .filter((day) => day.messages > 0 && day.errors > 0)
- .map((day) => {
- const rate = day.errors / day.messages;
- return {
- label: formatDayLabel(day.date),
- value: `${(rate * 100).toFixed(2)}%`,
- sub: `${day.errors} errors · ${day.messages} msgs · ${formatTokens(day.tokens)}`,
- rate,
- };
- })
- .toSorted((a, b) => b.rate - a.rate)
- .slice(0, 5)
- .map(({ rate: _rate, ...rest }) => rest);
-
- const topModels = aggregates.byModel.slice(0, 5).map((entry) => ({
- label: entry.model ?? "unknown",
- value: formatCost(entry.totals.totalCost),
- sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`,
- }));
- const topProviders = aggregates.byProvider.slice(0, 5).map((entry) => ({
- label: entry.provider ?? "unknown",
- value: formatCost(entry.totals.totalCost),
- sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`,
- }));
- const topTools = aggregates.tools.tools.slice(0, 6).map((tool) => ({
- label: tool.name,
- value: `${tool.count}`,
- sub: "calls",
- }));
- const topAgents = aggregates.byAgent.slice(0, 5).map((entry) => ({
- label: entry.agentId,
- value: formatCost(entry.totals.totalCost),
- sub: formatTokens(entry.totals.totalTokens),
- }));
- const topChannels = aggregates.byChannel.slice(0, 5).map((entry) => ({
- label: entry.channel,
- value: formatCost(entry.totals.totalCost),
- sub: formatTokens(entry.totals.totalTokens),
- }));
-
- return html`
-
- Usage Overview
-
-
-
- Messages
- ?
-
-
${aggregates.messages.total}
-
- ${aggregates.messages.user} user · ${aggregates.messages.assistant} assistant
-
-
-
-
- Tool Calls
- ?
-
-
${aggregates.tools.totalCalls}
-
${aggregates.tools.uniqueTools} tools used
-
-
-
- Errors
- ?
-
-
${aggregates.messages.errors}
-
${aggregates.messages.toolResults} tool results
-
-
-
- Avg Tokens / Msg
- ?
-
-
${formatTokens(avgTokens)}
-
Across ${aggregates.messages.total || 0} messages
-
-
-
- Avg Cost / Msg
- ?
-
-
${formatCost(avgCost, 4)}
-
${formatCost(totals.totalCost)} total
-
-
-
- Sessions
- ?
-
-
${sessionCount}
-
of ${totalSessions} in range
-
-
-
- Throughput
- ?
-
-
${throughputLabel}
-
${throughputCostLabel}
-
-
-
- Error Rate
- ?
-
-
1 ? "warn" : "good"}">${errorRatePct.toFixed(2)}%
-
- ${aggregates.messages.errors} errors · ${avgDurationLabel} avg session
-
-
-
-
- Cache Hit Rate
- ?
-
-
0.3 ? "warn" : "bad"}">${cacheHitLabel}
-
- ${formatTokens(totals.cacheRead)} cached · ${formatTokens(cacheBase)} prompt
-
-
-
-
- ${renderInsightList("Top Models", topModels, "No model data")}
- ${renderInsightList("Top Providers", topProviders, "No provider data")}
- ${renderInsightList("Top Tools", topTools, "No tool calls")}
- ${renderInsightList("Top Agents", topAgents, "No agent data")}
- ${renderInsightList("Top Channels", topChannels, "No channel data")}
- ${renderPeakErrorList("Peak Error Days", errorDays, "No error data")}
- ${renderPeakErrorList("Peak Error Hours", errorHours, "No error data")}
-
-
- `;
-}
-
-function renderSessionsCard(
- sessions: UsageSessionEntry[],
- selectedSessions: string[],
- selectedDays: string[],
- isTokenMode: boolean,
- sessionSort: "tokens" | "cost" | "recent" | "messages" | "errors",
- sessionSortDir: "asc" | "desc",
- recentSessions: string[],
- sessionsTab: "all" | "recent",
- onSelectSession: (key: string, shiftKey: boolean) => void,
- onSessionSortChange: (sort: "tokens" | "cost" | "recent" | "messages" | "errors") => void,
- onSessionSortDirChange: (dir: "asc" | "desc") => void,
- onSessionsTabChange: (tab: "all" | "recent") => void,
- visibleColumns: UsageColumnId[],
- totalSessions: number,
- onClearSessions: () => void,
-) {
- const showColumn = (id: UsageColumnId) => visibleColumns.includes(id);
- const formatSessionListLabel = (s: UsageSessionEntry): string => {
- const raw = s.label || s.key;
- // Agent session keys often include a token query param; remove it for readability.
- if (raw.startsWith("agent:") && raw.includes("?token=")) {
- return raw.slice(0, raw.indexOf("?token="));
- }
- return raw;
- };
- const copySessionName = async (s: UsageSessionEntry) => {
- const text = formatSessionListLabel(s);
- try {
- await navigator.clipboard.writeText(text);
- } catch {
- // Best effort; clipboard can fail on insecure contexts or denied permission.
- }
- };
-
- const buildSessionMeta = (s: UsageSessionEntry): string[] => {
- const parts: string[] = [];
- if (showColumn("channel") && s.channel) {
- parts.push(`channel:${s.channel}`);
- }
- if (showColumn("agent") && s.agentId) {
- parts.push(`agent:${s.agentId}`);
- }
- if (showColumn("provider") && (s.modelProvider || s.providerOverride)) {
- parts.push(`provider:${s.modelProvider ?? s.providerOverride}`);
- }
- if (showColumn("model") && s.model) {
- parts.push(`model:${s.model}`);
- }
- if (showColumn("messages") && s.usage?.messageCounts) {
- parts.push(`msgs:${s.usage.messageCounts.total}`);
- }
- if (showColumn("tools") && s.usage?.toolUsage) {
- parts.push(`tools:${s.usage.toolUsage.totalCalls}`);
- }
- if (showColumn("errors") && s.usage?.messageCounts) {
- parts.push(`errors:${s.usage.messageCounts.errors}`);
- }
- if (showColumn("duration") && s.usage?.durationMs) {
- parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
- }
- return parts;
- };
-
- // Helper to get session value (filtered by days if selected)
- const getSessionValue = (s: UsageSessionEntry): number => {
- const usage = s.usage;
- if (!usage) {
- return 0;
- }
-
- // If days are selected and session has daily breakdown, compute filtered total
- if (selectedDays.length > 0 && usage.dailyBreakdown && usage.dailyBreakdown.length > 0) {
- const filteredDays = usage.dailyBreakdown.filter((d) => selectedDays.includes(d.date));
- return isTokenMode
- ? filteredDays.reduce((sum, d) => sum + d.tokens, 0)
- : filteredDays.reduce((sum, d) => sum + d.cost, 0);
- }
-
- // Otherwise use total
- return isTokenMode ? (usage.totalTokens ?? 0) : (usage.totalCost ?? 0);
- };
-
- const sortedSessions = [...sessions].toSorted((a, b) => {
- switch (sessionSort) {
- case "recent":
- return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
- case "messages":
- return (b.usage?.messageCounts?.total ?? 0) - (a.usage?.messageCounts?.total ?? 0);
- case "errors":
- return (b.usage?.messageCounts?.errors ?? 0) - (a.usage?.messageCounts?.errors ?? 0);
- case "cost":
- return getSessionValue(b) - getSessionValue(a);
- case "tokens":
- default:
- return getSessionValue(b) - getSessionValue(a);
- }
- });
- const sortedWithDir = sessionSortDir === "asc" ? sortedSessions.toReversed() : sortedSessions;
-
- const totalValue = sortedWithDir.reduce((sum, session) => sum + getSessionValue(session), 0);
- const avgValue = sortedWithDir.length ? totalValue / sortedWithDir.length : 0;
- const totalErrors = sortedWithDir.reduce(
- (sum, session) => sum + (session.usage?.messageCounts?.errors ?? 0),
- 0,
- );
-
- const selectedSet = new Set(selectedSessions);
- const selectedEntries = sortedWithDir.filter((s) => selectedSet.has(s.key));
- const selectedCount = selectedEntries.length;
- const sessionMap = new Map(sortedWithDir.map((s) => [s.key, s]));
- const recentEntries = recentSessions
- .map((key) => sessionMap.get(key))
- .filter((entry): entry is UsageSessionEntry => Boolean(entry));
-
- return html`
-
-
-
- ${
- sessionsTab === "recent"
- ? recentEntries.length === 0
- ? html`
-
No recent sessions
- `
- : html`
-
- ${recentEntries.map((s) => {
- const value = getSessionValue(s);
- const isSelected = selectedSet.has(s.key);
- const displayLabel = formatSessionListLabel(s);
- const meta = buildSessionMeta(s);
- return html`
-
onSelectSession(s.key, e.shiftKey)}
- title="${s.key}"
- >
-
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
-
-
-
-
-
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `;
- })}
-
- `
- : sessions.length === 0
- ? html`
-
No sessions in range
- `
- : html`
-
- ${sortedWithDir.slice(0, 50).map((s) => {
- const value = getSessionValue(s);
- const isSelected = selectedSessions.includes(s.key);
- const displayLabel = formatSessionListLabel(s);
- const meta = buildSessionMeta(s);
-
- return html`
-
onSelectSession(s.key, e.shiftKey)}
- title="${s.key}"
- >
-
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
-
-
-
-
-
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `;
- })}
- ${sessions.length > 50 ? html`
+${sessions.length - 50} more
` : nothing}
-
- `
- }
- ${
- selectedCount > 1
- ? html`
-
-
Selected (${selectedCount})
-
- ${selectedEntries.map((s) => {
- const value = getSessionValue(s);
- const displayLabel = formatSessionListLabel(s);
- const meta = buildSessionMeta(s);
- return html`
-
onSelectSession(s.key, e.shiftKey)}
- title="${s.key}"
- >
-
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing}
-
-
-
-
-
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `;
- })}
-
-
- `
- : nothing
- }
-
- `;
-}
-
-function renderEmptyDetailState() {
- return nothing;
-}
-
-function renderSessionSummary(session: UsageSessionEntry) {
- const usage = session.usage;
- if (!usage) {
- return html`
- No usage data for this session.
- `;
- }
-
- const formatTs = (ts?: number): string => (ts ? new Date(ts).toLocaleString() : "—");
-
- const badges: string[] = [];
- if (session.channel) {
- badges.push(`channel:${session.channel}`);
- }
- if (session.agentId) {
- badges.push(`agent:${session.agentId}`);
- }
- if (session.modelProvider || session.providerOverride) {
- badges.push(`provider:${session.modelProvider ?? session.providerOverride}`);
- }
- if (session.model) {
- badges.push(`model:${session.model}`);
- }
-
- const toolItems =
- usage.toolUsage?.tools.slice(0, 6).map((tool) => ({
- label: tool.name,
- value: `${tool.count}`,
- sub: "calls",
- })) ?? [];
- const modelItems =
- usage.modelUsage?.slice(0, 6).map((entry) => ({
- label: entry.model ?? "unknown",
- value: formatCost(entry.totals.totalCost),
- sub: formatTokens(entry.totals.totalTokens),
- })) ?? [];
-
- return html`
- ${badges.length > 0 ? html`${badges.map((b) => html`${b}`)}
` : nothing}
-
-
-
Messages
-
${usage.messageCounts?.total ?? 0}
-
${usage.messageCounts?.user ?? 0} user · ${usage.messageCounts?.assistant ?? 0} assistant
-
-
-
Tool Calls
-
${usage.toolUsage?.totalCalls ?? 0}
-
${usage.toolUsage?.uniqueTools ?? 0} tools
-
-
-
Errors
-
${usage.messageCounts?.errors ?? 0}
-
${usage.messageCounts?.toolResults ?? 0} tool results
-
-
-
Duration
-
${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}
-
${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}
-
-
-
- ${renderInsightList("Top Tools", toolItems, "No tool calls")}
- ${renderInsightList("Model Mix", modelItems, "No model data")}
-
- `;
-}
-
-function renderSessionDetailPanel(
- session: UsageSessionEntry,
- timeSeries: { points: TimeSeriesPoint[] } | null,
- timeSeriesLoading: boolean,
- timeSeriesMode: "cumulative" | "per-turn",
- onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void,
- timeSeriesBreakdownMode: "total" | "by-type",
- onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void,
- startDate: string,
- endDate: string,
- selectedDays: string[],
- sessionLogs: SessionLogEntry[] | null,
- sessionLogsLoading: boolean,
- sessionLogsExpanded: boolean,
- onToggleSessionLogsExpanded: () => void,
- logFilters: {
- roles: SessionLogRole[];
- tools: string[];
- hasTools: boolean;
- query: string;
- },
- onLogFilterRolesChange: (next: SessionLogRole[]) => void,
- onLogFilterToolsChange: (next: string[]) => void,
- onLogFilterHasToolsChange: (next: boolean) => void,
- onLogFilterQueryChange: (next: string) => void,
- onLogFilterClear: () => void,
- contextExpanded: boolean,
- onToggleContextExpanded: () => void,
- onClose: () => void,
-) {
- const label = session.label || session.key;
- const displayLabel = label.length > 50 ? label.slice(0, 50) + "…" : label;
- const usage = session.usage;
-
- return html`
-
-
-
- ${renderSessionSummary(session)}
-
- ${renderTimeSeriesCompact(
- timeSeries,
- timeSeriesLoading,
- timeSeriesMode,
- onTimeSeriesModeChange,
- timeSeriesBreakdownMode,
- onTimeSeriesBreakdownChange,
- startDate,
- endDate,
- selectedDays,
- )}
-
-
- ${renderSessionLogsCompact(
- sessionLogs,
- sessionLogsLoading,
- sessionLogsExpanded,
- onToggleSessionLogsExpanded,
- logFilters,
- onLogFilterRolesChange,
- onLogFilterToolsChange,
- onLogFilterHasToolsChange,
- onLogFilterQueryChange,
- onLogFilterClear,
- )}
- ${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)}
-
-
-
- `;
-}
-
-function renderTimeSeriesCompact(
- timeSeries: { points: TimeSeriesPoint[] } | null,
- loading: boolean,
- mode: "cumulative" | "per-turn",
- onModeChange: (mode: "cumulative" | "per-turn") => void,
- breakdownMode: "total" | "by-type",
- onBreakdownChange: (mode: "total" | "by-type") => void,
- startDate?: string,
- endDate?: string,
- selectedDays?: string[],
-) {
- if (loading) {
- return html`
-
- `;
- }
- if (!timeSeries || timeSeries.points.length < 2) {
- return html`
-
- `;
- }
-
- // Filter and recalculate (same logic as main function)
- let points = timeSeries.points;
- if (startDate || endDate || (selectedDays && selectedDays.length > 0)) {
- const startTs = startDate ? new Date(startDate + "T00:00:00").getTime() : 0;
- const endTs = endDate ? new Date(endDate + "T23:59:59").getTime() : Infinity;
- points = timeSeries.points.filter((p) => {
- if (p.timestamp < startTs || p.timestamp > endTs) {
- return false;
- }
- if (selectedDays && selectedDays.length > 0) {
- const d = new Date(p.timestamp);
- const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
- return selectedDays.includes(dateStr);
- }
- return true;
- });
- }
- if (points.length < 2) {
- return html`
-
- `;
- }
- let cumTokens = 0,
- cumCost = 0;
- let sumOutput = 0;
- let sumInput = 0;
- let sumCacheRead = 0;
- let sumCacheWrite = 0;
- points = points.map((p) => {
- cumTokens += p.totalTokens;
- cumCost += p.cost;
- sumOutput += p.output;
- sumInput += p.input;
- sumCacheRead += p.cacheRead;
- sumCacheWrite += p.cacheWrite;
- return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost };
- });
-
- const width = 400,
- height = 80;
- const padding = { top: 16, right: 10, bottom: 20, left: 40 };
- const chartWidth = width - padding.left - padding.right;
- const chartHeight = height - padding.top - padding.bottom;
- const isCumulative = mode === "cumulative";
- const breakdownByType = mode === "per-turn" && breakdownMode === "by-type";
- const totalTypeTokens = sumOutput + sumInput + sumCacheRead + sumCacheWrite;
- const barTotals = points.map((p) =>
- isCumulative
- ? p.cumulativeTokens
- : breakdownByType
- ? p.input + p.output + p.cacheRead + p.cacheWrite
- : p.totalTokens,
- );
- const maxValue = Math.max(...barTotals, 1);
- const barWidth = Math.max(2, Math.min(8, (chartWidth / points.length) * 0.7));
- const barGap = Math.max(1, (chartWidth - barWidth * points.length) / (points.length - 1 || 1));
-
- return html`
-
-
-
-
${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}
- ${
- breakdownByType
- ? html`
-
-
Tokens by Type
-
-
-
- Output ${formatTokens(sumOutput)}
-
-
- Input ${formatTokens(sumInput)}
-
-
- Cache Write ${formatTokens(sumCacheWrite)}
-
-
- Cache Read ${formatTokens(sumCacheRead)}
-
-
-
Total: ${formatTokens(totalTypeTokens)}
-
- `
- : nothing
- }
-
- `;
-}
-
-function renderContextPanel(
- contextWeight: UsageSessionEntry["contextWeight"],
- usage: UsageSessionEntry["usage"],
- expanded: boolean,
- onToggleExpanded: () => void,
-) {
- if (!contextWeight) {
- return html`
-
- `;
- }
- const systemTokens = charsToTokens(contextWeight.systemPrompt.chars);
- const skillsTokens = charsToTokens(contextWeight.skills.promptChars);
- const toolsTokens = charsToTokens(
- contextWeight.tools.listChars + contextWeight.tools.schemaChars,
- );
- const filesTokens = charsToTokens(
- contextWeight.injectedWorkspaceFiles.reduce((sum, f) => sum + f.injectedChars, 0),
- );
- const totalContextTokens = systemTokens + skillsTokens + toolsTokens + filesTokens;
-
- let contextPct = "";
- if (usage && usage.totalTokens > 0) {
- const inputTokens = usage.input + usage.cacheRead;
- if (inputTokens > 0) {
- contextPct = `~${Math.min((totalContextTokens / inputTokens) * 100, 100).toFixed(0)}% of input`;
- }
- }
-
- const skillsList = contextWeight.skills.entries.toSorted((a, b) => b.blockChars - a.blockChars);
- const toolsList = contextWeight.tools.entries.toSorted(
- (a, b) => b.summaryChars + b.schemaChars - (a.summaryChars + a.schemaChars),
- );
- const filesList = contextWeight.injectedWorkspaceFiles.toSorted(
- (a, b) => b.injectedChars - a.injectedChars,
- );
- const defaultLimit = 4;
- const showAll = expanded;
- const skillsTop = showAll ? skillsList : skillsList.slice(0, defaultLimit);
- const toolsTop = showAll ? toolsList : toolsList.slice(0, defaultLimit);
- const filesTop = showAll ? filesList : filesList.slice(0, defaultLimit);
- const hasMore =
- skillsList.length > defaultLimit ||
- toolsList.length > defaultLimit ||
- filesList.length > defaultLimit;
-
- return html`
-
-
-
${contextPct || "Base context per message"}
-
-
- Sys ~${formatTokens(systemTokens)}
- Skills ~${formatTokens(skillsTokens)}
- Tools ~${formatTokens(toolsTokens)}
- Files ~${formatTokens(filesTokens)}
-
-
Total: ~${formatTokens(totalContextTokens)}
-
- ${
- skillsList.length > 0
- ? (() => {
- const more = skillsList.length - skillsTop.length;
- return html`
-
-
Skills (${skillsList.length})
-
- ${skillsTop.map(
- (s) => html`
-
- ${s.name}
- ~${formatTokens(charsToTokens(s.blockChars))}
-
- `,
- )}
-
- ${
- more > 0
- ? html`
+${more} more
`
- : nothing
- }
-
- `;
- })()
- : nothing
- }
- ${
- toolsList.length > 0
- ? (() => {
- const more = toolsList.length - toolsTop.length;
- return html`
-
-
Tools (${toolsList.length})
-
- ${toolsTop.map(
- (t) => html`
-
- ${t.name}
- ~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))}
-
- `,
- )}
-
- ${
- more > 0
- ? html`
+${more} more
`
- : nothing
- }
-
- `;
- })()
- : nothing
- }
- ${
- filesList.length > 0
- ? (() => {
- const more = filesList.length - filesTop.length;
- return html`
-
-
Files (${filesList.length})
-
- ${filesTop.map(
- (f) => html`
-
- ${f.name}
- ~${formatTokens(charsToTokens(f.injectedChars))}
-
- `,
- )}
-
- ${
- more > 0
- ? html`
+${more} more
`
- : nothing
- }
-
- `;
- })()
- : nothing
- }
-
-
- `;
-}
-
-function renderSessionLogsCompact(
- logs: SessionLogEntry[] | null,
- loading: boolean,
- expandedAll: boolean,
- onToggleExpandedAll: () => void,
- filters: {
- roles: SessionLogRole[];
- tools: string[];
- hasTools: boolean;
- query: string;
- },
- onFilterRolesChange: (next: SessionLogRole[]) => void,
- onFilterToolsChange: (next: string[]) => void,
- onFilterHasToolsChange: (next: boolean) => void,
- onFilterQueryChange: (next: string) => void,
- onFilterClear: () => void,
-) {
- if (loading) {
- return html`
-
- `;
- }
- if (!logs || logs.length === 0) {
- return html`
-
- `;
- }
-
- const normalizedQuery = filters.query.trim().toLowerCase();
- const entries = logs.map((log) => {
- const toolInfo = parseToolSummary(log.content);
- const cleanContent = toolInfo.cleanContent || log.content;
- return { log, toolInfo, cleanContent };
- });
- const toolOptions = Array.from(
- new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))),
- ).toSorted((a, b) => a.localeCompare(b));
- const filteredEntries = entries.filter((entry) => {
- if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) {
- return false;
- }
- if (filters.hasTools && entry.toolInfo.tools.length === 0) {
- return false;
- }
- if (filters.tools.length > 0) {
- const matchesTool = entry.toolInfo.tools.some(([name]) => filters.tools.includes(name));
- if (!matchesTool) {
- return false;
- }
- }
- if (normalizedQuery) {
- const haystack = entry.cleanContent.toLowerCase();
- if (!haystack.includes(normalizedQuery)) {
- return false;
- }
- }
- return true;
- });
- const displayedCount =
- filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery
- ? `${filteredEntries.length} of ${logs.length}`
- : `${logs.length}`;
-
- const roleSelected = new Set(filters.roles);
- const toolSelected = new Set(filters.tools);
-
- return html`
-
-
-
-
-
-
- onFilterQueryChange((event.target as HTMLInputElement).value)}
- />
-
-
-
- ${filteredEntries.map((entry) => {
- const { log, toolInfo, cleanContent } = entry;
- const roleClass = log.role === "user" ? "user" : "assistant";
- const roleLabel =
- log.role === "user" ? "You" : log.role === "assistant" ? "Assistant" : "Tool";
- return html`
-
-
- ${roleLabel}
- ${new Date(log.timestamp).toLocaleString()}
- ${log.tokens ? html`${formatTokens(log.tokens)}` : nothing}
-
-
${cleanContent}
- ${
- toolInfo.tools.length > 0
- ? html`
-
- ${toolInfo.summary}
-
- ${toolInfo.tools.map(
- ([name, count]) => html`
- ${name} × ${count}
- `,
- )}
-
-
- `
- : nothing
- }
-
- `;
- })}
- ${
- filteredEntries.length === 0
- ? html`
-
No messages match the filters.
- `
- : nothing
- }
-
-
- `;
-}
-
export function renderUsage(props: UsageProps) {
// Show loading skeleton if loading and no data yet
if (props.loading && !props.totals) {