From 1d3070ed4c04785e696c01f6806045698df924c9 Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 20 Feb 2026 23:19:02 +0100 Subject: [PATCH] Chain visualization, zoom levels, modal with full conversation context, sync button --- session-analytics-web/Program.cs | 156 ++++++++++++++++++ session-analytics-web/wwwroot/app.js | 193 +++++++++++++++++------ session-analytics-web/wwwroot/index.html | 6 +- session-analytics-web/wwwroot/style.css | 121 +++++++++++++- 4 files changed, 421 insertions(+), 55 deletions(-) diff --git a/session-analytics-web/Program.cs b/session-analytics-web/Program.cs index 7a333c6..92f242d 100644 --- a/session-analytics-web/Program.cs +++ b/session-analytics-web/Program.cs @@ -76,6 +76,162 @@ app.MapGet("/api/cost-summary", (Func factory, string? from, s return Results.Ok(row); }); +// GET /api/entry-chain/{id} - full chain: walk back to user message, then forward to end +app.MapGet("/api/entry-chain/{id}", (string id, Func factory) => +{ + using var db = factory(); + + // Step 1: Walk backwards to find the user message (chain root) + var backChain = new List(); + var currentId = id; + var seen = new HashSet(); + string? rootId = null; + + while (currentId != null && seen.Add(currentId)) + { + var row = db.QueryFirstOrDefault( + "SELECT id, session, type, timestamp, data, json_extract(data, '$.parentId') AS parentId FROM entries WHERE id = @id", + new { id = currentId }); + + if (row == null) break; + + backChain.Add(row); + + string dataStr = row.data; + try + { + var parsed = System.Text.Json.JsonDocument.Parse(dataStr); + var role = parsed.RootElement.TryGetProperty("message", out var msg) + && msg.TryGetProperty("role", out var r) ? r.GetString() : null; + if (role == "user") + { + rootId = (string)row.id; + break; + } + } + catch { } + + currentId = (string?)row.parentId; + } + + backChain.Reverse(); + + // Step 2: Walk forwards from the last entry in backChain until we hit the next user message + var chainIds = new HashSet(backChain.Select(r => (string)r.id)); + var forwardEntries = new List(); + + var lastId = (string)backChain.Last().id; + var maxIter = 100; + var currentId2 = lastId; + while (maxIter-- > 0) + { + var child = db.QueryFirstOrDefault( + "SELECT id, session, type, timestamp, data, json_extract(data, '$.message.role') AS role FROM entries WHERE json_extract(data, '$.parentId') = @pid", + new { pid = currentId2 }); + + if (child == null) break; + + string childRole = child.role; + // Stop when we reach the next user message (new turn) + if (childRole == "user") break; + + string childId = child.id; + if (!chainIds.Add(childId)) break; + + forwardEntries.Add(new { id = childId, session = (string)child.session, type = (string)child.type, timestamp = (string)child.timestamp, data = (string)child.data }); + currentId2 = childId; + } + + // Combine and sort chronologically + var fullChain = backChain.Concat(forwardEntries) + .OrderBy(r => (string)r.timestamp) + .Select(r => new { id = (string)r.id, session = (string)r.session, type = (string)r.type, timestamp = (string)r.timestamp, data = (string)r.data }) + .ToList(); + + return Results.Ok(fullChain); +}); + +// GET /api/entry-chains?from=&to= - returns cost entries with resolved chainId +app.MapGet("/api/entry-chains", (Func factory, string from, string to) => +{ + using var db = factory(); + + // Get all cost entries in range + var costEntries = db.Query(@" + SELECT id, session, type, timestamp, + json_extract(data, '$.message.usage.cost.total') AS cost, + json_extract(data, '$.message.usage.input') AS inputTokens, + json_extract(data, '$.message.usage.output') AS outputTokens, + json_extract(data, '$.message.model') AS model, + json_extract(data, '$.parentId') AS parentId, + data + FROM entries + WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL + AND timestamp >= @from AND timestamp < @to + ORDER BY timestamp ASC", new { from, to }).AsList(); + + // For each cost entry, walk up parentId chain to find the nearest user message. + // That user message ID becomes the chain root. + // Entries sharing the same user-message ancestor = same turn/chain. + var chainRoot = new Dictionary(); + + string FindUserRoot(string entryId, SqliteConnection conn) + { + if (chainRoot.TryGetValue(entryId, out var cached)) return cached; + + var visited = new HashSet { entryId }; + var currentId = entryId; + + while (true) + { + var row = conn.QueryFirstOrDefault( + @"SELECT json_extract(data, '$.parentId') AS parentId, + json_extract(data, '$.message.role') AS role + FROM entries WHERE id = @id", + new { id = currentId }); + + if (row == null) break; + + string role = row.role; + // If we landed on a user message, that's our chain root + if (role == "user" && currentId != entryId) + { + chainRoot[entryId] = currentId; + return currentId; + } + + string parentId = row.parentId; + if (parentId == null || visited.Contains(parentId)) break; + visited.Add(parentId); + currentId = parentId; + } + + // No user message found - entry is its own root + chainRoot[entryId] = entryId; + return entryId; + } + + foreach (var e in costEntries) FindUserRoot((string)e.id, db); + + // Assign chain index by first appearance + var chainIndex = new Dictionary(); + int nextChain = 0; + var result = costEntries.Select(e => + { + var root = chainRoot[(string)e.id]; + if (!chainIndex.ContainsKey(root)) chainIndex[root] = nextChain++; + return new + { + e.id, e.session, e.type, e.timestamp, + e.cost, e.inputTokens, e.outputTokens, e.model, + e.data, + chainId = chainIndex[root] + }; + }); + + return Results.Ok(result); +}); + // POST /api/sync - run export.mjs to import new entries app.MapPost("/api/sync", async () => { diff --git a/session-analytics-web/wwwroot/app.js b/session-analytics-web/wwwroot/app.js index 6702e00..af8f143 100644 --- a/session-analytics-web/wwwroot/app.js +++ b/session-analytics-web/wwwroot/app.js @@ -7,15 +7,18 @@ const ZOOM_LEVELS = [ { name: '3 Tage', days: 3 }, { name: '1 Tag', days: 1 }, { name: '12 Stunden', days: 0.5 }, - { name: '6 Stunden', days: 0.25 }, ]; const BAR_COUNT = 30; +const CHAIN_COLORS = [ + '#4ade80','#60a5fa','#f97316','#c084fc','#f472b6', + '#facc15','#34d399','#818cf8','#fb7185','#a78bfa', + '#22d3ee','#e879f9','#84cc16','#f59e0b','#6ee7b7', +]; let zoomIndex = 0; let viewFrom, viewTo; let zoomStack = []; -let entryMode = false; // true = max zoom, individual entries as bars -let entryData = []; // cached entries for entry mode +let entryMode = false; init(); @@ -45,10 +48,9 @@ async function loadView() { if (entryMode) { const [entries, summary] = await Promise.all([ - api(`/api/cost-entries?from=${enc(from)}&to=${enc(to)}`), + api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`), api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), ]); - entryData = entries; renderSummary(summary); renderEntryChart(entries); renderEntryList(entries); @@ -103,7 +105,6 @@ function renderBucketChart(buckets, bucketMinutes) { `; if (isMaxZoom) { - // At max zoom: click switches to entry mode centered on this bucket wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd)); } else { wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd)); @@ -113,14 +114,12 @@ function renderBucketChart(buckets, bucketMinutes) { const lbl = document.createElement('div'); lbl.className = 'chart-label'; - if (i % Math.ceil(BAR_COUNT / 6) === 0) { - lbl.textContent = fmtShort(bucketStart); - } + if (i % Math.ceil(BAR_COUNT / 6) === 0) lbl.textContent = fmtShort(bucketStart); labels.appendChild(lbl); } } -// --- Entry chart (individual entries as bars) --- +// --- Entry chart (grouped by chain from server) --- function renderEntryChart(entries) { const chart = $('chart'); const labels = $('chart-labels'); @@ -129,28 +128,63 @@ function renderEntryChart(entries) { if (!entries.length) return; + // Group by chainId from server + const chainMap = new Map(); + entries.forEach(e => { + const cid = e.chainId ?? 0; + if (!chainMap.has(cid)) chainMap.set(cid, []); + chainMap.get(cid).push(e); + }); + + const chains = [...chainMap.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, v]) => v); + const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001); - entries.forEach((e, i) => { - const cost = Number(e.cost); - const pct = (cost / maxCost) * 100; + window._entryChainColors = new Map(); - const wrapper = document.createElement('div'); - wrapper.className = 'bar-wrapper'; - wrapper.innerHTML = ` -
$${cost.toFixed(4)} - ${e.model || '?'}
-
- `; + chains.forEach((chain, ci) => { + const color = CHAIN_COLORS[ci % CHAIN_COLORS.length]; - wrapper.addEventListener('click', () => openModal(e.data)); - chart.appendChild(wrapper); - - const lbl = document.createElement('div'); - lbl.className = 'chart-label'; - if (entries.length <= 15 || i % Math.ceil(entries.length / 8) === 0) { - lbl.textContent = fmtTime(e.timestamp); + if (ci > 0) { + const spacer = document.createElement('div'); + spacer.className = 'bar-spacer'; + chart.appendChild(spacer); + labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' })); } - labels.appendChild(lbl); + + const chainTotal = chain.reduce((s, e) => s + Number(e.cost), 0); + + chain.forEach((e, ei) => { + const cost = Number(e.cost); + const pct = (cost / maxCost) * 100; + + window._entryChainColors.set(e.id, color); + + const wrapper = document.createElement('div'); + wrapper.className = 'bar-wrapper bar-in-chain'; + wrapper.innerHTML = ` +
$${cost.toFixed(4)} / ${e.model || '?'}
Kette: $${chainTotal.toFixed(2)}
+
+ `; + wrapper.addEventListener('click', () => openModal(e.data, e.id)); + chart.appendChild(wrapper); + + // Chain total label above first bar + if (ei === 0) { + const totalLbl = document.createElement('div'); + totalLbl.className = 'chain-cost-label'; + totalLbl.style.color = color; + totalLbl.textContent = `$${chainTotal.toFixed(2)}`; + wrapper.prepend(totalLbl); + } + + const lbl = document.createElement('div'); + lbl.className = 'chart-label'; + if (ei === 0) lbl.textContent = fmtTime(chain[0].timestamp); + labels.appendChild(lbl); + }); }); } @@ -186,10 +220,8 @@ function renderBlocks(buckets, bucketMinutes) {
`; - block.querySelector('.block-header').addEventListener('click', () => toggleBlock(i, bucketStart, bucketEnd)); - container.appendChild(block); } @@ -198,7 +230,7 @@ function renderBlocks(buckets, bucketMinutes) { } } -// --- Entry list (entry mode - flat, no expandable blocks) --- +// --- Entry list (flat, sorted by cost) --- function renderEntryList(entries) { const container = $('blocks'); container.innerHTML = ''; @@ -208,12 +240,13 @@ function renderEntryList(entries) { return; } - // Sort by cost descending const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost)); sorted.forEach(e => { + const color = window._entryChainColors?.get(e.id) || 'var(--green)'; const row = document.createElement('div'); row.className = 'entry entry-flat'; + row.style.borderLeftColor = color; row.innerHTML = ` ${fmtTime(e.timestamp)} ${e.model || '-'} @@ -221,7 +254,7 @@ function renderEntryList(entries) { $${Number(e.cost).toFixed(4)} ${e.type} `; - row.addEventListener('click', () => openModal(e.data)); + row.addEventListener('click', () => openModal(e.data, e.id)); container.appendChild(row); }); } @@ -229,10 +262,7 @@ function renderEntryList(entries) { // --- Toggle block entries --- async function toggleBlock(idx, from, to) { const el = $(`be-${idx}`); - if (el.classList.contains('open')) { - el.classList.remove('open'); - return; - } + if (el.classList.contains('open')) { el.classList.remove('open'); return; } if (!el.dataset.loaded) { const entries = await api(`/api/cost-entries?from=${enc(from.toISOString())}&to=${enc(to.toISOString())}`); @@ -245,42 +275,34 @@ async function toggleBlock(idx, from, to) { ${e.type} `).join(''); - el.querySelectorAll('.entry').forEach(row => { - row.addEventListener('click', () => openModal(row.dataset.json)); + const eid = JSON.parse(row.dataset.json).id; + row.addEventListener('click', () => openModal(row.dataset.json, eid)); }); - el.dataset.loaded = '1'; } - el.classList.add('open'); } // --- Zoom --- function zoomInto(clickStart, clickEnd) { if (zoomIndex >= ZOOM_LEVELS.length - 1) return; - zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); - const nextLevel = ZOOM_LEVELS[zoomIndex + 1]; const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2); const halfMs = (nextLevel.days * 86400000) / 2; - viewFrom = new Date(clickMid.getTime() - halfMs); viewTo = new Date(clickMid.getTime() + halfMs); zoomIndex++; entryMode = false; - loadView(); } function enterEntryMode(bucketStart, bucketEnd) { zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); - viewFrom = bucketStart; viewTo = bucketEnd; entryMode = true; - loadView(); } @@ -312,11 +334,78 @@ async function syncData() { } // --- Modal --- -function openModal(dataStr) { - try { - $('modal-content').textContent = JSON.stringify(JSON.parse(dataStr), null, 2); - } catch { $('modal-content').textContent = dataStr; } +async function openModal(dataStr, clickedId) { + const chainEl = $('modal-chain'); + const rawEl = $('modal-content'); + + try { rawEl.textContent = JSON.stringify(JSON.parse(dataStr), null, 2); } + catch { rawEl.textContent = dataStr; } + + chainEl.innerHTML = '

