Last active
August 18, 2026 20:09
-
-
Save smb/c3ac6770a8d997c7ddfce8d8e5fe1b35 to your computer and use it in GitHub Desktop.
claude code subagent log
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| // ..... | |
| "hooks": { | |
| "SubagentStop": [ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/subagent-completion-log.sh\"", | |
| "timeout": 20 | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 · <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 · ' + 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"> · ' + r.sub + '</span></span>' + | |
| '</div>'; | |
| }).join("") + | |
| '</div>'; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, function (c) { | |
| return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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