import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { InboxMixin } from '../lib/inbox-mixin.js'; // Shared palette for the breakdown bars (cycled when there are more rows). const PALETTE = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; // The `kind` values that have a proper label (the server sends the session's // source, or 'sub-agent' for child frames); anything else renders as-is, so a // new source needs no frontend change to appear. const KIND_LABELS = { 'web': 'dashboard.stats.kind.web', 'mobile': 'dashboard.stats.kind.mobile', 'telegram': 'dashboard.stats.kind.telegram', 'cron': 'dashboard.stats.kind.cron', 'sub-agent': 'dashboard.stats.kind.sub_agent', 'event-triage': 'dashboard.stats.kind.event_triage', 'memory-lint': 'dashboard.stats.kind.memory_lint', 'conversation-review': 'dashboard.stats.kind.conversation_review', 'unknown': 'dashboard.stats.kind.unknown', }; function fmtTok(n) { if (n == null) return '0'; if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return String(n); } function kindLabel(key) { const i18nKey = KIND_LABELS[key]; if (!i18nKey) return key; const label = t(i18nKey); return label === i18nKey ? key : label; } export class DashboardPage extends InboxMixin(LightElement) { static get properties() { return { ...super.properties, _open: { state: true }, _models: { state: true }, _plugins: { state: true }, _stats: { state: true }, _statsRange: { state: true }, _statsUser: { state: true }, }; } constructor() { super(); this._open = false; this._models = null; // null = loading, [] = no models configured this._plugins = null; this._pollTimer = null; this._stats = null; // null = loading this._statsRange = 'week'; this._statsUser = ''; // '' = whole instance this._members = []; // cached out of the stats response: the chips // must survive a reload that filters them away this._chartInstances = {}; this._statsTimer = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'dashboard'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) { this._loadAll(); this._loadStats(); this._startPolling(); } else { this._stopPolling(); } }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); this._stopPolling(); this._destroyCharts(); } updated(changed) { super.updated?.(changed); if (changed.has('_stats') && this._stats !== null) { requestAnimationFrame(() => this._initCharts()); } } _startPolling() { this._stopPolling(); this._pollTimer = setInterval(() => this._loadAll(), 10_000); this._statsTimer = setInterval(() => this._loadStats(), 180_000); } _stopPolling() { if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } if (this._statsTimer) { clearInterval(this._statsTimer); this._statsTimer = null; } } async _loadAll() { await Promise.all([ this._loadModels(), this._loadPlugins(), this._loadInbox(), ]); } async _loadModels() { try { const res = await fetch('/api/llm/models'); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._models = await res.json(); } catch { this._models = []; } } async _loadPlugins() { try { const res = await fetch('/api/plugins'); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._plugins = await res.json(); } catch { this._plugins = []; } } async _loadStats() { try { const params = new URLSearchParams({ range: this._statsRange }); if (this._statsUser) params.set('user', this._statsUser); const res = await fetch(`/api/stats/llm?${params}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._stats = await res.json(); if (Array.isArray(this._stats.members) && this._stats.members.length) { this._members = this._stats.members; } } catch { this._stats = { daily: [], by_user: [], by_kind: [], by_agent: [], by_model: [], by_provider: [] }; } } async _setRange(range) { if (range === this._statsRange) return; this._statsRange = range; this._stats = null; await this._loadStats(); } async _setUser(user) { if (user === this._statsUser) return; this._statsUser = user; this._stats = null; await this._loadStats(); } get _honchoActive() { return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false; } get _statusInfo() { if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: t('dashboard.status.loading') }; if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.no_models') }; if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: t('dashboard.status.online') }; if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: t('dashboard.status.degraded') }; return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.offline') }; } get _guide() { return [ { icon: 'bi-chat-dots-fill', title: t('dashboard.guide.chat.title'), desc: t('dashboard.guide.chat.desc'), color: '#d95d4e' }, { icon: 'bi-inbox', title: t('dashboard.guide.inbox.title'), desc: t('dashboard.guide.inbox.desc'), color: '#f59e0b' }, { icon: 'bi-people', title: t('dashboard.guide.agents.title'), desc: t('dashboard.guide.agents.desc'), color: '#8b5cf6' }, { icon: 'bi-clock', title: t('dashboard.guide.cron.title'), desc: t('dashboard.guide.cron.desc'), color: '#f97316' }, { icon: 'bi-cpu', title: t('dashboard.guide.models.title'), desc: t('dashboard.guide.models.desc'), color: '#10b981' }, { icon: 'bi-plug', title: t('dashboard.guide.providers.title'), desc: t('dashboard.guide.providers.desc'), color: '#06b6d4' }, { icon: 'bi-shield-check', title: t('dashboard.guide.security.title'), desc: t('dashboard.guide.security.desc'), color: '#ef4444' }, ]; } _nav(page) { const url = '#' + page; history.pushState({ page }, '', url); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } })); } // ── Charts ──────────────────────────────────────────────────────────────── _destroyCharts() { for (const c of Object.values(this._chartInstances)) { c.destroy(); } this._chartInstances = {}; } _shortModelName(name) { return name .replace(/^claude-/, '') .replace(/^gpt-/, '') .replace(/-\d{8}$/, ''); } _memberLabel(key) { if (!key) return t('dashboard.stats.user_unknown'); const m = this._members.find(m => m.id === key); return m ? m.label : key; } // The breakdown cards to show for the current data, in display order. Both // the render and the chart init consume this, so they can never disagree // about which canvases exist. The per-member card is pointless — and empty // by construction — when the scope is already a single member. get _breakdownSpecs() { const s = this._stats; if (!s) return []; return [ !this._statsUser && { id: 'chart-by-user', title: t('dashboard.stats.by_user'), rows: s.by_user ?? [] }, { id: 'chart-by-kind', title: t('dashboard.stats.by_kind'), rows: s.by_kind ?? [] }, { id: 'chart-by-agent', title: t('dashboard.stats.by_agent'), rows: s.by_agent ?? [] }, { id: 'chart-by-model', title: t('dashboard.stats.by_model'), rows: s.by_model ?? [] }, { id: 'chart-by-provider', title: t('dashboard.stats.by_provider'), rows: s.by_provider ?? [] }, ] .filter(Boolean) .filter(c => c.rows.length > 0) .map(c => ({ ...c, labelFn: c.id === 'chart-by-user' ? k => this._memberLabel(k) : c.id === 'chart-by-kind' ? k => kindLabel(k) : c.id === 'chart-by-model' ? k => this._shortModelName(k) : k => k, // The axis may carry a shortened label; the tooltip shows the key itself. titleFn: c.id === 'chart-by-model' ? k => k : null, })); } get _periodLabel() { return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day'); } // Generates the full sequence of expected slots for the current range and // merges with backend data, filling missing slots with zeros. _fillGaps(daily) { const now = new Date(); const pad = n => String(n).padStart(2, '0'); const slots = []; if (this._statsRange === 'hour') { for (let i = 59; i >= 0; i--) { const d = new Date(now - i * 60_000); slots.push(`${pad(d.getHours())}:${pad(d.getMinutes())}`); } } else if (this._statsRange === 'day') { for (let i = 23; i >= 0; i--) { const d = new Date(now - i * 3_600_000); slots.push(`${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:00`); } } else { const count = this._statsRange === 'week' ? 7 : 30; for (let i = count - 1; i >= 0; i--) { const d = new Date(now - i * 86_400_000); slots.push(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`); } } const index = new Map(daily.map(d => [d.day, d])); const zero = { requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, avg_duration_ms: 0 }; return slots.map(s => ({ day: s, ...(index.get(s) ?? zero) })); } _initCharts() { if (!window.Chart || !this._stats) return; this._destroyCharts(); const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'; const gridColor = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)'; const textColor = dark ? '#adb5bd' : '#6c757d'; const isHour = this._statsRange === 'hour'; const filled = this._fillGaps(this._stats.daily); const days = filled.map(d => d.day); const req = filled.map(d => d.requests); const inp = filled.map(d => d.input_tokens); const out = filled.map(d => d.output_tokens); const cache = filled.map(d => d.cache_read_tokens); // null for empty slots so the latency line doesn't touch zero where there were no requests const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null); const axisDefaults = () => ({ ticks: { color: textColor, font: { size: 11 } }, grid: { color: gridColor }, border: { color: gridColor }, }); const xAxis = () => ({ ...axisDefaults(), ticks: { color: textColor, font: { size: 11 }, maxTicksLimit: isHour ? 7 : 15, maxRotation: 0, minRotation: 0, }, }); const baseOpts = (extraPlugins = {}) => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, plugins: { legend: { display: false }, ...extraPlugins, }, scales: { x: xAxis(), y: { ...axisDefaults(), beginAtZero: true }, }, }); const barDs = (data, color) => ({ data, backgroundColor: color, borderRadius: 4, borderSkipped: false, }); const lineDs = (data, borderColor, bgColor, opts = {}) => ({ data, borderColor, backgroundColor: bgColor, fill: true, tension: 0.3, pointRadius: 0, borderWidth: 2, ...opts, }); const type = isHour ? 'line' : 'bar'; const get = id => this.querySelector(`#${id}`); // Requests const c1 = get('chart-requests'); if (c1) this._chartInstances.requests = new Chart(c1, { type, data: { labels: days, datasets: [isHour ? lineDs(req, '#3b82f6', 'rgba(59,130,246,0.12)') : barDs(req, '#3b82f6')], }, options: baseOpts(), }); // Tokens const c2 = get('chart-tokens'); if (c2) this._chartInstances.tokens = new Chart(c2, { type, data: { labels: days, datasets: isHour ? [ lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: t('dashboard.stats.chart.input') }), lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: t('dashboard.stats.chart.output') }), lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: t('dashboard.stats.chart.cached') }), ] : (() => { const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0))); return [ { label: t('dashboard.stats.chart.cached'), data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false }, { label: t('dashboard.stats.chart.non_cached'), data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false }, { label: t('dashboard.stats.chart.output'), data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false }, ]; })(), }, options: baseOpts({ legend: { display: true, labels: { color: textColor, boxWidth: 10, font: { size: 11 } }, }, tooltip: { callbacks: { footer(items) { const idx = items[0]?.dataIndex; const total = inp[idx] ?? 0; if (!total) return ''; const pct = Math.round((cache[idx] ?? 0) / total * 100); return t('dashboard.stats.chart.cache_hit', { pct }); }, }, }, }), }); // Latency const c3 = get('chart-latency'); if (c3) this._chartInstances.latency = new Chart(c3, { type, data: { labels: days, datasets: [isHour ? lineDs(lat, '#8b5cf6', 'rgba(139,92,246,0.12)', { spanGaps: false }) : barDs(lat.map(v => v ?? 0), '#8b5cf6')], }, options: baseOpts(), }); // Breakdowns — "how the spend splits": horizontal bars on billed tokens, // requests and the input/output/cache split in the tooltip. for (const spec of this._breakdownSpecs) { const c = get(spec.id); if (!c) continue; this._chartInstances[spec.id] = new Chart(c, { type: 'bar', data: { labels: spec.rows.map(r => spec.labelFn(r.key)), datasets: [{ data: spec.rows.map(r => r.total_tokens), backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]), borderRadius: 4, borderSkipped: false, }], }, options: { ...baseOpts({ tooltip: { callbacks: { title: items => { const r = spec.rows[items[0]?.dataIndex]; return r ? (spec.titleFn ?? spec.labelFn)(r.key) : ''; }, label: item => { const r = spec.rows[item.dataIndex]; if (!r) return ''; return [ `${fmtTok(r.total_tokens)} tokens · ${r.requests} ${t('dashboard.stats.tip.requests')}`, `${t('dashboard.stats.chart.input')}: ${fmtTok(r.input_tokens)} · ${t('dashboard.stats.chart.output')}: ${fmtTok(r.output_tokens)} · ${t('dashboard.stats.chart.cached')}: ${fmtTok(r.cache_read_tokens)}`, ]; }, }, }, }), indexAxis: 'y', scales: { x: { ...axisDefaults(), beginAtZero: true, ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } }, y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } }, }, }, }); } } // ── Render ──────────────────────────────────────────────────────────────── _renderScopeChips() { if (this._members.length < 2) return nothing; return html`
${this._members.map(m => html` `)}
`; } _renderStats() { if (this._stats === null) { return html`
${t('dashboard.stats.loading')}
`; } const empty = this._stats.daily.length === 0 && !(this._stats.by_user ?? []).length && !(this._stats.by_model ?? []).length; if (empty) { return html`
${t('dashboard.stats.empty')}
`; } const breakdown = this._breakdownSpecs; return html`
${t('dashboard.stats.requests', { per: this._periodLabel })}
${t('dashboard.stats.tokens', { per: this._periodLabel })}
${t('dashboard.stats.latency')}
${breakdown.length ? html`
${t('dashboard.stats.sub.breakdown')}
${breakdown.map(c => html`
${c.title}
`)}
` : nothing} `; } render() { const st = this._statusInfo; const noModels = this._models !== null && this._models.length === 0; const approvals = this._inboxData?.approvals ?? []; const clarifs = this._inboxData?.clarifications ?? []; const elicits = this._inboxData?.elicitations ?? []; const inboxTotal = approvals.length + clarifs.length + elicits.length; return html`
${t('chat.title')}

${t('chat.title')}

${t('dashboard.hero.subtitle')}

${st.dot ? html`` : nothing} ${st.icon ? html`` : nothing} ${st.text}
${noModels ? html`
${t('dashboard.banner.no_models.title')} ${t('dashboard.banner.no_models.desc')}
` : nothing}
${t('dashboard.section.stats')}
${[['hour', t('dashboard.stats.range.hour')], ['day', t('dashboard.stats.range.day')], ['week', t('dashboard.stats.range.week')], ['month', t('dashboard.stats.range.month')]].map(([r, label]) => html` `)}
${this._renderScopeChips()} ${this._renderStats()}
${t('dashboard.section.pending')} ${inboxTotal > 0 ? html`${inboxTotal}` : nothing}
${this._renderInboxSection()} ${!this._honchoActive ? html`
${t('dashboard.tip.honcho.title')} ${t('dashboard.tip.honcho.desc')}
` : nothing}
${t('dashboard.section.guide')}
${this._guide.map(s => html`
${s.title}

${s.desc}

`)}
`; } }