Lade Kontext...

'; $('modal').classList.remove('hidden'); + + try { + const parsed = JSON.parse(dataStr); + const entryId = parsed.id; + const chain = await api(`/api/entry-chain/${encodeURIComponent(entryId)}`); + + // Calculate chain total + const chainCosts = chain.map(entry => { + try { + const d = JSON.parse(entry.data); + return d.message?.usage?.cost?.total || 0; + } catch { return 0; } + }); + const chainTotal = chainCosts.reduce((a, b) => a + b, 0); + const chainCount = chainCosts.filter(c => c > 0).length; + + let html = `
+ Kette: $${chainTotal.toFixed(4)} + ${chainCount} Einträge mit Kosten / ${chain.length} gesamt +
`; + + html += chain.map(entry => { + const d = JSON.parse(entry.data); + const msg = d.message; + if (!msg) return ''; + + const role = msg.role || '?'; + const isUser = role === 'user'; + const isHighlighted = d.id === clickedId; + const content = extractContent(msg); + const cost = msg.usage?.cost?.total; + const model = msg.model; + const time = fmtTime(entry.timestamp); + + return ` +
+
+ ${role} + ${time} + ${model ? `${model}` : ''} + ${cost ? `$${cost.toFixed(4)}` : ''} +
+
${escHtml(content)}
+
+ `; + }).join(''); + + chainEl.innerHTML = html; + + // Scroll to highlighted entry + const active = $('chain-active'); + if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } catch (e) { + chainEl.innerHTML = `

