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` +
+
+
+
Activity by Time
+
Estimates require session timestamps.
+
+
${formatTokens(0)} tokens
+
+
No timeline data yet.
+
+ `; + } + + const maxHour = Math.max(...stats.hourTotals, 1); + const maxWeekday = Math.max(...stats.weekdayTotals.map((d) => d.tokens), 1); + + return html` +
+
+
+
Activity by Time
+
+ Estimated from session spans (first/last activity). Time zone: ${timeZone === "utc" ? "UTC" : "Local"}. +
+
+
${formatTokens(stats.totalTokens)} tokens
+
+
+
+
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` +
+
+
+
${displayLabel}
+
+
+ ${ + usage + ? html` + ${formatTokens(usage.totalTokens)} tokens + ${formatCost(usage.totalCost)} + ` + : nothing + } +
+ +
+
+ ${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` +
+
Loading...
+
+ `; + } + if (!timeSeries || timeSeries.points.length < 2) { + return html` +
+
No timeline data
+
+ `; + } + + // 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` +
+
No data in range
+
+ `; + } + 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` +
+
+
Usage Over Time
+
+
+ + +
+ ${ + !isCumulative + ? html` +
+ + +
+ ` + : nothing + } +
+
+ + + + + + + ${formatTokens(maxValue)} + 0 + + ${ + points.length > 0 + ? svg` + ${new Date(points[0].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + ${new Date(points[points.length - 1].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + ` + : nothing + } + + ${points.map((p, i) => { + const val = barTotals[i]; + const x = padding.left + i * (barWidth + barGap); + const barHeight = (val / maxValue) * chartHeight; + const y = padding.top + chartHeight - barHeight; + const date = new Date(p.timestamp); + const tooltipLines = [ + date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + `${formatTokens(val)} tokens`, + ]; + if (breakdownByType) { + tooltipLines.push(`Output ${formatTokens(p.output)}`); + tooltipLines.push(`Input ${formatTokens(p.input)}`); + tooltipLines.push(`Cache write ${formatTokens(p.cacheWrite)}`); + tooltipLines.push(`Cache read ${formatTokens(p.cacheRead)}`); + } + const tooltip = tooltipLines.join(" · "); + if (!breakdownByType) { + return svg`${tooltip}`; + } + const segments = [ + { value: p.output, class: "output" }, + { value: p.input, class: "input" }, + { value: p.cacheWrite, class: "cache-write" }, + { value: p.cacheRead, class: "cache-read" }, + ]; + let yCursor = padding.top + chartHeight; + return svg` + ${segments.map((seg) => { + if (seg.value <= 0 || val <= 0) { + return nothing; + } + const segHeight = barHeight * (seg.value / val); + yCursor -= segHeight; + return svg`${tooltip}`; + })} + `; + })} + +
${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` +
+
No context data
+
+ `; + } + 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` +
+
+
System Prompt Breakdown
+ ${ + hasMore + ? html`` + : nothing + } +
+

${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` +
+
Conversation
+
Loading...
+
+ `; + } + if (!logs || logs.length === 0) { + return html` +
+
Conversation
+
No messages
+
+ `; + } + + 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` +
+
+ Conversation (${displayedCount} messages) + +
+
+ + + + 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 ${isTokenMode ? "Token" : "Cost"} Usage
+
+
+
+ ${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}
+
+ ${formatFullDate(d.date)}
+ ${formatTokens(d.totalTokens)} tokens
+ ${formatCost(d.totalCost)} + ${ + breakdownLines.length + ? html`${breakdownLines.map((line) => html`
${line}
`)}` + : nothing + } +
+
+ `; + })} +
+
+
+ `; +} + +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` +
+
${isTokenMode ? "Tokens" : "Cost"} by Type
+
+
+
+
+
+
+
+ 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` +
+
+
Sessions
+
+ ${sessions.length} shown${totalSessions !== sessions.length ? ` · ${totalSessions} total` : ""} +
+
+
+
+ ${isTokenMode ? formatTokens(avgValue) : formatCost(avgValue)} avg + ${totalErrors} errors +
+
+ + +
+ + + ${ + selectedCount > 0 + ? html` + + ` + : nothing + } +
+ ${ + 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` -
-
-
-
Activity by Time
-
Estimates require session timestamps.
-
-
${formatTokens(0)} tokens
-
-
No timeline data yet.
-
- `; - } - - const maxHour = Math.max(...stats.hourTotals, 1); - const maxWeekday = Math.max(...stats.weekdayTotals.map((d) => d.tokens), 1); - - return html` -
-
-
-
Activity by Time
-
- Estimated from session spans (first/last activity). Time zone: ${timeZone === "utc" ? "UTC" : "Local"}. -
-
-
${formatTokens(stats.totalTokens)} tokens
-
-
-
-
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 ${isTokenMode ? "Token" : "Cost"} Usage
-
-
-
- ${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}
-
- ${formatFullDate(d.date)}
- ${formatTokens(d.totalTokens)} tokens
- ${formatCost(d.totalCost)} - ${ - breakdownLines.length - ? html`${breakdownLines.map((line) => html`
${line}
`)}` - : nothing - } -
-
- `; - })} -
-
-
- `; -} - -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` -
-
${isTokenMode ? "Tokens" : "Cost"} by Type
-
-
-
-
-
-
-
- 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` -
-
-
Sessions
-
- ${sessions.length} shown${totalSessions !== sessions.length ? ` · ${totalSessions} total` : ""} -
-
-
-
- ${isTokenMode ? formatTokens(avgValue) : formatCost(avgValue)} avg - ${totalErrors} errors -
-
- - -
- - - ${ - selectedCount > 0 - ? html` - - ` - : nothing - } -
- ${ - 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` -
-
-
-
${displayLabel}
-
-
- ${ - usage - ? html` - ${formatTokens(usage.totalTokens)} tokens - ${formatCost(usage.totalCost)} - ` - : nothing - } -
- -
-
- ${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` -
-
Loading...
-
- `; - } - if (!timeSeries || timeSeries.points.length < 2) { - return html` -
-
No timeline data
-
- `; - } - - // 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` -
-
No data in range
-
- `; - } - 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` -
-
-
Usage Over Time
-
-
- - -
- ${ - !isCumulative - ? html` -
- - -
- ` - : nothing - } -
-
- - - - - - - ${formatTokens(maxValue)} - 0 - - ${ - points.length > 0 - ? svg` - ${new Date(points[0].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - ${new Date(points[points.length - 1].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} - ` - : nothing - } - - ${points.map((p, i) => { - const val = barTotals[i]; - const x = padding.left + i * (barWidth + barGap); - const barHeight = (val / maxValue) * chartHeight; - const y = padding.top + chartHeight - barHeight; - const date = new Date(p.timestamp); - const tooltipLines = [ - date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), - `${formatTokens(val)} tokens`, - ]; - if (breakdownByType) { - tooltipLines.push(`Output ${formatTokens(p.output)}`); - tooltipLines.push(`Input ${formatTokens(p.input)}`); - tooltipLines.push(`Cache write ${formatTokens(p.cacheWrite)}`); - tooltipLines.push(`Cache read ${formatTokens(p.cacheRead)}`); - } - const tooltip = tooltipLines.join(" · "); - if (!breakdownByType) { - return svg`${tooltip}`; - } - const segments = [ - { value: p.output, class: "output" }, - { value: p.input, class: "input" }, - { value: p.cacheWrite, class: "cache-write" }, - { value: p.cacheRead, class: "cache-read" }, - ]; - let yCursor = padding.top + chartHeight; - return svg` - ${segments.map((seg) => { - if (seg.value <= 0 || val <= 0) { - return nothing; - } - const segHeight = barHeight * (seg.value / val); - yCursor -= segHeight; - return svg`${tooltip}`; - })} - `; - })} - -
${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` -
-
No context data
-
- `; - } - 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` -
-
-
System Prompt Breakdown
- ${ - hasMore - ? html`` - : nothing - } -
-

${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` -
-
Conversation
-
Loading...
-
- `; - } - if (!logs || logs.length === 0) { - return html` -
-
Conversation
-
No messages
-
- `; - } - - 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` -
-
- Conversation (${displayedCount} messages) - -
-
- - - - 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) {