From ed931a984c8c05883ae248925d26fa0516ec5e06 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 15 May 2026 12:06:09 +0000 Subject: [PATCH 1/2] refactor(ui): unify Per Session and Live Sessions tables into shared renderer Both the Cost Intelligence and Cache Warming pages now render the same unified live-sessions table via buildLiveSessionRows() + renderLiveSessionsTable(). The unified table merges all columns from both: identity (Project, Session), activity (Turns, Idle), cost (Conversation, Worker, Total, Savings), and warming (TTL, S(t), P(returns), Status, Warmups, Hits). Both pages gain features they were missing: Costs page gets warming columns and text filter; Warming page gets project/session links and cost columns. --- packages/gateway/src/ui.ts | 185 ++++++++++++++++++++++++++----------- 1 file changed, 131 insertions(+), 54 deletions(-) diff --git a/packages/gateway/src/ui.ts b/packages/gateway/src/ui.ts index e7996f5..1931118 100644 --- a/packages/gateway/src/ui.ts +++ b/packages/gateway/src/ui.ts @@ -611,6 +611,132 @@ function warmingStatusBadge(snap: WarmingSnapshot): string { return `waiting`; } +// --------------------------------------------------------------------------- +// Unified live-sessions table (shared by Costs + Warming pages) +// --------------------------------------------------------------------------- + +/** A joined row for the unified live-sessions table. */ +type LiveSessionRow = { + sessionId: string; + projectId: string; + projectLabel: string; + turns: number; + idleMs: number; + conversationCost: number; + workerCost: number; + totalCost: number; + savings: number; + ttl: string; + survivalPct: number; + pReturnsPct: number; + warmingSnap: WarmingSnapshot | null; + warmupCount: number; + warmupHits: number; +}; + +/** Union-join cost tracker, active sessions, and warming snapshots into rows. */ +function buildLiveSessionRows(): LiveSessionRow[] { + const allCosts = getAllSessionCosts(); + const activeSessions = getActiveSessions(); + + // Universe of session IDs from both sources + const allIds = new Set([...allCosts.keys(), ...activeSessions.keys()]); + + const rows: LiveSessionRow[] = []; + for (const sid of allIds) { + const costs = allCosts.get(sid) ?? null; + const state = activeSessions.get(sid) ?? null; + const snap = state ? computeWarmingSnapshot(state) : null; + + const projPath = state?.projectPath ?? ""; + const projId = projPath ? ensureProject(projPath) : ""; + const projLabel = projId ? (projectName(projId) ?? "(unnamed)") : "-"; + + rows.push({ + sessionId: sid, + projectId: projId, + projectLabel: projLabel, + turns: costs?.conversation.turns ?? snap?.messageCount ?? 0, + idleMs: snap?.idleMs ?? NaN, + conversationCost: costs?.conversation.cost ?? 0, + workerCost: costs ? totalWorkerCost(costs) : 0, + totalCost: costs ? totalActualCost(costs) : 0, + savings: costs ? totalSavings(costs) : 0, + ttl: snap?.ttl ?? "-", + survivalPct: snap ? snap.survivalAtIdle * 100 : 0, + pReturnsPct: snap ? snap.pReturns * 100 : 0, + warmingSnap: snap, + warmupCount: snap?.warmupCount ?? 0, + warmupHits: snap?.warmupHits ?? 0, + }); + } + + return rows; +} + +/** + * Render the unified live-sessions table (filter input + table). + * Does NOT include headings — callers add their own

/

. + */ +function renderLiveSessionsTable(rows: LiveSessionRow[]): string { + if (rows.length === 0) { + return `

No active sessions.

`; + } + + let html = `
+ + + + + + + + + + + + + + + + + `; + + for (const r of rows) { + const projCell = r.projectId + ? `${esc(r.projectLabel)}` + : esc(r.projectLabel); + + const sessCell = r.projectId + ? `${esc(r.sessionId.slice(0, 16))}` + : `${esc(r.sessionId.slice(0, 16))}`; + + const idleCell = Number.isNaN(r.idleMs) ? "-" : formatDuration(r.idleMs); + const statusCell = r.warmingSnap ? warmingStatusBadge(r.warmingSnap) : "-"; + const savingsColor = r.savings >= 0 ? "#10b981" : "#e06c75"; + + html += ` + + + + + + + + + + + + + + + `; + } + + html += `
ProjectSessionTurnsIdleConversationWorkerTotalSavingsTTLS(t)P(returns)StatusWarmupsHits
${projCell}${sessCell}${r.turns}${idleCell}${formatUSD(r.conversationCost)}${formatUSD(r.workerCost)}${formatUSD(r.totalCost)}${formatUSD(r.savings)}${esc(r.ttl)}${r.warmingSnap ? r.survivalPct.toFixed(1) + "%" : "-"}${r.warmingSnap ? r.pReturnsPct.toFixed(1) + "%" : "-"}${statusCell}${r.warmupCount}${r.warmupHits}
`; + return html; +} + // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- @@ -1208,39 +1334,9 @@ function pageWarming(): string { `; } - // Live sessions table + // Live sessions table (unified: cost + warming columns) body += `