Kontext konnte nicht geladen werden

`; + } +} + +function extractContent(msg) { + if (typeof msg.content === 'string') return msg.content.slice(0, 2000); + if (Array.isArray(msg.content)) { + return msg.content.filter(c => c.type === 'text').map(c => c.text).join('\n').slice(0, 2000); + } + return JSON.stringify(msg.content).slice(0, 500); } function closeModal() { $('modal').classList.add('hidden'); } @@ -345,6 +434,10 @@ function shortNum(n) { return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); } +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} + function escAttr(s) { return String(s).replace(/'/g, ''').replace(/"/g, '"'); } diff --git a/session-analytics-web/wwwroot/index.html b/session-analytics-web/wwwroot/index.html index 41125ff..a6c260b 100644 --- a/session-analytics-web/wwwroot/index.html +++ b/session-analytics-web/wwwroot/index.html @@ -30,7 +30,11 @@ diff --git a/session-analytics-web/wwwroot/style.css b/session-analytics-web/wwwroot/style.css index 6ac4842..d5e84a4 100644 --- a/session-analytics-web/wwwroot/style.css +++ b/session-analytics-web/wwwroot/style.css @@ -98,9 +98,14 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } #chart { display: flex; align-items: flex-end; - gap: 2px; + gap: 1px; height: 140px; width: 100%; + margin-top: 16px; +} + +#chart-labels { + gap: 1px; } .bar-wrapper { @@ -114,6 +119,9 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } position: relative; } +.bar-in-chain { gap: 0; } +.bar-spacer { width: 10px; min-width: 10px; flex: 0 0 10px; } + .bar { width: 100%; min-width: 4px; @@ -228,6 +236,7 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } .entry-flat { background: var(--surface); border: 1px solid var(--border); + border-left: 3px solid var(--green); border-radius: 8px; margin-bottom: 4px; padding: 10px 16px; @@ -265,13 +274,117 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } color: var(--muted); font-size: 1.2rem; cursor: pointer; } +/* Chain messages */ +.chain-msg { + padding: 12px 14px; + border-radius: 8px; + margin-bottom: 8px; +} + +.chain-user { + background: #1e2d5a; + border-left: 3px solid var(--accent); +} + +.chain-assistant { + background: #1a2a2a; + border-left: 3px solid var(--green); +} + +.chain-header { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 6px; + font-size: 0.72rem; +} + +.chain-role { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.chain-user .chain-role { color: var(--accent); } +.chain-assistant .chain-role { color: var(--green); } + +.chain-time { color: var(--muted); font-family: monospace; } +.chain-model { color: var(--muted); } +.chain-cost { color: var(--green); font-weight: 600; } + +/* Chain summary in modal */ +.chain-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + margin-bottom: 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; +} + +.chain-summary-cost { + font-size: 1rem; + font-weight: 700; + color: var(--green); +} + +.chain-summary-count { + font-size: 0.75rem; + color: var(--muted); +} + +/* Chain cost label above first bar */ +.chain-cost-label { + font-size: 0.55rem; + font-weight: 700; + white-space: nowrap; + pointer-events: none; + position: absolute; + top: -2px; + transform: translateY(-100%); +} + +.chain-highlighted { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 12px rgba(108, 142, 245, 0.3); +} + +.chain-body { + font-size: 0.82rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; + color: var(--text); +} + +/* Raw JSON toggle */ +.raw-json { + margin-top: 12px; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.raw-json summary { + cursor: pointer; + font-size: 0.75rem; + color: var(--muted); +} + #modal-content { font-family: monospace; - font-size: 0.78rem; + font-size: 0.72rem; white-space: pre-wrap; word-break: break-all; - color: var(--text); - line-height: 1.5; + color: var(--muted); + line-height: 1.4; + max-height: 300px; + overflow-y: auto; + margin-top: 8px; } ::-webkit-scrollbar { width: 6px; }