Skip to content

Instantly share code, notes, and snippets.

@smb
Last active August 18, 2026 20:09
Show Gist options
  • Select an option

  • Save smb/c3ac6770a8d997c7ddfce8d8e5fe1b35 to your computer and use it in GitHub Desktop.

Select an option

Save smb/c3ac6770a8d997c7ddfce8d8e5fe1b35 to your computer and use it in GitHub Desktop.
claude code subagent log
{
// .....
"hooks": {
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/subagent-completion-log.sh\"",
"timeout": 20
}
]
}
]
}
}
#!/usr/bin/env bash
set -uo pipefail
# SubagentStop payload
payload="$(cat)"
log="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/subagent-usage.jsonl"
# Logging darf niemals den eigentlichen Claude-Lauf beschädigen.
mkdir -p "$(dirname "$log")" 2>/dev/null || exit 0
transcript_path="$(printf '%s' "$payload" | jq -r '.agent_transcript_path // empty' 2>/dev/null)"
[[ -n "$transcript_path" && -f "$transcript_path" ]] || exit 0
# Race Condition: SubagentStop feuert, bevor die letzte assistant-Message
# (inkl. usage) im Transkript geflusht ist - ohne Wartezeit lesen wir eine
# unvollständige Datei und loggen leere/falsche Zahlen. Kurz pollen, bis
# mindestens eine assistant-Message mit usage auftaucht. Budget bewusst
# deutlich unter dem Hook-Timeout (settings.json) halten, damit für die
# eigentliche jq-Auswertung + flock-Schreiben danach noch Luft bleibt -
# reisst der Timeout mitten im Schreiben ab, geht der Eintrag ganz verloren.
for _ in $(seq 1 25); do
if jq -e 'select(.type == "assistant" and .message.usage != null)' "$transcript_path" >/dev/null 2>&1; then
break
fi
sleep 0.3
done
# SubagentStop selbst enthält keine Token-/Dauer-Felder. Die einzige
# verlässliche Quelle ist das Subagent-Transkript: jede assistant-Message
# trägt ein usage-Objekt im Anthropic-API-Format. Manche Turns werden dort
# doppelt geloggt (Streaming-Checkpoint + final, identische message.id) -
# daher Dedup vor der Summierung, sonst zählen wir Tokens doppelt.
entry="$(
jq -cs --argjson meta "$payload" '
([ .[] | select(.type == "assistant" and .message.usage != null) ]
| group_by(.message.id)
| map(.[0])
) as $turns
# Dauer über alle Transkript-Zeilen (nicht nur die deduplizierten Turns),
# sonst kollabiert die Spanne bei Streaming-Duplikaten auf denselben Timestamp.
| ([ .[] | select(.timestamp != null) | (.timestamp | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) ] | sort) as $ts
| {
ts: (now | todateiso8601),
kind: "completion",
fable: ($turns | any(.message.model // "" | test("(?i)fable"))),
agent_id: ($meta.agent_id // null),
session_id: ($meta.session_id // null),
subagent: {
type: ($meta.agent_type // null),
last_assistant_message: ($meta.last_assistant_message // "" | .[0:200])
},
telemetry: {
models_used: ($turns | map(.message.model) | unique),
turn_count: ($turns | length),
duration_ms: (
if ($ts | length) >= 2
then (($ts[-1] - $ts[0]) * 1000 | round)
else null
end
),
usage: {
input_tokens: ($turns | map(.message.usage.input_tokens // 0) | add // 0),
output_tokens: ($turns | map(.message.usage.output_tokens // 0) | add // 0),
cache_creation_input_tokens: ($turns | map(.message.usage.cache_creation_input_tokens // 0) | add // 0),
cache_read_input_tokens: ($turns | map(.message.usage.cache_read_input_tokens // 0) | add // 0)
}
}
}
' "$transcript_path" 2>/dev/null
)" || exit 0
[[ -n "$entry" ]] || exit 0
# Parallel gestartete Subagents können gleichzeitig loggen.
# Unter Linux verwenden wir deshalb flock.
if command -v flock >/dev/null 2>&1; then
(
flock -x 9 || exit 0
printf '%s\n' "$entry" >&9
) 9>>"$log" 2>/dev/null || true
else
# Fallback z.B. auf Systemen ohne flock.
printf '%s\n' "$entry" >>"$log" 2>/dev/null || true
fi
exit 0
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subagent Usage Dashboard</title>
<style>
:root {
--bg: #09090b;
--surface: #111114;
--surface-2: #17171b;
--surface-3: #1d1d22;
--border: rgba(255,255,255,0.09);
--border-strong: rgba(255,255,255,0.18);
--text: #e4e4e7;
--text-dim: #a1a1aa;
--text-faint: #8b8b94;
--accent: #38bdf8;
--accent-soft: rgba(56,189,248,0.16);
--accent-line: rgba(56,189,248,0.45);
--fable: #f59e0b;
--fable-soft: rgba(245,158,11,0.16);
--fable-line: rgba(245,158,11,0.55);
--mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--radius: 6px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.45;
}
body { min-height: 100dvh; }
::selection { background: var(--accent-soft); color: var(--text); }
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
button, input { font-family: inherit; font-size: inherit; color: inherit; }
button:focus-visible,
input:focus-visible,
.day-row:focus-visible,
thead th:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 3px;
}
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
#app {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* ---------- Header ---------- */
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
header h1 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
}
header h1 .path {
color: var(--text-faint);
font-weight: 400;
font-family: var(--mono);
font-size: 0.8125rem;
margin-left: 0.6rem;
}
.file-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.file-actions button {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 0.4rem 0.7rem;
border-radius: var(--radius);
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease;
}
.file-actions button:hover { border-color: var(--border-strong); color: var(--text); }
.file-actions button:active { transform: translateY(1px); }
#parseNote {
color: var(--text-faint);
font-size: 0.75rem;
}
#parseNote.warn { color: var(--fable); }
/* ---------- Drop zone / empty state ---------- */
#dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
margin: 2rem;
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
padding: 4rem 2rem;
text-align: center;
transition: border-color 150ms ease, background 150ms ease;
}
#dropzone.dragover {
border-color: var(--accent);
background: var(--accent-soft);
}
#dropzone svg { opacity: 0.55; }
#dropzone .hint {
font-size: 0.9375rem;
color: var(--text-dim);
max-width: 32ch;
}
#dropzone .hint strong { color: var(--text); font-weight: 600; }
#dropzone .pick {
background: var(--accent);
color: #04121a;
border: none;
padding: 0.55rem 1.1rem;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
}
#dropzone .pick:hover { filter: brightness(1.08); }
#dropzone .pick:active { transform: translateY(1px); }
#dropzone .sub {
font-size: 0.75rem;
color: var(--text-faint);
}
#dropError {
color: var(--fable);
font-size: 0.8125rem;
max-width: 42ch;
}
/* ---------- Dashboard shell ---------- */
main#dashboard {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
main#dashboard[hidden] { display: none; }
section { display: flex; flex-direction: column; gap: 0.6rem; }
.section-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-dim);
}
/* ---------- Stat strip ---------- */
#stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.stat {
padding: 0.75rem 1rem;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.stat:last-child { border-right: none; }
.stat .label { font-size: 0.6875rem; color: var(--text-faint); }
.stat .value {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 1.375rem;
font-weight: 600;
color: var(--text);
}
.stat.fable .value { color: var(--fable); }
.stat .sub { font-size: 0.6875rem; color: var(--text-faint); }
/* ---------- Timeline ---------- */
#timelineHead {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6rem;
}
.range-controls {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--text-dim);
}
.range-controls input[type="date"] {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 0.25rem 0.4rem;
font-family: var(--mono);
font-size: 0.75rem;
}
.range-controls button {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius);
padding: 0.25rem 0.6rem;
cursor: pointer;
font-size: 0.75rem;
}
.range-controls button:hover { color: var(--text); border-color: var(--border-strong); }
#timeline {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0;
position: relative;
}
.day-row {
display: grid;
grid-template-columns: 92px 1fr 120px;
align-items: center;
gap: 0.75rem;
padding: 0.18rem 1rem;
cursor: pointer;
user-select: none;
transition: opacity 150ms ease;
}
#dayTooltip {
position: fixed;
z-index: 20;
background: var(--surface-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.5rem 0.65rem;
font-size: 0.75rem;
min-width: 168px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
pointer-events: none;
}
#dayTooltip .tt-date { font-family: var(--mono); color: var(--text); margin-bottom: 0.3rem; }
#dayTooltip .tt-row { display: flex; justify-content: space-between; gap: 1rem; color: var(--text-dim); padding: 0.05rem 0; }
#dayTooltip .tt-row.is-fable { color: var(--fable); }
#dayTooltip .tt-sep { border-top: 1px solid var(--border); margin: 0.3rem 0; }
.model-legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.9rem;
font-size: 0.75rem;
color: var(--text-dim);
}
.model-legend .item { display: inline-flex; align-items: center; gap: 0.35rem; }
.model-legend .dot { width: 8px; height: 8px; border-radius: 999px; flex: none; }
@media (prefers-reduced-motion: reduce) {
#dayTooltip { transition: none; }
}
.day-row .date {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-dim);
white-space: nowrap;
}
.day-row .bar-track {
position: relative;
height: 16px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.day-row .bar-seg {
position: absolute;
top: 0;
bottom: 0;
}
.day-row .meta {
font-family: var(--mono);
font-size: 0.6875rem;
color: var(--text-faint);
text-align: right;
white-space: nowrap;
}
.day-row:hover { background: var(--surface-2); }
.day-row.dim { opacity: 0.32; }
.day-row.selected { background: var(--surface-2); }
@media (prefers-reduced-motion: reduce) {
.day-row { transition: none; }
}
/* ---------- Heatmap ---------- */
#heatmap {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
overflow-x: auto;
}
.heat-cell {
fill: var(--surface-2);
stroke: transparent;
stroke-width: 1.5px;
cursor: pointer;
transition: opacity 150ms ease;
}
.heat-cell.level-1 { fill: rgba(56, 189, 248, 0.28); }
.heat-cell.level-2 { fill: rgba(56, 189, 248, 0.48); }
.heat-cell.level-3 { fill: rgba(56, 189, 248, 0.72); }
.heat-cell.level-4 { fill: var(--accent); }
.heat-cell.has-fable { stroke: var(--fable); }
.heat-cell.dim { opacity: 0.32; }
.heat-cell:hover, .heat-cell:focus-visible { stroke: var(--text); outline: none; }
.heat-cell.has-fable:hover, .heat-cell.has-fable:focus-visible { stroke: var(--fable); }
.heat-month, .heat-weekday {
fill: var(--text-faint);
font-size: 9px;
font-family: var(--mono);
}
@media (prefers-reduced-motion: reduce) {
.heat-cell { transition: none; }
}
/* ---------- Breakdown panels ---------- */
#breakdowns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.panel {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.panel .panel-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
padding: 0.6rem 0.9rem 0.4rem;
}
.bd-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.3rem 0.9rem;
border-top: 1px solid var(--border);
font-size: 0.8125rem;
}
.bd-row .name {
flex: 0 0 auto;
min-width: 96px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bd-row .name.is-fable { color: var(--fable); }
.bd-row .bd-bar-track {
flex: 1;
height: 6px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.bd-row .bd-bar { height: 100%; background: var(--accent); }
.bd-row.is-fable .bd-bar { background: var(--fable); }
.bd-row .count {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 0.75rem;
min-width: 6.5rem;
text-align: right;
white-space: nowrap;
}
.bd-row .count-sub { color: var(--text-faint); }
/* ---------- Table ---------- */
#tableHead {
display: flex;
align-items: center;
justify-content: space-between;
}
#tableWrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
max-height: 520px;
}
table {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
thead th {
position: sticky;
top: 0;
background: var(--surface);
text-align: left;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
font-weight: 600;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-strong);
cursor: pointer;
white-space: nowrap;
}
thead th:hover { color: var(--text-dim); }
thead th.sorted { color: var(--accent); }
thead th .arrow { font-size: 0.65rem; margin-left: 0.15rem; }
tbody td {
padding: 0.4rem 0.75rem;
border-top: 1px solid var(--border);
white-space: nowrap;
color: var(--text-dim);
}
tbody tr:hover td { background: var(--surface-2); color: var(--text); }
tbody tr.pending td { color: var(--text-faint); font-style: italic; }
td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
font-size: 0.6875rem;
font-family: var(--mono);
border: 1px solid var(--border-strong);
color: var(--text-dim);
}
.badge.is-fable {
color: var(--fable);
border-color: var(--fable-line);
background: var(--fable-soft);
}
.desc-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
footer {
text-align: center;
color: var(--text-faint);
font-size: 0.6875rem;
padding: 1rem;
}
@media (max-width: 860px) {
#stats { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2n) { border-right: none; }
#breakdowns { grid-template-columns: 1fr; }
.day-row { grid-template-columns: 76px 1fr 90px; }
}
</style>
</head>
<body>
<!--
THESIS: Read subagent usage like a performance trace, not a marketing chart;
refuses the generic AI-dashboard rut of glass cards, purple gradients and
vanity numbers.
OWN-WORLD: zinc-950 ground, a small categorical palette (one stable hue per
model per loaded file) plus one reserved amber signal exclusively for
fable:true that never shares a hue with any model, monospace for every
number/id, hairline dividers instead of card chrome.
STORY: the engineer drags in subagent-usage.jsonl and reads daily volume two
ways, a duration-based waterfall timeline and a GitHub-style calendar
heatmap, both colored per model; fable calls are impossible to miss on
either.
FIRST VIEWPORT: stat strip (incl. absolute standard vs. fable tokens), model
legend, waterfall timeline (length=duration, per-model color segments,
opacity=token density), calendar heatmap, breakdown panels, dense table.
FORM: DevTools Performance Panel world, raised by duration/shading-as-
intensity (Labanotation) and a reserved alert color that flags and holds
(boarding-pass/gate-board); extended on user request with per-model
categorical color and a GitHub-style day heatmap, same world, same rules.
FINISH: unreviewed and undocumented is unfinished; this build ends with the
finish review, the verdict, and DESIGN.md.
-->
<div id="app">
<header>
<h1>Subagent Usage Dashboard<span class="path" id="fileLabel"></span></h1>
<div class="file-actions" id="fileActions" hidden>
<span id="parseNote"></span>
<button type="button" id="loadAnother">Andere Datei laden</button>
</div>
</header>
<div id="dropzone">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path d="M20 26V6M20 6L12 14M20 6L28 14" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 26v6a2 2 0 0 0 2 2h24a2 2 0 0 0 2-2v-6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="hint"><strong>subagent-usage.jsonl</strong> hier ablegen oder auswählen.<br>Alles bleibt lokal im Browser, nichts wird hochgeladen.</div>
<button type="button" class="pick" id="pickFile">Datei auswählen</button>
<input type="file" id="fileInput" accept=".jsonl,.json,.log,.txt" hidden>
<div class="sub">Erwartet: eine Zeile pro JSON-Objekt (kind: dispatch / completion)</div>
<div id="dropError" hidden></div>
</div>
<main id="dashboard" hidden>
<section id="stats"></section>
<section id="timelineSection">
<div id="timelineHead">
<span class="section-label">Verlauf, tagesweise</span>
<div class="range-controls">
<input type="date" id="rangeFrom">
<span>bis</span>
<input type="date" id="rangeTo">
<button type="button" id="rangeReset">Alle Tage</button>
</div>
</div>
<div id="modelLegend" class="model-legend"></div>
<div id="timeline"></div>
<div id="dayTooltip" role="tooltip" hidden></div>
</section>
<section id="heatmapSection">
<span class="section-label">Kalender</span>
<div id="heatmap"></div>
</section>
<section id="breakdownsSection">
<span class="section-label">Aufschlüsselung <span id="breakdownScope"></span></span>
<div id="breakdowns"></div>
</section>
<section id="tableSection">
<div id="tableHead">
<span class="section-label">Alle Aufrufe <span id="tableScope"></span></span>
</div>
<div id="tableWrap">
<table>
<thead id="tableHeadRow"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</section>
</main>
<footer id="pageFooter" hidden>Rein clientseitig geparst &middot; <span id="lineCount"></span> Zeilen</footer>
</div>
<script>
(function () {
"use strict";
var COLUMNS = [
{ key: "ts", label: "Zeit", type: "text" },
{ key: "type", label: "Typ", type: "text" },
{ key: "model", label: "Modell", type: "text" },
{ key: "fable", label: "Fable", type: "bool" },
{ key: "status", label: "Status", type: "text" },
{ key: "duration_ms", label: "Dauer (ms)", type: "num" },
{ key: "total_tokens", label: "Tokens", type: "num" },
{ key: "description", label: "Beschreibung", type: "text" }
];
var state = {
calls: [],
days: [],
maxDayTokens: 0,
maxDayDuration: 0,
maxDayDensity: 0, // tokens per ms, for intensity shading
range: null, // {from: 'YYYY-MM-DD', to: 'YYYY-MM-DD'} or null = all
sortKey: "ts",
sortDir: -1,
skippedLines: 0
};
// ---------- Parsing ----------
function parseJsonl(text) {
var lines = text.split(/\r?\n/);
var rows = [];
var skipped = 0;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
try {
rows.push(JSON.parse(line));
} catch (e) {
skipped++;
}
}
return { rows: rows, skipped: skipped, total: lines.filter(function (l) { return l.trim(); }).length };
}
function looksFable(str) {
return typeof str === "string" && /fable/i.test(str);
}
// Kategoriale Modellfarben: sechs Farbtöne, bewusst außerhalb des
// Amber/Rot-Bereichs (0-90°) gehalten, damit die reservierte Fable-Farbe
// unter keiner Kombination mit einem anderen Modell verwechselbar wird.
// Zuweisung nach erstem Auftreten, stabil pro geladener Datei (siehe
// handleFile: modelColorMap.clear() bei jedem neuen Upload).
var MODEL_PALETTE = ["#38bdf8", "#34d399", "#a78bfa", "#f472b6", "#60a5fa", "#4ade80"];
var modelColorMap = new Map();
function modelColor(modelKey) {
if (modelKey === "fable") return "var(--fable)";
if (!modelColorMap.has(modelKey)) {
modelColorMap.set(modelKey, MODEL_PALETTE[modelColorMap.size % MODEL_PALETTE.length]);
}
return modelColorMap.get(modelKey);
}
function shortModelName(model) {
if (!model || model === "-") return model || "-";
var m = model.match(/claude-([a-z]+)/i);
return m ? m[1].charAt(0).toUpperCase() + m[1].slice(1) : model;
}
function normalize(row) {
var agentId = row.agent_id || (row.subagent && row.subagent.agent_id) || null;
var kind = row.kind || "dispatch";
var fable = typeof row.fable === "boolean"
? row.fable
: (looksFable(row.subagent && row.subagent.requested_model) ||
looksFable(row.subagent && row.subagent.resolved_model) ||
(row.telemetry && row.telemetry.models_used && row.telemetry.models_used.some(looksFable)));
return {
raw: row,
kind: kind,
agent_id: agentId,
ts: row.ts || null,
fable: !!fable
};
}
function sumUsage(usage) {
if (!usage) return null;
var input = usage.input_tokens || 0;
var output = usage.output_tokens || 0;
var cacheCreate = usage.cache_creation_input_tokens || 0;
var cacheRead = usage.cache_read_input_tokens || 0;
return input + output + cacheCreate + cacheRead;
}
function buildCalls(rows) {
var byAgent = new Map();
var unkeyed = [];
rows.forEach(function (r) {
var n = normalize(r);
if (!n.agent_id) { unkeyed.push(n); return; }
var entry = byAgent.get(n.agent_id) || {};
if (n.kind === "completion") entry.completion = n;
else entry.dispatch = n;
byAgent.set(n.agent_id, entry);
});
var calls = [];
byAgent.forEach(function (entry, agentId) {
var d = entry.dispatch, c = entry.completion;
var subagentInfo = (d && d.raw.subagent) || (c && c.raw.subagent) || {};
var telemetry = (c && c.raw.telemetry) || {};
var totalTokens = sumUsage(telemetry.usage);
var modelsUsed = telemetry.models_used || [];
calls.push({
agent_id: agentId,
ts: (d && d.ts) || (c && c.ts) || null,
type: subagentInfo.type || "-",
description: subagentInfo.description || subagentInfo.last_assistant_message || "",
requested_model: subagentInfo.requested_model || null,
resolved_model: subagentInfo.resolved_model || modelsUsed[0] || null,
model: subagentInfo.resolved_model || modelsUsed[0] || subagentInfo.requested_model || "-",
status: (d && subagentInfo.status) || (c ? "completed" : "unknown"),
fable: (d && d.fable) || (c && c.fable) || false,
duration_ms: typeof telemetry.duration_ms === "number" ? telemetry.duration_ms : null,
total_tokens: totalTokens,
turn_count: typeof telemetry.turn_count === "number" ? telemetry.turn_count : null,
has_dispatch: !!d,
has_completion: !!c,
pending: !!d && !c
});
});
unkeyed.forEach(function (n, i) {
var subagentInfo = n.raw.subagent || {};
var telemetry = n.raw.telemetry || {};
calls.push({
agent_id: "unkeyed-" + i,
ts: n.ts,
type: subagentInfo.type || "-",
description: subagentInfo.description || subagentInfo.last_assistant_message || "",
requested_model: subagentInfo.requested_model || null,
resolved_model: subagentInfo.resolved_model || null,
model: subagentInfo.resolved_model || subagentInfo.requested_model || "-",
status: n.kind === "completion" ? "completed" : (subagentInfo.status || "unknown"),
fable: n.fable,
duration_ms: typeof telemetry.duration_ms === "number" ? telemetry.duration_ms : null,
total_tokens: sumUsage(telemetry.usage),
turn_count: typeof telemetry.turn_count === "number" ? telemetry.turn_count : null,
has_dispatch: n.kind !== "completion",
has_completion: n.kind === "completion",
pending: n.kind !== "completion"
});
});
calls.sort(function (a, b) {
if (!a.ts) return 1;
if (!b.ts) return -1;
return a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0;
});
return calls;
}
function dayOf(ts) {
if (!ts) return null;
return ts.slice(0, 10);
}
function buildDays(calls) {
var map = new Map();
calls.forEach(function (call) {
var d = dayOf(call.ts);
if (!d) return;
var bucket = map.get(d);
if (!bucket) {
bucket = { date: d, calls: 0, tokens: 0, fableCalls: 0, fableTokens: 0, durationMs: 0, fableDurationMs: 0, models: new Map() };
map.set(d, bucket);
}
bucket.calls++;
var tokens = call.total_tokens || 0;
var duration = call.duration_ms || 0;
bucket.tokens += tokens;
bucket.durationMs += duration;
if (call.fable) {
bucket.fableCalls++;
bucket.fableTokens += tokens;
bucket.fableDurationMs += duration;
}
// Fable-Aufrufe werden immer unter dem Schlüssel "fable" gebündelt
// (unabhängig vom genauen Modellstring), damit die reservierte
// Alarmfarbe garantiert greift statt sich auf Regex-Treffer im
// Modellnamen zu verlassen.
var modelKey = call.fable ? "fable" : (call.model || "-");
var mEntry = bucket.models.get(modelKey) || { model: modelKey, calls: 0, tokens: 0, durationMs: 0 };
mEntry.calls++;
mEntry.tokens += tokens;
mEntry.durationMs += duration;
bucket.models.set(modelKey, mEntry);
});
var days = Array.from(map.values());
days.sort(function (a, b) { return a.date < b.date ? -1 : 1; });
return days;
}
// ---------- Rendering ----------
function fmtNum(n) {
if (n === null || n === undefined) return "–";
return n.toLocaleString("de-DE");
}
function fmtDuration(ms) {
if (ms === null || ms === undefined) return "–";
if (ms < 1000) return ms + " ms";
return (ms / 1000).toFixed(1) + " s";
}
function fmtPct(part, whole) {
if (!whole) return "0%";
return Math.round((part / whole) * 100) + "%";
}
function inRange(dateStr) {
if (!state.range) return true;
return dateStr >= state.range.from && dateStr <= state.range.to;
}
function visibleCalls() {
return state.calls.filter(function (c) {
var d = dayOf(c.ts);
return d && inRange(d);
});
}
function renderStats() {
var visible = visibleCalls();
var totalCalls = visible.length;
var totalTokens = visible.reduce(function (s, c) { return s + (c.total_tokens || 0); }, 0);
var totalDuration = visible.reduce(function (s, c) { return s + (c.duration_ms || 0); }, 0);
var fableCalls = visible.filter(function (c) { return c.fable; });
var fableTokens = fableCalls.reduce(function (s, c) { return s + (c.total_tokens || 0); }, 0);
var standardTokens = totalTokens - fableTokens;
var stats = [
{ label: "Aufrufe", value: fmtNum(totalCalls) },
{ label: "Tokens gesamt", value: fmtNum(totalTokens) },
{ label: "Dauer gesamt", value: fmtDuration(totalDuration) },
{ label: "Standard-Tokens", value: fmtNum(standardTokens), sub: fmtPct(standardTokens, totalTokens) + " aller Tokens" },
{ label: "Fable-Aufrufe", value: fmtNum(fableCalls.length), sub: fmtPct(fableCalls.length, totalCalls) + " aller Aufrufe", fable: true },
{ label: "Fable-Tokens", value: fmtNum(fableTokens), sub: fmtPct(fableTokens, totalTokens) + " aller Tokens", fable: true }
];
var el = document.getElementById("stats");
el.innerHTML = stats.map(function (s) {
return '<div class="stat' + (s.fable ? " fable" : "") + '">' +
'<span class="label">' + s.label + '</span>' +
'<span class="value mono">' + s.value + '</span>' +
(s.sub ? '<span class="sub">' + s.sub + '</span>' : '') +
'</div>';
}).join("");
}
// Bar length = share of the day's total call duration (the world's own
// "block length is exact duration" discipline). Fill opacity = token
// density (tokens per ms), a continuous intensity channel independent of
// the fable split. The amber segment marks fable's share of that
// duration and always renders at full opacity so it keeps flagging and
// holding regardless of how quiet the rest of the day reads.
function dayById(date) {
for (var i = 0; i < state.days.length; i++) {
if (state.days[i].date === date) return state.days[i];
}
return null;
}
// Geteilte Hover/Fokus/Drag-Logik für alles mit einem data-date-Attribut
// (Zeitleisten-Zeilen UND Heatmap-Zellen) - identisches Interaktionsmuster,
// eine Implementierung.
var dayTooltipEl = null;
var mouseIsDown = false;
document.addEventListener("mousedown", function () { mouseIsDown = true; });
document.addEventListener("mouseup", function () { mouseIsDown = false; });
function tooltipHtml(day) {
var fablePct = day.tokens ? Math.round((day.fableTokens / day.tokens) * 100) : 0;
var modelLines = Array.from(day.models.values())
.sort(function (a, b) { return b.tokens - a.tokens; })
.map(function (m) {
var label = m.model === "fable" ? "Fable" : shortModelName(m.model);
return '<div class="tt-row"><span>' + escapeHtml(label) + '</span><span class="mono">' + fmtNum(m.tokens) + '</span></div>';
}).join("");
return '<div class="tt-date">' + day.date + '</div>' +
'<div class="tt-row"><span>Aufrufe</span><span class="mono">' + fmtNum(day.calls) + '</span></div>' +
'<div class="tt-row"><span>Dauer</span><span class="mono">' + fmtDuration(day.durationMs) + '</span></div>' +
'<div class="tt-row"><span>Tokens</span><span class="mono">' + fmtNum(day.tokens) + '</span></div>' +
'<div class="tt-row is-fable"><span>Fable-Anteil</span><span class="mono">' + fablePct + '%</span></div>' +
'<div class="tt-sep"></div>' + modelLines;
}
function showDayTooltip(el, date) {
var day = dayById(date);
if (!day || !dayTooltipEl) return;
dayTooltipEl.innerHTML = tooltipHtml(day);
var rect = el.getBoundingClientRect();
var top = rect.bottom + 6;
dayTooltipEl.style.left = rect.left + "px";
dayTooltipEl.style.top = top + "px";
dayTooltipEl.hidden = false;
// Nicht rechts/unten aus dem Viewport laufen lassen.
var ttRect = dayTooltipEl.getBoundingClientRect();
if (ttRect.right > window.innerWidth) {
dayTooltipEl.style.left = Math.max(4, window.innerWidth - ttRect.width - 8) + "px";
}
if (ttRect.bottom > window.innerHeight) {
dayTooltipEl.style.top = (rect.top - ttRect.height - 6) + "px";
}
}
function hideDayTooltip() { if (dayTooltipEl) dayTooltipEl.hidden = true; }
function attachDayInteractions(nodeList) {
var dragStart = null;
nodeList.forEach(function (el) {
var date = el.getAttribute("data-date");
el.addEventListener("mousedown", function (e) {
dragStart = date;
setRange(dragStart, dragStart);
e.preventDefault();
});
el.addEventListener("mouseenter", function () {
showDayTooltip(el, date);
if (dragStart !== null && mouseIsDown) {
setRange(dragStart < date ? dragStart : date, dragStart < date ? date : dragStart);
}
});
el.addEventListener("mouseleave", hideDayTooltip);
el.addEventListener("focus", function () { showDayTooltip(el, date); });
el.addEventListener("blur", hideDayTooltip);
el.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setRange(date, date);
}
});
});
}
// Balkenlänge = Anteil an der Tages-Gesamtdauer (die Welt hört von
// Labanotation: "Blocklänge ist die exakte Dauer"). Jedes Modell bekommt
// ein eigenes Segment, dessen Breite wiederum sein Dauer-Anteil an diesem
// Tag ist - so bleibt die Länge weiter durationsbasiert, während die
// Füllung jetzt kategorial statt nur zweifarbig ist. Die Füll-Opazität
// trägt zusätzlich Token-Dichte als kontinuierliches Intensitäts-Signal;
// das Fable-Segment bleibt davon ausgenommen und rendert immer voll
// deckend, damit es weiter aufflammt und hält statt zu verblassen.
function renderTimeline() {
var el = document.getElementById("timeline");
if (!state.days.length) { el.innerHTML = ""; return; }
el.innerHTML = state.days.map(function (day) {
var lengthPct = state.maxDayDuration ? (day.durationMs / state.maxDayDuration) * 100 : 0;
var density = day.durationMs ? day.tokens / day.durationMs : 0;
var intensity = state.maxDayDensity ? density / state.maxDayDensity : 0;
var opacity = (0.35 + 0.65 * intensity).toFixed(2);
var selected = state.range && day.date >= state.range.from && day.date <= state.range.to;
var dim = state.range && !selected;
var modelsSorted = Array.from(day.models.values()).sort(function (a, b) { return b.durationMs - a.durationMs; });
var offset = 0;
var segmentsHtml = modelsSorted.map(function (m) {
var share = day.durationMs ? m.durationMs / day.durationMs : 0;
var w = lengthPct * share;
var isFable = m.model === "fable";
var seg = '<span class="bar-seg' + (isFable ? " is-fable" : "") + '" style="left:' + offset.toFixed(2) +
'%;width:' + w.toFixed(2) + '%;background:' + modelColor(m.model) +
(isFable ? '' : ';opacity:' + opacity) + '"></span>';
offset += w;
return seg;
}).join("");
return '<div class="day-row' + (dim ? " dim" : "") + (selected && state.range ? " selected" : "") +
'" data-date="' + day.date + '" tabindex="0" role="button" aria-label="' +
day.date + ', ' + fmtNum(day.calls) + ' Aufrufe auswaehlen">' +
'<span class="date">' + day.date + '</span>' +
'<span class="bar-track">' + segmentsHtml + '</span>' +
'<span class="meta">' + fmtNum(day.calls) + ' Aufrufe &middot; ' + fmtNum(day.tokens) + ' Tok</span>' +
'</div>';
}).join("");
attachDayInteractions(el.querySelectorAll(".day-row"));
}
function renderLegend() {
var visible = visibleCalls();
var seen = new Map(); // preserves first-seen order -> stable with modelColor()
visible.forEach(function (c) {
var key = c.fable ? "fable" : (c.model || "-");
if (!seen.has(key)) seen.set(key, key === "fable" ? "Fable" : shortModelName(c.model));
});
var el = document.getElementById("modelLegend");
el.innerHTML = Array.from(seen.entries()).map(function (entry) {
return '<span class="item"><span class="dot" style="background:' + modelColor(entry[0]) + '"></span>' + escapeHtml(entry[1]) + '</span>';
}).join("");
}
var MONTH_NAMES_DE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
var WEEKDAY_LABELS_SPARSE = ["Mo", "", "Mi", "", "Fr", "", ""]; // wie GitHub: nur jede zweite Zeile beschriften
function isoWeekday(date) { return (date.getUTCDay() + 6) % 7; } // Mo=0 .. So=6
function parseDayStr(s) {
var p = s.split("-");
return new Date(Date.UTC(+p[0], +p[1] - 1, +p[2]));
}
function toDayStr(date) { return date.toISOString().slice(0, 10); }
// Kalender-Heatmap im Stil von GitHubs Contribution-Graph: eine Zelle pro
// Kalendertag (auch ohne Aktivität), Wochen als Spalten, Mo-So als Zeilen.
// Farbintensität = Tokens dieses Tages relativ zum Maximum im Datensatz
// (eigene, cyan-basierte Skala statt Githubs Grün - bleibt konsistent mit
// der restlichen Welt). Fable-Tage bekommen zusätzlich einen reservierten
// Amber-Ring, unabhängig von der Intensitätsstufe - dieselbe "flags and
// holds"-Disziplin wie im Rest der Seite.
function renderHeatmap() {
var el = document.getElementById("heatmap");
if (!state.days.length) { el.innerHTML = ""; return; }
var dayMap = new Map();
state.days.forEach(function (d) { dayMap.set(d.date, d); });
var sortedDates = state.days.map(function (d) { return d.date; }).sort();
var minDate = parseDayStr(sortedDates[0]);
var maxDate = parseDayStr(sortedDates[sortedDates.length - 1]);
var start = new Date(minDate);
start.setUTCDate(start.getUTCDate() - isoWeekday(start));
var end = new Date(maxDate);
end.setUTCDate(end.getUTCDate() + (6 - isoWeekday(end)));
var weeks = [];
var week = [];
var cur = new Date(start);
while (cur <= end) {
week.push(new Date(cur));
if (week.length === 7) { weeks.push(week); week = []; }
cur.setUTCDate(cur.getUTCDate() + 1);
}
if (week.length) weeks.push(week);
var maxTokens = state.maxDayTokens || 1;
var cell = 11, gap = 3, step = cell + gap;
var leftPad = 22, topPad = 16;
var width = leftPad + weeks.length * step;
var height = topPad + 7 * step;
var cellsHtml = "";
var monthLabels = "";
var lastMonth = null;
weeks.forEach(function (weekDates, wi) {
var firstOfMonth = null;
weekDates.forEach(function (date, di) {
var dateStr = toDayStr(date);
var day = dayMap.get(dateStr);
var tokens = day ? day.tokens : 0;
var level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
var hasFable = !!(day && day.fableCalls > 0);
var selected = state.range && dateStr >= state.range.from && dateStr <= state.range.to;
var dim = state.range && !selected;
var x = leftPad + wi * step;
var y = topPad + di * step;
if (date.getUTCDate() === 1) firstOfMonth = { x: x, month: date.getUTCMonth() };
cellsHtml += '<rect data-date="' + dateStr + '" tabindex="0" role="button" aria-label="' + dateStr +
(day ? ', ' + fmtNum(day.calls) + ' Aufrufe' : ', keine Aufrufe') + '" x="' + x + '" y="' + y +
'" width="' + cell + '" height="' + cell + '" rx="2" class="heat-cell level-' + level +
(hasFable ? " has-fable" : "") + (dim ? " dim" : "") + '"></rect>';
});
if (firstOfMonth && firstOfMonth.month !== lastMonth) {
monthLabels += '<text x="' + firstOfMonth.x + '" y="' + (topPad - 5) + '" class="heat-month">' + MONTH_NAMES_DE[firstOfMonth.month] + '</text>';
lastMonth = firstOfMonth.month;
}
});
var weekdayLabels = WEEKDAY_LABELS_SPARSE.map(function (label, i) {
if (!label) return "";
return '<text x="0" y="' + (topPad + i * step + cell - 1) + '" class="heat-weekday">' + label + '</text>';
}).join("");
el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" width="' + width + '" height="' + height +
'" role="img" aria-label="Kalenderansicht der taeglichen Nutzung">' + monthLabels + weekdayLabels + cellsHtml + '</svg>';
attachDayInteractions(el.querySelectorAll(".heat-cell"));
}
function setRange(from, to) {
state.range = { from: from, to: to };
document.getElementById("rangeFrom").value = from;
document.getElementById("rangeTo").value = to;
renderAll(false);
}
function clearRange() {
state.range = null;
document.getElementById("rangeFrom").value = "";
document.getElementById("rangeTo").value = "";
renderAll(false);
}
function renderBreakdowns() {
var visible = visibleCalls();
var totalTokens = visible.reduce(function (s, c) { return s + (c.total_tokens || 0); }, 0);
var fableTokens = visible.filter(function (c) { return c.fable; }).reduce(function (s, c) { return s + (c.total_tokens || 0); }, 0);
var standardTokens = totalTokens - fableTokens;
var byType = groupBy(visible, function (c) { return c.type || "-"; })
.sort(function (a, b) { return b.count - a.count; })
.map(function (r) {
return { name: r.name, bar: r.count, value: fmtNum(r.count) + " Aufrufe", sub: fmtNum(r.tokens) + " Tok", color: "var(--accent)" };
});
var byModel = groupBy(visible, function (c) { return c.fable ? "fable" : (c.model || "-"); })
.sort(function (a, b) { return b.tokens - a.tokens; })
.map(function (r) {
var isFable = r.name === "fable";
return {
name: isFable ? "Fable" : shortModelName(r.name),
bar: r.tokens,
value: fmtNum(r.tokens) + " Tok",
sub: fmtNum(r.count) + " Aufrufe",
color: modelColor(r.name),
fable: isFable
};
});
var byFableTokens = [
{ name: "Standard", bar: standardTokens, value: fmtNum(standardTokens) + " Tok", sub: fmtPct(standardTokens, totalTokens), color: "var(--accent)" },
{ name: "Fable", bar: fableTokens, value: fmtNum(fableTokens) + " Tok", sub: fmtPct(fableTokens, totalTokens), color: "var(--fable)", fable: true }
];
document.getElementById("breakdowns").innerHTML = [
renderPanel("Nach Subagent-Typ", byType),
renderPanel("Nach Modell (Tokens)", byModel),
renderPanel("Tokens: Standard vs. Fable", byFableTokens)
].join("");
var scope = state.range ? (state.range.from === state.range.to ? state.range.from : state.range.from + " bis " + state.range.to) : "alle Tage";
document.getElementById("breakdownScope").textContent = "(" + scope + ")";
document.getElementById("tableScope").textContent = "(" + scope + ")";
}
function groupBy(list, keyFn) {
var map = new Map();
list.forEach(function (item) {
var k = keyFn(item);
var entry = map.get(k) || { name: k, count: 0, tokens: 0 };
entry.count++;
entry.tokens += item.total_tokens || 0;
map.set(k, entry);
});
return Array.from(map.values());
}
function renderPanel(title, rows) {
var max = rows.reduce(function (m, r) { return Math.max(m, r.bar); }, 0) || 1;
return '<div class="panel">' +
'<div class="panel-title">' + title + '</div>' +
rows.map(function (r) {
var width = (r.bar / max) * 100;
return '<div class="bd-row' + (r.fable ? " is-fable" : "") + '">' +
'<span class="name' + (r.fable ? " is-fable" : "") + '">' + escapeHtml(r.name) + '</span>' +
'<span class="bd-bar-track"><span class="bd-bar" style="width:' + width.toFixed(1) + '%;background:' + r.color + '"></span></span>' +
'<span class="count">' + r.value + '<span class="count-sub"> &middot; ' + r.sub + '</span></span>' +
'</div>';
}).join("") +
'</div>';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function renderTableHead() {
var head = document.getElementById("tableHeadRow");
head.innerHTML = "<tr>" + COLUMNS.map(function (col) {
var sorted = state.sortKey === col.key;
var arrow = sorted ? (state.sortDir === 1 ? "↑" : "↓") : "";
return '<th data-key="' + col.key + '" class="' + (sorted ? "sorted" : "") + '" tabindex="0" role="button" aria-label="Sortieren nach ' + col.label + '">' + col.label +
(arrow ? '<span class="arrow">' + arrow + '</span>' : '') + '</th>';
}).join("") + "</tr>";
function applySort(th) {
var key = th.getAttribute("data-key");
if (state.sortKey === key) state.sortDir *= -1;
else { state.sortKey = key; state.sortDir = 1; }
renderTableBody();
renderTableHead();
}
head.querySelectorAll("th").forEach(function (th) {
th.addEventListener("click", function () { applySort(th); });
th.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); applySort(th); }
});
});
}
function renderTableBody() {
var visible = visibleCalls().slice();
var key = state.sortKey, dir = state.sortDir;
visible.sort(function (a, b) {
var av = a[key], bv = b[key];
if (av === null || av === undefined) av = key === "ts" ? "" : -Infinity;
if (bv === null || bv === undefined) bv = key === "ts" ? "" : -Infinity;
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
var body = document.getElementById("tableBody");
if (!visible.length) {
body.innerHTML = '<tr><td colspan="' + COLUMNS.length + '" style="text-align:center;color:var(--text-faint);padding:1.5rem;">Keine Aufrufe im gewählten Zeitraum.</td></tr>';
return;
}
body.innerHTML = visible.map(function (c) {
return '<tr class="' + (c.fable ? "is-fable " : "") + (c.pending ? "pending" : "") + '">' +
'<td class="mono">' + (c.ts ? c.ts.replace("T", " ").replace("Z", "") : "–") + '</td>' +
'<td>' + escapeHtml(c.type) + '</td>' +
'<td class="mono">' + escapeHtml(c.model) + '</td>' +
'<td>' + (c.fable ? '<span class="badge is-fable">fable</span>' : '') + '</td>' +
'<td>' + escapeHtml(c.pending ? "läuft / kein Abschluss" : c.status) + '</td>' +
'<td class="num">' + fmtDuration(c.duration_ms) + '</td>' +
'<td class="num">' + fmtNum(c.total_tokens) + '</td>' +
'<td class="desc-cell" title="' + escapeHtml(c.description) + '">' + escapeHtml(c.description) + '</td>' +
'</tr>';
}).join("");
}
function renderAll(recomputeDays) {
if (recomputeDays) {
state.days = buildDays(state.calls);
state.maxDayTokens = state.days.reduce(function (m, d) { return Math.max(m, d.tokens); }, 0);
state.maxDayDuration = state.days.reduce(function (m, d) { return Math.max(m, d.durationMs); }, 0);
state.maxDayDensity = state.days.reduce(function (m, d) {
var density = d.durationMs ? d.tokens / d.durationMs : 0;
return Math.max(m, density);
}, 0);
}
renderStats();
renderLegend();
renderTimeline();
renderHeatmap();
renderBreakdowns();
renderTableHead();
renderTableBody();
}
// ---------- File handling ----------
function handleFile(file) {
var reader = new FileReader();
reader.onload = function () {
var parsed = parseJsonl(String(reader.result));
if (!parsed.rows.length) {
showDropError("Keine gültigen JSON-Zeilen gefunden (" + parsed.total + " Zeilen gelesen).");
return;
}
modelColorMap.clear();
state.calls = buildCalls(parsed.rows);
state.skippedLines = parsed.skipped;
state.range = null;
document.getElementById("fileLabel").textContent = file.name;
document.getElementById("fileActions").hidden = false;
document.getElementById("lineCount").textContent = parsed.total.toLocaleString("de-DE");
document.getElementById("pageFooter").hidden = false;
var note = document.getElementById("parseNote");
if (parsed.skipped > 0) {
note.textContent = parsed.skipped + " Zeile(n) konnten nicht geparst werden";
note.classList.add("warn");
} else {
note.textContent = parsed.rows.length + " Zeilen, " + state.calls.length + " Aufrufe";
note.classList.remove("warn");
}
document.getElementById("dropzone").style.display = "none";
document.getElementById("dashboard").hidden = false;
renderAll(true);
};
reader.onerror = function () {
showDropError("Datei konnte nicht gelesen werden.");
};
reader.readAsText(file);
}
function showDropError(msg) {
var el = document.getElementById("dropError");
el.textContent = msg;
el.hidden = false;
}
dayTooltipEl = document.getElementById("dayTooltip");
var dropzone = document.getElementById("dropzone");
["dragenter", "dragover"].forEach(function (evt) {
dropzone.addEventListener(evt, function (e) {
e.preventDefault();
dropzone.classList.add("dragover");
});
});
["dragleave", "drop"].forEach(function (evt) {
dropzone.addEventListener(evt, function (e) {
e.preventDefault();
dropzone.classList.remove("dragover");
});
});
dropzone.addEventListener("drop", function (e) {
var file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) handleFile(file);
});
document.getElementById("pickFile").addEventListener("click", function () {
document.getElementById("fileInput").click();
});
document.getElementById("fileInput").addEventListener("change", function (e) {
if (e.target.files[0]) handleFile(e.target.files[0]);
});
document.getElementById("loadAnother").addEventListener("click", function () {
document.getElementById("dashboard").hidden = true;
document.getElementById("dropzone").style.display = "";
document.getElementById("fileActions").hidden = true;
document.getElementById("pageFooter").hidden = true;
document.getElementById("dropError").hidden = true;
document.getElementById("fileInput").value = "";
});
document.getElementById("rangeReset").addEventListener("click", clearRange);
document.getElementById("rangeFrom").addEventListener("change", function (e) {
if (!state.range) state.range = { from: e.target.value, to: e.target.value };
else state.range.from = e.target.value;
renderAll(false);
});
document.getElementById("rangeTo").addEventListener("change", function (e) {
if (!state.range) state.range = { from: e.target.value, to: e.target.value };
else state.range.to = e.target.value;
renderAll(false);
});
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment