From 28ce0822e69f398fd21590ce6944816dd7b79d81 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 13:28:01 +0000 Subject: [PATCH] feat: add two-column admin panel with LLM analytics display Co-Authored-By: Claude Opus 4.6 --- client/src/ui/AdminPanel.ts | 184 ++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 6 deletions(-) diff --git a/client/src/ui/AdminPanel.ts b/client/src/ui/AdminPanel.ts index a04c0fd..fd7d44e 100644 --- a/client/src/ui/AdminPanel.ts +++ b/client/src/ui/AdminPanel.ts @@ -1,4 +1,4 @@ -import type { TunableConstants, TunableKey } from '@dflike/shared'; +import type { TunableConstants, TunableKey, LlmStatsData } from '@dflike/shared'; const LABELS: Record = { TICK_RATE: 'Tick Rate (ticks/sec)', @@ -44,6 +44,10 @@ export class AdminPanel { onAuth: ((password: string) => void) | null = null; onUpdate: ((key: TunableKey, value: number) => void) | null = null; onReset: (() => void) | null = null; + onRequestLlmStats: (() => void) | null = null; + onToggleLlmTracking: ((enabled: boolean) => void) | null = null; + private statsColumn: HTMLDivElement | null = null; + private trackingToggle: HTMLInputElement | null = null; constructor() { this.container = document.createElement('div'); @@ -118,9 +122,9 @@ export class AdminPanel { this.editorView = document.createElement('div'); this.editorView.style.cssText = ` display: none; - flex-direction: column; - gap: 4px; - overflow-y: auto; + flex-direction: row; + gap: 8px; + overflow: hidden; flex: 1; `; @@ -152,10 +156,130 @@ export class AdminPanel { } } + requestStatsRefresh(): void { + if (this.authenticated) { + this.onRequestLlmStats?.(); + } + } + + updateLlmStats(data: LlmStatsData): void { + if (!this.statsColumn) return; + if (this.trackingToggle) { + this.trackingToggle.checked = data.trackingEnabled; + } + + // Remove everything after the toggle row + while (this.statsColumn.children.length > 1) { + this.statsColumn.removeChild(this.statsColumn.lastChild!); + } + + const fmt = (n: number) => n < 0.01 ? `$${n.toFixed(6)}` : `$${n.toFixed(4)}`; + + if (data.types.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'color: #555; font-size: 8px; padding: 8px 0; text-align: center;'; + empty.textContent = 'No LLM calls recorded yet'; + this.statsColumn.appendChild(empty); + return; + } + + for (const type of data.types) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; + flex-direction: column; + gap: 1px; + padding: 4px 0; + border-bottom: 1px solid #1c1450; + `; + + const header = document.createElement('div'); + header.style.cssText = 'display: flex; justify-content: space-between; align-items: baseline;'; + + const nameEl = document.createElement('span'); + nameEl.style.cssText = 'color: #e0d0b0; font-size: 9px; font-weight: bold;'; + nameEl.textContent = type.label; + + const countEl = document.createElement('span'); + countEl.style.cssText = 'color: #8878a8; font-size: 8px;'; + countEl.textContent = `${type.count} calls`; + + header.appendChild(nameEl); + header.appendChild(countEl); + row.appendChild(header); + + const costLine = document.createElement('div'); + costLine.style.cssText = 'color: #a0a0c0; font-size: 7px;'; + costLine.textContent = `min ${fmt(type.minCost)} / avg ${fmt(type.avgCost)} / max ${fmt(type.maxCost)}`; + row.appendChild(costLine); + + const totalLine = document.createElement('div'); + totalLine.style.cssText = 'display: flex; justify-content: space-between; font-size: 7px;'; + + const totalCostEl = document.createElement('span'); + totalCostEl.style.cssText = 'color: #a0a0c0;'; + totalCostEl.textContent = `total: ${fmt(type.totalCost)}`; + + const ratesEl = document.createElement('span'); + ratesEl.style.cssText = `color: ${type.failPct > 0 ? '#ff6666' : '#a0a0c0'};`; + ratesEl.textContent = `retry ${type.retryPct}% / fail ${type.failPct}%`; + + totalLine.appendChild(totalCostEl); + totalLine.appendChild(ratesEl); + row.appendChild(totalLine); + + this.statsColumn.appendChild(row); + } + + // Totals row + const totalsRow = document.createElement('div'); + totalsRow.style.cssText = ` + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 0; + border-top: 2px solid #8878a8; + margin-top: 4px; + `; + + const totalsHeader = document.createElement('div'); + totalsHeader.style.cssText = 'display: flex; justify-content: space-between; align-items: baseline;'; + + const totalsLabel = document.createElement('span'); + totalsLabel.style.cssText = 'color: #e0d0b0; font-size: 9px; font-weight: bold;'; + totalsLabel.textContent = 'TOTAL'; + + const totalsCalls = document.createElement('span'); + totalsCalls.style.cssText = 'color: #8878a8; font-size: 8px;'; + totalsCalls.textContent = `${data.totalCount} calls`; + + totalsHeader.appendChild(totalsLabel); + totalsHeader.appendChild(totalsCalls); + totalsRow.appendChild(totalsHeader); + + const totalsCost = document.createElement('div'); + totalsCost.style.cssText = 'color: #a0a0c0; font-size: 7px;'; + totalsCost.textContent = `total cost: ${fmt(data.totalCost)} / retries: ${data.totalRetries} / failures: ${data.totalFailures}`; + totalsRow.appendChild(totalsCost); + + this.statsColumn.appendChild(totalsRow); + } + private buildEditor(constants: TunableConstants): void { this.editorView.innerHTML = ''; this.inputs.clear(); + // Left column: existing constants editor + const leftCol = document.createElement('div'); + leftCol.style.cssText = ` + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + `; + const debounceTimers = new Map>(); for (const key of Object.keys(LABELS) as TunableKey[]) { @@ -219,7 +343,7 @@ export class AdminPanel { row.appendChild(labelRow); row.appendChild(inputEl); - this.editorView.appendChild(row); + leftCol.appendChild(row); } // Reset button @@ -239,6 +363,54 @@ export class AdminPanel { resetBtn.addEventListener('click', () => { this.onReset?.(); }); - this.editorView.appendChild(resetBtn); + leftCol.appendChild(resetBtn); + + // Right column: LLM analytics + const rightCol = document.createElement('div'); + rightCol.style.cssText = ` + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + `; + this.statsColumn = rightCol; + + // Tracking toggle + const toggleRow = document.createElement('div'); + toggleRow.style.cssText = ` + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid #1c1450; + flex-shrink: 0; + `; + + const toggleLabel = document.createElement('span'); + toggleLabel.style.cssText = 'color: #a0a0c0; font-size: 8px;'; + toggleLabel.textContent = 'LLM Tracking'; + + this.trackingToggle = document.createElement('input'); + this.trackingToggle.type = 'checkbox'; + this.trackingToggle.checked = true; + this.trackingToggle.style.cssText = 'accent-color: #8878a8; cursor: pointer;'; + this.trackingToggle.addEventListener('change', () => { + this.onToggleLlmTracking?.(this.trackingToggle!.checked); + }); + + toggleRow.appendChild(toggleLabel); + toggleRow.appendChild(this.trackingToggle); + rightCol.appendChild(toggleRow); + + // Stats placeholder + const statsPlaceholder = document.createElement('div'); + statsPlaceholder.style.cssText = 'color: #555; font-size: 8px; padding: 8px 0; text-align: center;'; + statsPlaceholder.textContent = 'Loading stats...'; + rightCol.appendChild(statsPlaceholder); + + this.editorView.appendChild(leftCol); + this.editorView.appendChild(rightCol); } }