diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index db29bd7ec..e8a350788 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -50,6 +50,7 @@ import { rotateDeviceToken, } from "./controllers/devices"; import { renderSkills } from "./views/skills"; +import { renderAnalytics } from "./views/analytics"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels } from "./controllers/channels"; import { loadPresence } from "./controllers/presence"; @@ -81,6 +82,7 @@ import { import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; +import { loadAnalytics } from "./controllers/analytics"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -304,6 +306,20 @@ export function renderApp(state: AppViewState) { }) : nothing} + ${state.tab === "analytics" + ? renderAnalytics({ + loading: state.analyticsLoading, + error: state.analyticsError, + data: state.analyticsData, + days: state.analyticsDays, + onDaysChange: (days) => { + state.analyticsDays = days; + void loadAnalytics(state); + }, + onRefresh: () => loadAnalytics(state), + }) + : nothing} + ${state.tab === "cron" ? renderCron({ loading: state.cronLoading, diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 0cceff4cb..5ed671c0f 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -1,3 +1,4 @@ +import { loadAnalytics } from "./controllers/analytics"; import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadCronJobs, loadCronStatus } from "./controllers/cron"; import { loadChannels } from "./controllers/channels"; @@ -146,6 +147,7 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "channels") await loadChannelsTab(host); if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp); if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp); + if (host.tab === "analytics") await loadAnalytics(host as unknown as ClawdbotApp); if (host.tab === "cron") await loadCron(host); if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp); if (host.tab === "nodes") { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f589c760c..a53774c81 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -112,6 +112,10 @@ export type AppViewState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + analyticsLoading: boolean; + analyticsError: string | null; + analyticsData: unknown | null; + analyticsDays: number; cronLoading: boolean; cronJobs: CronJob[]; cronStatus: CronStatus | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0e21d283a..f07066076 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -202,6 +202,11 @@ export class ClawdbotApp extends LitElement { @state() sessionsIncludeGlobal = true; @state() sessionsIncludeUnknown = false; + @state() analyticsLoading = false; + @state() analyticsError: string | null = null; + @state() analyticsData: unknown | null = null; + @state() analyticsDays = 30; + @state() cronLoading = false; @state() cronJobs: CronJob[] = []; @state() cronStatus: CronStatus | null = null; diff --git a/ui/src/ui/controllers/analytics.ts b/ui/src/ui/controllers/analytics.ts new file mode 100644 index 000000000..a4489a6a3 --- /dev/null +++ b/ui/src/ui/controllers/analytics.ts @@ -0,0 +1,28 @@ +import type { GatewayBrowserClient } from "../gateway.js"; +import type { CostUsageSummary } from "../views/analytics.js"; + +export type AnalyticsState = { + client: GatewayBrowserClient; + analyticsLoading: boolean; + analyticsError: string | null; + analyticsData: CostUsageSummary | null; + analyticsDays: number; +}; + +export async function loadAnalytics(state: AnalyticsState): Promise { + state.analyticsLoading = true; + state.analyticsError = null; + + try { + const res = await state.client.request("usage.cost", { days: state.analyticsDays }); + if (res.ok && res.result) { + state.analyticsData = res.result as CostUsageSummary; + } else { + state.analyticsError = res.error?.message ?? "Failed to load usage data"; + } + } catch (err) { + state.analyticsError = err instanceof Error ? err.message : "Failed to load usage data"; + } finally { + state.analyticsLoading = false; + } +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 5938e25e9..8735d3453 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -4,7 +4,7 @@ export const TAB_GROUPS = [ { label: "Chat", tabs: ["chat"] }, { label: "Control", - tabs: ["overview", "channels", "instances", "sessions", "cron"], + tabs: ["overview", "channels", "instances", "sessions", "cron", "analytics"], }, { label: "Agent", tabs: ["skills", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, @@ -16,6 +16,7 @@ export type Tab = | "instances" | "sessions" | "cron" + | "analytics" | "skills" | "nodes" | "chat" @@ -29,6 +30,7 @@ const TAB_PATHS: Record = { instances: "/instances", sessions: "/sessions", cron: "/cron", + analytics: "/analytics", skills: "/skills", nodes: "/nodes", chat: "/chat", @@ -114,6 +116,8 @@ export function iconForTab(tab: Tab): IconName { return "fileText"; case "cron": return "loader"; + case "analytics": + return "barChart"; case "skills": return "zap"; case "nodes": @@ -141,6 +145,8 @@ export function titleForTab(tab: Tab) { return "Sessions"; case "cron": return "Cron Jobs"; + case "analytics": + return "Analytics"; case "skills": return "Skills"; case "nodes": @@ -170,6 +176,8 @@ export function subtitleForTab(tab: Tab) { return "Inspect active sessions and adjust per-session defaults."; case "cron": return "Schedule wakeups and recurring agent runs."; + case "analytics": + return "Token usage and cost trends over time."; case "skills": return "Manage skill availability and API key injection."; case "nodes": diff --git a/ui/src/ui/views/analytics.ts b/ui/src/ui/views/analytics.ts new file mode 100644 index 000000000..fb2d3ad77 --- /dev/null +++ b/ui/src/ui/views/analytics.ts @@ -0,0 +1,240 @@ +import { html, nothing } from "lit"; + +export type CostUsageTotals = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + missingCostEntries: number; +}; + +export type CostUsageDailyEntry = CostUsageTotals & { + date: string; +}; + +export type CostUsageSummary = { + updatedAt: number; + days: number; + daily: CostUsageDailyEntry[]; + totals: CostUsageTotals; +}; + +export type AnalyticsProps = { + loading: boolean; + error: string | null; + data: CostUsageSummary | null; + days: number; + onDaysChange: (days: number) => void; + onRefresh: () => void; +}; + +function formatNumber(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 n.toFixed(0); +} + +function formatCost(n: number): string { + if (n === 0) return "$0.00"; + if (n < 0.01) return `$${n.toFixed(4)}`; + return `$${n.toFixed(2)}`; +} + +function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "totalCost") { + if (!daily.length) { + return html`
No data for this period
`; + } + + const values = daily.map((d) => d[metric]); + const max = Math.max(...values, 1); + const barWidth = Math.max(8, Math.min(40, Math.floor(600 / daily.length) - 4)); + + return html` +
+
+ ${daily.map((entry, i) => { + const value = entry[metric]; + const height = Math.max(2, (value / max) * 150); + const label = metric === "totalCost" ? formatCost(value) : formatNumber(value); + const dateLabel = entry.date.slice(5); // MM-DD + return html` +
+
${label}
+
+
${dateLabel}
+
+ `; + })} +
+
+ `; +} + +export function renderAnalytics(props: AnalyticsProps) { + const { loading, error, data, days } = props; + const totals = data?.totals; + + return html` + + +
+
Usage Summary
+
Token consumption and estimated costs.
+ +
+ + +
+ + ${error ? html`
${error}
` : nothing} + + ${totals + ? html` +
+
+
Total Tokens
+
${formatNumber(totals.totalTokens)}
+
+
+
Input Tokens
+
${formatNumber(totals.input)}
+
+
+
Output Tokens
+
${formatNumber(totals.output)}
+
+
+
Cache Read
+
${formatNumber(totals.cacheRead)}
+
+
+
Cache Write
+
${formatNumber(totals.cacheWrite)}
+
+
+
Est. Cost
+
${formatCost(totals.totalCost)}
+
+
+ ` + : nothing} +
+ +
+
Daily Tokens
+
Token usage per day.
+ ${data?.daily ? renderBarChart(data.daily, "totalTokens") : html`
${loading ? "Loading..." : "No data"}
`} +
+ +
+
Daily Cost
+
Estimated cost per day.
+ ${data?.daily ? renderBarChart(data.daily, "totalCost") : html`
${loading ? "Loading..." : "No data"}
`} +
+ `; +}