Live Sessions

`; - if (snapshots.length === 0) { - body += `

No active sessions. Cache warming data appears when sessions are processed through the gateway.

`; - } else { - body += `
- - - - - - - - - - - - `; - for (const snap of snapshots) { - body += ` - - - - - - - - - - `; - } - body += `
SessionTurnsIdleTTLS(t)P(returns)StatusWarmupsHits
${esc(snap.sessionId.slice(0, 16))}${snap.messageCount}${formatDuration(snap.idleMs)}${snap.ttl ?? "5m"}${(snap.survivalAtIdle * 100).toFixed(1)}%${(snap.pReturns * 100).toFixed(1)}%${warmingStatusBadge(snap)}${snap.warmupCount}${snap.warmupHits}
`; - } + body += renderLiveSessionsTable(buildLiveSessionRows()); // Global histograms const globalHists = getGlobalHistogramsSnapshot(); @@ -1378,28 +1474,9 @@ function pageCosts(): string { } body += ``; - // Per-session table - const activeSessions = getActiveSessions(); - body += `

Per Session

- `; - for (const [sid, c] of allCosts) { - const actual = totalActualCost(c); - const saved = totalSavings(c); - const sess = activeSessions.get(sid); - const projPath = sess?.projectPath ?? ""; - const projId = projPath ? ensureProject(projPath) : ""; - const projLabel = projId ? (projectName(projId) ?? "(unnamed)") : "-"; - body += ` - - - - - - - - `; - } - body += `
ProjectSessionTurnsConversationWorkerTotalSavings
${projId ? `${esc(projLabel)}` : esc(projLabel)}${projId ? `${esc(sid.slice(0, 16))}` : `${esc(sid.slice(0, 16))}`}${c.conversation.turns}${formatUSD(c.conversation.cost)}${formatUSD(totalWorkerCost(c))}${formatUSD(actual)}${formatUSD(saved)}
`; + // Per-session table (unified: cost + warming columns) + body += `

Per Session

