diff --git a/docs/gateway/images/control-ui-analytics.png b/docs/gateway/images/control-ui-analytics.png index 3f1cb78a8..220e603bc 100644 Binary files a/docs/gateway/images/control-ui-analytics.png and b/docs/gateway/images/control-ui-analytics.png differ diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 188479679..c563161a1 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -44,6 +44,34 @@ The onboarding wizard generates a gateway token by default, so paste it here on - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Update: run a package/git update + restart (`update.run`) with a restart report +- Analytics: token usage stats, cost estimates, and plan quota display (`usage.cost`, `usage.status`) + +## Analytics + +The Analytics tab provides visibility into your token usage and costs: + +![Analytics Dashboard](/gateway/images/control-ui-analytics.png) + +### Plan Quota + +Shows your current usage against plan limits (for providers that expose quota data): + +- **Usage bars**: Visual progress bars for each rate limit window (5-hour, weekly, model-specific) +- **Reset timers**: Time remaining until each limit resets +- **Provider support**: Currently supports Anthropic API keys with quota access + +For Claude Code OAuth tokens (which use the `user:inference` scope only), plan quota is not available programmatically. The UI displays a link to [claude.ai/settings/usage](https://claude.ai/settings/usage) where you can check your usage directly. + +### Usage Summary + +Aggregates token consumption from your session transcripts: + +- **Summary cards**: Total tokens, input/output breakdown, cache read/write, estimated cost +- **Daily charts**: Bar charts showing token usage and cost over time +- **Time periods**: Select 7, 14, 30, or 90 day views +- **Refresh**: Click to reload data from the gateway + +Cost estimates are based on published model pricing and may not reflect actual billing (discounts, commitments, etc.). ## Chat behavior diff --git a/ui/src/ui/analytics-screenshot.e2e.mjs b/ui/src/ui/analytics-screenshot.e2e.mjs new file mode 100644 index 000000000..c5f5dfa2d --- /dev/null +++ b/ui/src/ui/analytics-screenshot.e2e.mjs @@ -0,0 +1,94 @@ +import { chromium } from 'playwright-core'; +import { readdirSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +function getTokenFromConfig() { + const configPath = join(process.env.HOME, '.clawdbot/clawdbot.json'); + if (!existsSync(configPath)) { + throw new Error('Config file not found at ~/.clawdbot/clawdbot.json'); + } + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const token = config?.gateway?.auth?.token; + if (!token) { + throw new Error('No gateway.auth.token found in config'); + } + return token; +} + +const TOKEN = getTokenFromConfig(); +const BASE_URL = process.env.GATEWAY_URL || 'http://127.0.0.1:18789'; + +function findChromiumExecutable() { + const cacheDir = join(process.env.HOME, '.cache/ms-playwright'); + if (!existsSync(cacheDir)) return undefined; + + const dirs = readdirSync(cacheDir) + .filter(d => d.startsWith('chromium-') && !d.includes('headless')) + .sort() + .reverse(); + + if (dirs.length === 0) return undefined; + + const chromiumDir = join(cacheDir, dirs[0]); + const linuxPath = join(chromiumDir, 'chrome-linux64/chrome'); + const macPath = join(chromiumDir, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'); + + if (existsSync(linuxPath)) return linuxPath; + if (existsSync(macPath)) return macPath; + + return undefined; +} + +async function takeScreenshot() { + const executablePath = findChromiumExecutable(); + console.log('Using chromium:', executablePath || 'bundled'); + + const launchOptions = { headless: true }; + if (executablePath) { + launchOptions.executablePath = executablePath; + } + + const browser = await chromium.launch(launchOptions); + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 } + }); + + const page = await context.newPage(); + + page.on('console', msg => console.log('PAGE:', msg.text())); + + // Navigate and set token + await page.goto(`${BASE_URL}/?token=${TOKEN}`); + await page.waitForTimeout(2000); + + await page.evaluate((token) => { + const settings = JSON.parse(localStorage.getItem('clawdbot-control-ui-settings') || '{}'); + settings.token = token; + localStorage.setItem('clawdbot-control-ui-settings', JSON.stringify(settings)); + }, TOKEN); + + // Navigate to analytics + await page.goto(`${BASE_URL}/analytics`); + await page.waitForTimeout(4000); + + // Click refresh + const refreshButton = page.locator('button', { hasText: 'Refresh' }); + try { + await refreshButton.click(); + console.log('Clicked refresh'); + await page.waitForTimeout(3000); + } catch (e) { + console.log('Could not click refresh:', e.message); + } + + const outputPath = process.argv[2] || '/tmp/analytics-screenshot.png'; + await page.screenshot({ path: outputPath, fullPage: false }); + console.log('Screenshot saved to', outputPath); + + await browser.close(); +} + +takeScreenshot().catch(err => { + console.error('Error:', err); + process.exit(1); +});