`; + body += renderLiveSessionsTable(buildLiveSessionRows()); } // ===================================================== From 4aeb65f361404838c4dbc38ffcd704bdfcc8cbae Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 15 May 2026 12:20:08 +0000 Subject: [PATCH 2/2] review: address all findings from self-review - Replace ensureProject() (write) with projectId() (read-only) in render path - Parameterize buildLiveSessionRows() to accept pre-fetched data, eliminating double calls to getAllSessionCosts/getActiveSessions/computeWarmingSnapshot - Derive warming page summary stats from same rows as table (fixes count divergence) - Reduce from 14 to 9 columns: merge Conversation+Worker into Total, drop S(t) and TTL, merge Idle into Status badge, merge Warmups+Hits into single column - Add Cache Hit % column (cacheReadTokens / total input tokens) - Show '-' instead of '$0.00' for cost columns when no cost data exists - Show '-' instead of '0' for warming columns when no warming snapshot exists - Fix TTL fallback: show '5m' when snap exists but ttl is undefined (was '-') - Restore informative empty-state message on warming page - Add data-sort to Session column --- packages/gateway/src/ui.ts | 142 +++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 55 deletions(-) diff --git a/packages/gateway/src/ui.ts b/packages/gateway/src/ui.ts index 1931118..6ec8a05 100644 --- a/packages/gateway/src/ui.ts +++ b/packages/gateway/src/ui.ts @@ -13,7 +13,7 @@ import { recallById, config, projectName, - ensureProject, + projectId as lookupProjectId, renderMarkdown, type TaggedResult, } from "@loreai/core"; @@ -621,51 +621,59 @@ type LiveSessionRow = { projectId: string; projectLabel: string; turns: number; - idleMs: number; - conversationCost: number; - workerCost: number; + hasCosts: boolean; totalCost: number; savings: number; - ttl: string; - survivalPct: number; + cacheHitPct: number; pReturnsPct: number; warmingSnap: WarmingSnapshot | null; + idleMs: number; warmupCount: number; warmupHits: number; }; -/** Union-join cost tracker, active sessions, and warming snapshots into rows. */ -function buildLiveSessionRows(): LiveSessionRow[] { - const allCosts = getAllSessionCosts(); - const activeSessions = getActiveSessions(); - +/** + * Union-join cost tracker, active sessions, and warming snapshots into rows. + * Accepts pre-fetched maps so callers avoid redundant data fetching. + */ +function buildLiveSessionRows( + allCosts: ReadonlyMap, + activeSessions: ReadonlyMap, + snapshots: ReadonlyMap, +): LiveSessionRow[] { // Universe of session IDs from both sources const allIds = new Set([...allCosts.keys(), ...activeSessions.keys()]); const rows: LiveSessionRow[] = []; for (const sid of allIds) { const costs = allCosts.get(sid) ?? null; - const state = activeSessions.get(sid) ?? null; - const snap = state ? computeWarmingSnapshot(state) : null; + const snap = snapshots.get(sid) ?? null; + const sess = activeSessions.get(sid); - const projPath = state?.projectPath ?? ""; - const projId = projPath ? ensureProject(projPath) : ""; + const projPath = sess?.projectPath ?? ""; + const projId = projPath ? lookupProjectId(projPath) : undefined; const projLabel = projId ? (projectName(projId) ?? "(unnamed)") : "-"; + // Cache hit ratio: cacheReadTokens / total input tokens + let cacheHitPct = NaN; + if (costs) { + const c = costs.conversation; + const totalInput = c.inputTokens + c.cacheReadTokens + c.cacheWriteTokens; + cacheHitPct = totalInput > 0 ? (c.cacheReadTokens / totalInput) * 100 : 0; + } + rows.push({ sessionId: sid, - projectId: projId, + projectId: projId ?? "", projectLabel: projLabel, turns: costs?.conversation.turns ?? snap?.messageCount ?? 0, - idleMs: snap?.idleMs ?? NaN, - conversationCost: costs?.conversation.cost ?? 0, - workerCost: costs ? totalWorkerCost(costs) : 0, + hasCosts: costs !== null, totalCost: costs ? totalActualCost(costs) : 0, savings: costs ? totalSavings(costs) : 0, - ttl: snap?.ttl ?? "-", - survivalPct: snap ? snap.survivalAtIdle * 100 : 0, + cacheHitPct, pReturnsPct: snap ? snap.pReturns * 100 : 0, warmingSnap: snap, + idleMs: snap?.idleMs ?? NaN, warmupCount: snap?.warmupCount ?? 0, warmupHits: snap?.warmupHits ?? 0, }); @@ -677,29 +685,27 @@ function buildLiveSessionRows(): LiveSessionRow[] { /** * Render the unified live-sessions table (filter input + table). * Does NOT include headings — callers add their own

/

. + * + * 9 columns: Project | Session | Turns | Total | Savings | Cache Hit | + * P(returns) | Status | Hits/Warmups */ -function renderLiveSessionsTable(rows: LiveSessionRow[]): string { +function renderLiveSessionsTable(rows: LiveSessionRow[], emptyMessage?: string): string { if (rows.length === 0) { - return `

No active sessions.

`; + return `

${esc(emptyMessage ?? "No active sessions.")}

`; } let html = `
- + - - - - - + - - + `; for (const r of rows) { @@ -711,25 +717,33 @@ function renderLiveSessionsTable(rows: LiveSessionRow[]): string { ? `${esc(r.sessionId.slice(0, 16))}` : `${esc(r.sessionId.slice(0, 16))}`; - const idleCell = Number.isNaN(r.idleMs) ? "-" : formatDuration(r.idleMs); - const statusCell = r.warmingSnap ? warmingStatusBadge(r.warmingSnap) : "-"; const savingsColor = r.savings >= 0 ? "#10b981" : "#e06c75"; + // Status cell: badge + idle duration (e.g., "warming 3m" or "dead 15m") + let statusCell: string; + if (r.warmingSnap) { + const badge = warmingStatusBadge(r.warmingSnap); + const idle = Number.isNaN(r.idleMs) ? "" : ` ${formatDuration(r.idleMs)}`; + statusCell = `${badge}${idle}`; + } else { + statusCell = "-"; + } + + // Hits/Warmups cell: "3/10" or "-" + const hitsCell = r.warmingSnap + ? `${r.warmupHits}/${r.warmupCount}` + : "-"; + html += ` - - - - - - - + + + - - + `; } @@ -1294,24 +1308,34 @@ function pageWarming(): string { ]); body += `

Cache Warming

`; - const sessions = getActiveSessions(); + const activeSessions = getActiveSessions(); const cbStatus = getCircuitBreakerStatus(); - // Collect snapshots for all live sessions - const snapshots: WarmingSnapshot[] = []; - for (const [, state] of sessions) { - snapshots.push(computeWarmingSnapshot(state)); + // Build warming snapshots once — used for both stat cards and table + const snapshotMap = new Map(); + for (const [sid, state] of activeSessions) { + snapshotMap.set(sid, computeWarmingSnapshot(state)); } - // Aggregate stats - const totalWarmups = snapshots.reduce((s, x) => s + x.warmupCount, 0); - const totalHits = snapshots.reduce((s, x) => s + x.warmupHits, 0); - const warmingNow = snapshots.filter((x) => x.shouldWarmNow).length; - const deadCount = snapshots.filter((x) => x.disabled).length; + // Build unified rows (shared with Costs page) + const rows = buildLiveSessionRows(getAllSessionCosts(), activeSessions, snapshotMap); + + // Aggregate stats from rows (consistent with table content) + let totalWarmups = 0; + let totalHits = 0; + let warmingNow = 0; + let deadCount = 0; + for (const r of rows) { + if (!r.warmingSnap) continue; + totalWarmups += r.warmupCount; + totalHits += r.warmupHits; + if (r.warmingSnap.shouldWarmNow) warmingNow++; + if (r.warmingSnap.disabled) deadCount++; + } // Summary stat cards body += `
-
Live Sessions
${snapshots.length}
+
Live Sessions
${rows.length}
Warming Now
${warmingNow}
Dead
${deadCount}
Total Warmups
${totalWarmups}
@@ -1336,7 +1360,7 @@ function pageWarming(): string { // Live sessions table (unified: cost + warming columns) body += `

Live Sessions

`; - body += renderLiveSessionsTable(buildLiveSessionRows()); + body += renderLiveSessionsTable(rows, "No active sessions. Cache warming data appears when sessions are processed through the gateway."); // Global histograms const globalHists = getGlobalHistogramsSnapshot(); @@ -1475,8 +1499,16 @@ function pageCosts(): string { body += `
ProjectSessionSession TurnsIdleConversationWorker Total SavingsTTLS(t)Cache Hit P(returns) StatusWarmupsHitsHits/Warmups
${projCell} ${sessCell} ${r.turns}${idleCell}${formatUSD(r.conversationCost)}${formatUSD(r.workerCost)}${formatUSD(r.totalCost)}${formatUSD(r.savings)}${esc(r.ttl)}${r.warmingSnap ? r.survivalPct.toFixed(1) + "%" : "-"}${r.hasCosts ? formatUSD(r.totalCost) : "-"}${r.hasCosts ? `${formatUSD(r.savings)}` : "-"}${!Number.isNaN(r.cacheHitPct) ? r.cacheHitPct.toFixed(0) + "%" : "-"} ${r.warmingSnap ? r.pReturnsPct.toFixed(1) + "%" : "-"} ${statusCell}${r.warmupCount}${r.warmupHits}${hitsCell}
`; // Per-session table (unified: cost + warming columns) + const activeSessions = getActiveSessions(); + const snapshotMap = new Map(); + for (const [sid, state] of activeSessions) { + snapshotMap.set(sid, computeWarmingSnapshot(state)); + } body += `

Per Session

`; - body += renderLiveSessionsTable(buildLiveSessionRows()); + body += renderLiveSessionsTable( + buildLiveSessionRows(allCosts, activeSessions, snapshotMap), + "No active sessions yet. Cost tracking begins when the first conversation turn is processed.", + ); } // =====================================================