mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Persist bounded, aggregate-only Lifetime dashboard metrics across proxy restarts and expose them through a new `/stats-lifetime` endpoint. The change keeps session/runtime stats separate from durable lifetime stats, gates sensitive dashboard metadata for loopback or explicitly trusted dashboard clients, and updates the dashboard Lifetime view to consume the new endpoint. Closes #2137 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added bounded persistent lifetime metrics state and wired proxy metric events into it. - Added `/stats-lifetime` with sensitive project/persistence details gated behind dashboard metadata access checks. - Extended loopback/dashboard metadata access policy for trusted dashboard client CIDRs without widening admin/debug endpoints. - Reorganized dashboard session/lifetime presentation around runtime counters versus durable aggregates. - Added focused tests for persistent aggregation, persistence, endpoint registration, loopback gating, trusted dashboard CIDRs, and recent request ordering. - Fixed current Ruff/mypy issues in the lifetime metrics normalization code. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv run --extra proxy --with pytest --with pytest-asyncio pytest tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_stats_recent_requests.py -q 53 passed, 1 warning uvx ruff==0.15.17 check headroom/proxy/forwarded_headers.py headroom/proxy/loopback_guard.py headroom/proxy/persistent_metrics.py headroom/proxy/savings_tracker.py headroom/proxy/server.py tests/test_forwarded_headers.py tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_project_savings.py tests/test_proxy_stats_recent_requests.py All checks passed! uv run --extra proxy --with mypy mypy headroom/proxy/persistent_metrics.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows review worktree, Python 3.13.3 via uv. - Exact command / steps: Ran the focused persistent metrics, persistence, loopback gating, and recent request tests; ran CI-matching Ruff on touched files; ran mypy on the new persistent metrics module. - Observed result: `/stats-lifetime` is registered, non-loopback callers receive only non-sensitive aggregate data, loopback/trusted dashboard clients receive the full lifetime payload, admin/debug endpoints remain loopback-only, and persistent metrics normalize malformed stored state without type/lint errors. - Not tested: Full repository pytest suite, full dashboard browser screenshot pass, or live long-running proxy traffic. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No changelog entry is required for this dashboard/internal metrics iteration. The endpoint intentionally exposes only aggregate lifetime data to ordinary network callers and strips project/persistence details unless the caller passes the dashboard metadata access policy. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2750 lines
189 KiB
HTML
2750 lines
189 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Headroom Dashboard</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||
<script src="https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js" defer></script>
|
||
<script>
|
||
(function() {
|
||
const saved = localStorage.getItem('headroom-theme');
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
if (saved === 'light' || (!saved && !prefersDark)) {
|
||
document.documentElement.classList.remove('dark');
|
||
} else {
|
||
document.documentElement.classList.add('dark');
|
||
}
|
||
})();
|
||
</script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
surface: 'var(--color-surface)',
|
||
border: 'var(--color-border)',
|
||
accent: '#22d3ee',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
:root {
|
||
--bg: #f9fafb;
|
||
--color-surface: #ffffff;
|
||
--color-border: #e5e7eb;
|
||
--card-alt-bg: #f3f4f6;
|
||
--diff-before-header: #fff5f5;
|
||
--diff-before-body: #fef2f2;
|
||
--diff-after-header: #f0fdf4;
|
||
--diff-after-body: #f0fdf4;
|
||
--ttl-card-bg: linear-gradient(180deg, rgba(34,211,238,0.10), rgba(224,249,253,0.90));
|
||
}
|
||
html.dark {
|
||
--bg: #0f0f0f;
|
||
--color-surface: #1a1a1a;
|
||
--color-border: #2a2a2a;
|
||
--card-alt-bg: #141414;
|
||
--diff-before-header: #1a0808;
|
||
--diff-before-body: #120606;
|
||
--diff-after-header: #081a08;
|
||
--diff-after-body: #0a120a;
|
||
--ttl-card-bg: linear-gradient(180deg, rgba(34,211,238,0.10), rgba(12,16,22,0.88));
|
||
}
|
||
body { background: var(--bg); }
|
||
.sparkline { stroke: #22d3ee; stroke-width: 1.5; fill: none; }
|
||
.sparkline-area { fill: url(#sparkline-gradient); }
|
||
.trend-line { stroke: #22d3ee; stroke-width: 2; fill: none; }
|
||
.trend-area { fill: url(#trend-gradient); }
|
||
@keyframes pulse-subtle { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
||
.pulse-live { animation: pulse-subtle 2s ease-in-out infinite; }
|
||
#feed-container {
|
||
height: calc(100vh - 57px);
|
||
scroll-behavior: smooth;
|
||
}
|
||
#feed-virtual-list {
|
||
position: relative;
|
||
}
|
||
.transformation-card { background: var(--card-alt-bg); }
|
||
.transformation-card:hover { background: var(--color-surface); }
|
||
.diff-before-header { background: var(--diff-before-header); }
|
||
.diff-before { background: var(--diff-before-body); }
|
||
.diff-after-header { background: var(--diff-after-header); }
|
||
.diff-after { background: var(--diff-after-body); }
|
||
.ttl-bucket-gradient-card { background: var(--ttl-card-bg); }
|
||
|
||
/* Light mode: invert the gray text scale (dark bg grays → light bg grays) */
|
||
html:not(.dark) body { color: #1f2937; }
|
||
html:not(.dark) .text-gray-200 { color: #1f2937; }
|
||
html:not(.dark) .text-gray-300 { color: #374151; }
|
||
html:not(.dark) .text-gray-400 { color: #6b7280; }
|
||
html:not(.dark) .text-gray-500 { color: #9ca3af; }
|
||
html:not(.dark) .text-gray-600 { color: #6b7280; }
|
||
html:not(.dark) .hover\:text-gray-200:hover { color: #111827; }
|
||
html:not(.dark) .hover\:text-white:hover { color: #111827; }
|
||
/* Light mode overrides for dark-specific utility classes */
|
||
html:not(.dark) .bg-black\/20 { background-color: rgba(0,0,0,0.04); }
|
||
html:not(.dark) .bg-black\/30 { background-color: rgba(0,0,0,0.06); }
|
||
html:not(.dark) .border-white\/8 { border-color: rgba(0,0,0,0.10); }
|
||
html:not(.dark) .ring-white\/5 { --tw-ring-color: rgba(0,0,0,0.08); }
|
||
html:not(.dark) .border-red-900\/30 { border-color: rgba(239,68,68,0.25); }
|
||
html:not(.dark) .border-emerald-900\/30 { border-color: rgba(16,185,129,0.25); }
|
||
/* Light mode: TTL card color adjustments */
|
||
html:not(.dark) .text-cyan-50 { color: #164e63; }
|
||
html:not(.dark) .text-violet-50 { color: #4c1d95; }
|
||
html:not(.dark) .text-cyan-200\/80 { color: #0e7490; }
|
||
html:not(.dark) .text-cyan-300\/75 { color: #0891b2; }
|
||
html:not(.dark) .text-violet-300\/75 { color: #7c3aed; }
|
||
/* Light mode: fix hardcoded dark card backgrounds */
|
||
.bg-card-alt { background: var(--card-alt-bg); }
|
||
.hover-bg-surface:hover { background: var(--color-surface); }
|
||
</style>
|
||
</head>
|
||
<body class="text-gray-200 min-h-screen" x-data="dashboard()" x-init="init()">
|
||
<!-- Header -->
|
||
<header class="border-b border-border px-6 py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
|
||
<span class="text-xs text-gray-500 font-mono" x-text="formatVersion(version)"></span>
|
||
</div>
|
||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
|
||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||
:class="viewMode === 'session' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||
@click="setViewMode('session')">
|
||
Session
|
||
</button>
|
||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||
:class="viewMode === 'lifetime' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||
@click="setViewMode('lifetime')">
|
||
Lifetime
|
||
</button>
|
||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||
:class="viewMode === 'history' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||
@click="setViewMode('history')">
|
||
Historical
|
||
</button>
|
||
</div>
|
||
<template x-if="stats.anon_telemetry_shipping">
|
||
<div class="inline-flex items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-2.5 py-1"
|
||
title="Anonymous aggregate telemetry is enabled. Disable with HEADROOM_TELEMETRY=off or --no-telemetry.">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"
|
||
class="w-3 h-3 text-amber-400 shrink-0">
|
||
<path fill-rule="evenodd"
|
||
d="M6.701 2.25c.577-1 2.02-1 2.598 0l5.196 9a1.5 1.5 0 0 1-1.299 2.25H2.804a1.5 1.5 0 0 1-1.3-2.25l5.197-9ZM8 4a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||
clip-rule="evenodd"/>
|
||
</svg>
|
||
<span class="text-xs text-amber-400 font-medium">Anon Telemetry</span>
|
||
</div>
|
||
</template>
|
||
<template x-if="stats.config && stats.config.savings_profile">
|
||
<div class="inline-flex items-center gap-1.5 rounded-full border border-cyan-500/40 bg-cyan-500/10 px-2.5 py-1"
|
||
:title="'Current proxy profile: ' + stats.config.savings_profile">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400"></span>
|
||
<span class="text-xs text-cyan-100">
|
||
<span x-text="stats.config.savings_profile"></span>
|
||
<template x-if="stats.config.target_savings_percent !== null">
|
||
<span x-text="' · target ' + stats.config.target_savings_percent + '%'"></span>
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-500">Status</span>
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="w-2 h-2 rounded-full pulse-live"
|
||
:class="healthy ? 'bg-emerald-400' : 'bg-red-400'"></span>
|
||
<span class="text-sm" x-text="healthy ? 'Healthy' : 'Error'"></span>
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-500">
|
||
Updated <span x-text="lastUpdate"></span>
|
||
</div>
|
||
<button onclick="toggleTheme()" id="theme-toggle"
|
||
class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-400 hover:text-gray-200 transition-colors"
|
||
title="Toggle light/dark mode">
|
||
<svg class="w-4 h-4 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||
</svg>
|
||
<svg class="w-4 h-4 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||
</svg>
|
||
</button>
|
||
<button x-show="log_full_messages" id="feed-toggle"
|
||
class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
||
@click="toggleFeed()"
|
||
:class="feedOpen ? 'bg-accent text-black' : ''">
|
||
Live Feed
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="p-6 max-w-7xl mx-auto">
|
||
<template x-if="viewMode === 'session'">
|
||
<div>
|
||
<p class="mb-4 text-xs text-gray-500">Current proxy process · runtime counters reset on restart</p>
|
||
|
||
<!-- Runtime Overview -->
|
||
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-2">
|
||
<section class="bg-surface rounded-lg p-4 border border-border">
|
||
<h3 class="mb-3 text-sm font-medium text-gray-300">Request Health</h3>
|
||
<div class="grid grid-cols-2 gap-3 text-sm"><div>Completed <span class="float-right tabular-nums" x-text="formatNumber(stats.requests?.total || 0)"></span></div><div>Failed <span class="float-right tabular-nums text-red-400" x-text="formatNumber(stats.requests?.failed || 0)"></span></div><div>Rate Limited <span class="float-right tabular-nums text-amber-400" x-text="formatNumber(stats.requests?.rate_limited || 0)"></span></div><div>Cached <span class="float-right tabular-nums text-cyan-400" x-text="formatNumber(stats.requests?.cached || 0)"></span></div></div>
|
||
</section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border">
|
||
<h3 class="mb-3 text-sm font-medium text-gray-300">Live Activity</h3>
|
||
<div class="grid grid-cols-2 gap-3 text-sm"><div>Active Requests <span class="float-right tabular-nums" x-text="formatNumber(stats.proxy_inbound?.active || 0)"></span></div><div>Active WebSockets <span class="float-right tabular-nums" x-text="formatNumber(stats.runtime?.websocket_sessions?.active_sessions || 0)"></span></div><div>Relay Tasks <span class="float-right tabular-nums" x-text="formatNumber(stats.runtime?.websocket_sessions?.active_relay_tasks || 0)"></span></div><div>Compression Queued <span class="float-right tabular-nums" x-text="formatNumber(stats.runtime?.compression_executor?.queued || 0)"></span></div></div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Session Optimization -->
|
||
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3">
|
||
<!-- Token Savings (%) -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Token Savings</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-accent" x-text="formatNumber(stats.tokens?.saved || 0)"></span>
|
||
<!-- Active compression ratio: savings as a fraction of tokens we
|
||
*attempted* to compress (extracted units + tool schema). When
|
||
the attempted denominator is missing (backend-routed paths
|
||
that don't yet wire per-message live-zone tracking), fall back
|
||
to the whole-request ratio so the headline doesn't collapse to
|
||
0% while compression is happening (issue #455). -->
|
||
<span class="text-sm text-accent" x-text="headlineSavingsPercent.toFixed(1) + '%'" :title="headlineSavingsTitle"></span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-500 leading-relaxed">
|
||
<span x-text="'Proxy ' + formatNumber(stats.tokens?.proxy_compression_saved || 0) + ' (' + proxyShareOfTotal.toFixed(1) + '%)'"></span>
|
||
<span class="mx-1 text-gray-600">/</span>
|
||
<span x-text="cliFilteringAvailable ? (cliFilteringLabel + ' ' + formatNumber(cliFilteringSaved) + ' this session (' + cliFilteringSessionPctDisplay.toFixed(1) + '%)') : (cliFilteringLabel + ' not installed')"></span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-600 leading-relaxed">
|
||
<span x-text="'Of total wire: ' + (stats.tokens?.savings_percent || 0).toFixed(2) + '%'" title="Savings as fraction of all input tokens including frozen prefix"></span>
|
||
</div>
|
||
<div class="mt-2 h-8">
|
||
<svg class="w-full h-full" viewBox="0 0 100 32" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:0.3"/>
|
||
<stop offset="100%" style="stop-color:#22d3ee;stop-opacity:0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<path class="sparkline-area" :d="getSparklineArea(savingsHistory)"></path>
|
||
<path class="sparkline" :d="getSparkline(savingsHistory)"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Output Tokens Saved (counterfactual) -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Output Tokens Saved</div>
|
||
<template x-if="stats.tokens?.output_reduction?.available">
|
||
<div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-accent" x-text="formatNumber(stats.tokens?.output_saved || 0)"></span>
|
||
<span class="text-sm text-accent" x-text="(stats.tokens?.output_reduction_percent || 0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-500 leading-relaxed">
|
||
<!-- "measured" = A/B holdout (unbiased); "estimated" = vs learned baseline -->
|
||
<span class="uppercase tracking-wide"
|
||
:class="stats.tokens?.output_reduction?.method === 'measured' ? 'text-emerald-400' : 'text-gray-400'"
|
||
x-text="stats.tokens?.output_reduction?.method || ''"></span>
|
||
<span x-text="'· 95% CI ' + (stats.tokens?.output_reduction?.ci_low_percent || 0).toFixed(1) + '–' + (stats.tokens?.output_reduction?.ci_high_percent || 0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-600 leading-relaxed"
|
||
x-text="formatNumber(stats.tokens?.output_reduction?.requests || 0) + ' shaped responses · counterfactual'">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="!stats.tokens?.output_reduction?.available">
|
||
<div class="mt-1 text-xs text-gray-500 leading-relaxed">
|
||
<span class="text-2xl font-light tabular-nums text-gray-600">—</span>
|
||
<div class="mt-1">Enable the output shaper (HEADROOM_OUTPUT_SHAPER=1) and run
|
||
<code class="text-gray-400">headroom learn --verbosity --apply</code> to start measuring.</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Tool-Schema Deferral (tool search) — only rendered when there's a saving to show -->
|
||
<template x-if="(stats.savings?.by_layer?.tool_search?.tokens || 0) > 0">
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Tool-Schema Deferral</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-emerald-400" x-text="formatNumber(stats.savings?.by_layer?.tool_search?.tokens || 0)"></span>
|
||
<span class="text-sm text-gray-400">tokens</span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-600 leading-relaxed"
|
||
x-text="formatNumber(stats.savings?.by_layer?.tool_search?.requests || 0) + ' calls · tool schemas deferred (recent window)'">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Performance -->
|
||
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
|
||
<!-- Headroom Overhead -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Overhead</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums" x-text="(stats.overhead?.average_ms || 0).toFixed(0) + 'ms'"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500">
|
||
TTFB <span x-text="((stats.ttfb?.average_ms || 0) / 1000).toFixed(2)"></span>s avg
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token Throughput -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border flex flex-col justify-between">
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1.5">Throughput</div>
|
||
<div class="flex flex-col gap-1 text-[11px] text-gray-300">
|
||
<div class="flex justify-between items-baseline border-b border-border/40 pb-0.5">
|
||
<span class="text-gray-500">Input (wall / active p50)</span>
|
||
<span class="font-mono text-accent">
|
||
<span x-text="(stats.throughput?.rolling?.input_wall_clock || 0).toFixed(1)"></span> /
|
||
<span x-text="(stats.throughput?.rolling?.input_active_p50 || 0).toFixed(1)"></span> tok/s
|
||
</span>
|
||
</div>
|
||
<div class="flex justify-between items-baseline border-b border-border/40 pb-0.5" x-show="(stats.throughput?.rolling?.compression_p50 || 0) > 0">
|
||
<span class="text-gray-500">Compression (p50 / p95)</span>
|
||
<span class="font-mono text-emerald-400">
|
||
<span x-text="(stats.throughput?.rolling?.compression_p50 || 0).toFixed(1)"></span> /
|
||
<span x-text="(stats.throughput?.rolling?.compression_p95 || 0).toFixed(1)"></span> tok/s
|
||
</span>
|
||
</div>
|
||
<div class="flex justify-between items-baseline border-b border-border/40 pb-0.5">
|
||
<span class="text-gray-500">Forward (p50 / p95)</span>
|
||
<span class="font-mono text-cyan-400">
|
||
<span x-text="(stats.throughput?.rolling?.forward_p50 || 0).toFixed(1)"></span> /
|
||
<span x-text="(stats.throughput?.rolling?.forward_p95 || 0).toFixed(1)"></span> tok/s
|
||
</span>
|
||
</div>
|
||
<div class="flex justify-between items-baseline" x-show="(stats.throughput?.rolling?.generation_p50 || 0) > 0">
|
||
<span class="text-gray-500">Generation (p50 / p95)</span>
|
||
<span class="font-mono text-yellow-400">
|
||
<span x-text="(stats.throughput?.rolling?.generation_p50 || 0).toFixed(1)"></span> /
|
||
<span x-text="(stats.throughput?.rolling?.generation_p95 || 0).toFixed(1)"></span> tok/s
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 pt-1.5 border-t border-border/40 text-[10px] text-gray-500 flex justify-between items-center">
|
||
<span>Current 5m (active p50):</span>
|
||
<span class="font-mono text-gray-400">
|
||
In: <span x-text="(stats.throughput?.current?.input_active_p50 || 0).toFixed(0)"></span> ·
|
||
Fwd: <span x-text="(stats.throughput?.current?.forward_p50 || 0).toFixed(0)"></span> tok/s
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Performance -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Performance</div>
|
||
<div class="space-y-3">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Overhead Range</span>
|
||
<span class="font-mono text-sm" x-text="(stats.overhead?.min_ms || 0).toFixed(0) + ' - ' + (stats.overhead?.max_ms || 0).toFixed(0) + 'ms'"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">TTFB Range</span>
|
||
<span class="font-mono text-sm" x-text="((stats.ttfb?.min_ms || 0) / 1000).toFixed(2) + ' - ' + ((stats.ttfb?.max_ms || 0) / 1000).toFixed(2) + 's'"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Failed Requests</span>
|
||
<span class="font-mono text-sm" x-text="stats.requests?.failed || 0"></span>
|
||
</div>
|
||
<!-- Per-transform timing breakdown -->
|
||
<template x-if="Object.keys(stats.pipeline_timing || {}).length > 0">
|
||
<div>
|
||
<div class="border-t border-border my-2"></div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-2">Pipeline Breakdown</div>
|
||
<template x-for="[name, t] in Object.entries(stats.pipeline_timing || {})" :key="name">
|
||
<div class="flex justify-between items-center mb-1">
|
||
<span class="text-xs text-gray-400 font-mono truncate mr-2" x-text="name"></span>
|
||
<span class="text-xs font-mono whitespace-nowrap"
|
||
:class="t.average_ms > 100 ? 'text-amber-400' : t.average_ms > 50 ? 'text-yellow-400' : 'text-gray-400'"
|
||
x-text="t.average_ms.toFixed(0) + 'ms avg / ' + t.max_ms.toFixed(0) + 'ms max'"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Optimization Breakdown -->
|
||
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
|
||
<!-- Token Usage -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Token Usage</div>
|
||
<div class="space-y-3">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Before Compression</span>
|
||
<span class="font-mono text-sm" x-text="formatNumber(stats.tokens?.total_before_compression || 0)"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center" x-show="cliFilteringAvailable">
|
||
<span class="text-sm text-gray-400" x-text="cliFilteringLabel + ' Filtered (this session)'"></span>
|
||
<span class="font-mono text-sm text-emerald-400" x-text="formatNumber(cliFilteringSaved)"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center" x-show="!cliFilteringAvailable">
|
||
<span class="text-sm text-gray-400" x-text="cliFilteringLabel + ' Filtered (this session)'"></span>
|
||
<span class="font-mono text-sm text-gray-600">not installed</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Proxy Removed</span>
|
||
<span class="font-mono text-sm text-accent" x-text="formatNumber(stats.tokens?.proxy_compression_saved || 0)"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">After Compression (sent)</span>
|
||
<span class="font-mono text-sm" x-text="formatNumber(stats.tokens?.input || 0)"></span>
|
||
</div>
|
||
<div class="border-t border-border my-2"></div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Output Tokens</span>
|
||
<span class="font-mono text-sm" x-text="formatNumber(stats.tokens?.output || 0)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What Headroom Removed -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">What Headroom Removed</div>
|
||
<template x-if="Object.keys(stats.waste_signals || {}).length > 0">
|
||
<div class="space-y-3">
|
||
<template x-for="[signal, tokens] in sortedWasteSignals" :key="signal">
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1">
|
||
<span class="text-sm text-gray-400" x-text="wasteSignalLabel(signal)"></span>
|
||
<span class="font-mono text-sm text-accent" x-text="formatNumber(tokens) + ' tokens'"></span>
|
||
</div>
|
||
<div class="w-full h-2 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="wasteSignalColor(signal)"
|
||
:style="'width: ' + getWastePercent(tokens) + '%'"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="Object.keys(stats.waste_signals || {}).length === 0">
|
||
<div class="text-sm text-gray-500 italic py-8 text-center">
|
||
No waste signals detected yet. Data appears after requests are processed.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Cumulative Savings Trend -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<span class="text-sm font-medium text-gray-300">Savings Over Time</span>
|
||
<span class="text-xs text-gray-500 font-mono" x-text="formatNumber(stats.tokens?.saved || 0) + ' tokens total'"></span>
|
||
</div>
|
||
<template x-if="(stats.savings_history || []).length >= 2">
|
||
<div class="h-32">
|
||
<svg class="w-full h-full" viewBox="0 0 200 64" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="trend-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:0.2"/>
|
||
<stop offset="100%" style="stop-color:#22d3ee;stop-opacity:0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<path class="trend-area" :d="getTrendArea(stats.savings_history)"></path>
|
||
<path class="trend-line" :d="getTrendLine(stats.savings_history)"></path>
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
<template x-if="(stats.savings_history || []).length < 2">
|
||
<div class="h-32 flex items-center justify-center text-sm text-gray-500 italic">
|
||
Trend data will appear after multiple requests.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prefix Cache Impact: current process only -->
|
||
<template x-if="cacheSessionActive">
|
||
<div class="bg-surface rounded-lg p-4 border border-border mb-6">
|
||
<div class="flex justify-between items-center mb-3">
|
||
<div class="text-sm font-medium text-gray-300">Prefix Cache Impact</div>
|
||
<div class="text-xs text-emerald-400 font-mono"
|
||
x-show="cacheSessionActive"
|
||
x-text="'Net savings: $' + formatCurrency(stats.prefix_cache?.totals?.net_savings_usd || 0)"></div>
|
||
<div class="text-xs text-gray-500 font-mono"
|
||
x-show="!cacheSessionActive">no activity since restart</div>
|
||
</div>
|
||
<!-- Totals row -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Cache Writes</div>
|
||
<div class="text-2xl font-light tabular-nums text-amber-400"
|
||
x-show="cacheSessionActive"
|
||
x-text="formatNumber(stats.prefix_cache?.totals?.cache_write_tokens || 0)"></div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-600"
|
||
x-show="!cacheSessionActive">—</div>
|
||
<div class="text-xs text-amber-400/70"
|
||
x-show="cacheSessionActive"
|
||
x-text="'$' + formatCurrency(stats.prefix_cache?.totals?.write_premium_usd || 0) + ' write premium'"></div>
|
||
<div class="text-xs text-gray-500"
|
||
x-show="!cacheSessionActive">no activity since restart</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Hit Rate</div>
|
||
<div class="text-2xl font-light tabular-nums" x-show="cacheSessionActive" :class="(stats.prefix_cache?.totals?.hit_rate || 0) > 80 ? 'text-emerald-400' : (stats.prefix_cache?.totals?.hit_rate || 0) > 50 ? 'text-amber-400' : 'text-red-400'" x-text="(stats.prefix_cache?.totals?.hit_rate || 0).toFixed(0) + '%'"></div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-600" x-show="!cacheSessionActive">—</div>
|
||
<div class="text-xs text-gray-500" x-show="cacheSessionActive" x-text="(stats.prefix_cache?.totals?.hit_requests || 0) + ' / ' + (stats.prefix_cache?.totals?.requests || 0) + ' requests'"></div>
|
||
<div class="text-xs text-gray-500" x-show="!cacheSessionActive">no activity since restart</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Cache Busts</div>
|
||
<div class="text-2xl font-light tabular-nums" x-show="cacheSessionActive" :class="(stats.prefix_cache?.totals?.bust_count || 0) > 5 ? 'text-red-400' : (stats.prefix_cache?.totals?.bust_count || 0) > 0 ? 'text-amber-400' : 'text-emerald-400'" x-text="stats.prefix_cache?.totals?.bust_count || 0"></div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-600" x-show="!cacheSessionActive">—</div>
|
||
<div class="text-xs text-gray-500" x-show="cacheSessionActive" x-text="formatNumber(stats.prefix_cache?.totals?.bust_write_tokens || 0) + ' tokens re-written'"></div>
|
||
<div class="text-xs text-gray-500" x-show="!cacheSessionActive">no activity since restart</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Providers</div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-300" x-show="cacheSessionActive" x-text="Object.keys(stats.prefix_cache?.by_provider || {}).length"></div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-600" x-show="!cacheSessionActive">—</div>
|
||
<div class="text-xs text-gray-500" x-show="cacheSessionActive">with cache data</div>
|
||
<div class="text-xs text-gray-500" x-show="!cacheSessionActive">no activity since restart</div>
|
||
</div>
|
||
</div>
|
||
<!-- Cache efficiency bar (session-scoped; hidden until traffic arrives) -->
|
||
<div x-show="cacheSessionActive">
|
||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>Cache Efficiency</span>
|
||
<span x-text="cacheSavingsPercent + '% of input served from cache'"></span>
|
||
</div>
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden flex">
|
||
<div class="h-full bg-emerald-500 transition-all duration-500"
|
||
:style="'width: ' + cacheSavingsPercent + '%'"></div>
|
||
<div class="h-full bg-amber-500 transition-all duration-500"
|
||
:style="'width: ' + cacheWritePercent + '%'"></div>
|
||
</div>
|
||
<div class="flex gap-4 mt-1 text-xs text-gray-500">
|
||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-emerald-500 inline-block"></span> Reads (discounted)</span>
|
||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-amber-500 inline-block"></span> Writes</span>
|
||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-border inline-block"></span> Uncached</span>
|
||
</div>
|
||
</div>
|
||
<template x-if="hasObservedTtlBuckets">
|
||
<div class="mt-5 border-t border-border pt-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-[0.22em]">Observed TTL Buckets</div>
|
||
<div class="text-sm text-gray-300 mt-1">Provider-reported cache write mix</div>
|
||
</div>
|
||
<div class="text-xs text-gray-500 font-mono" x-text="observedTtlWindowLabel"></div>
|
||
</div>
|
||
<div class="grid grid-cols-1 xl:grid-cols-[1.3fr_0.9fr] gap-4">
|
||
<div class="ttl-bucket-gradient-card rounded-2xl border border-cyan-500/20 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div class="text-[11px] uppercase tracking-[0.22em] text-cyan-300/75">Bucket Mix</div>
|
||
<div class="mt-1 text-2xl font-light text-cyan-50">
|
||
<span data-testid="ttl-bucket-mix-1h-total" x-text="formatNumber(stats.prefix_cache?.totals?.cache_write_1h_tokens || 0)"></span>
|
||
<span class="text-sm text-cyan-300/70">1h</span>
|
||
<span class="mx-2 text-cyan-500/60">/</span>
|
||
<span data-testid="ttl-bucket-mix-5m-total" x-text="formatNumber(stats.prefix_cache?.totals?.cache_write_5m_tokens || 0)"></span>
|
||
<span class="text-sm text-cyan-300/70">5m</span>
|
||
</div>
|
||
</div>
|
||
<div data-testid="ttl-bucket-headline" class="rounded-full border border-cyan-400/20 bg-black/20 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-cyan-200/80" x-text="observedTtlHeadline"></div>
|
||
</div>
|
||
<div class="mt-4">
|
||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>Observed write-token split</span>
|
||
<span x-text="(stats.prefix_cache?.totals?.observed_ttl_mix?.active_buckets || []).join(' + ')"></span>
|
||
</div>
|
||
<div class="h-3 overflow-hidden rounded-full bg-black/30 ring-1 ring-white/5 flex">
|
||
<div class="h-full bg-cyan-400 transition-all duration-500" :style="'width:' + (stats.prefix_cache?.totals?.observed_ttl_mix?.['1h_pct'] || 0) + '%'"></div>
|
||
<div class="h-full bg-violet-400 transition-all duration-500" :style="'width:' + (stats.prefix_cache?.totals?.observed_ttl_mix?.['5m_pct'] || 0) + '%'"></div>
|
||
</div>
|
||
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-400">
|
||
<span class="inline-flex items-center gap-1.5"><span class="inline-block h-2 w-2 rounded-full bg-cyan-400"></span><span data-testid="ttl-bucket-mix-1h-pct" x-text="'1h ' + (stats.prefix_cache?.totals?.observed_ttl_mix?.['1h_pct'] || 0).toFixed(1) + '%'"></span></span>
|
||
<span class="inline-flex items-center gap-1.5"><span class="inline-block h-2 w-2 rounded-full bg-violet-400"></span><span data-testid="ttl-bucket-mix-5m-pct" x-text="'5m ' + (stats.prefix_cache?.totals?.observed_ttl_mix?.['5m_pct'] || 0).toFixed(1) + '%'"></span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-1 gap-3">
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-cyan-300/75">1h Cache Writes</div>
|
||
<div data-testid="ttl-bucket-1h-value" class="mt-2 text-3xl font-light text-cyan-50" x-text="formatNumber(stats.prefix_cache?.totals?.observed_ttl_buckets?.['1h']?.tokens || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="formatNumber(stats.prefix_cache?.totals?.observed_ttl_buckets?.['1h']?.requests || 0) + ' requests observed'"></div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-violet-300/75">5m Cache Writes</div>
|
||
<div data-testid="ttl-bucket-5m-value" class="mt-2 text-3xl font-light text-violet-50" x-text="formatNumber(stats.prefix_cache?.totals?.observed_ttl_buckets?.['5m']?.tokens || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="formatNumber(stats.prefix_cache?.totals?.observed_ttl_buckets?.['5m']?.requests || 0) + ' requests observed'"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Compression vs cache: net impact of prefix-mutating layers -->
|
||
<template x-if="hasCompressionVsCache">
|
||
<div class="mt-5 border-t border-border pt-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide">Compression vs Cache</div>
|
||
<div class="text-[11px] text-gray-600 mt-0.5">Tokens saved by compression against cached-prefix tokens its mutations invalidated</div>
|
||
</div>
|
||
<div data-testid="cvc-net-headline" class="rounded-full border bg-black/20 px-3 py-1 text-[11px] uppercase tracking-[0.18em]" :class="compressionVsCacheNet >= 0 ? 'border-emerald-400/20 text-emerald-300/90' : 'border-red-400/25 text-red-300/90'" x-text="compressionVsCacheNet >= 0 ? 'Net positive' : 'Net negative'"></div>
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-emerald-300/75">Saved by Compression</div>
|
||
<div data-testid="cvc-saved-value" class="mt-2 text-3xl font-light text-emerald-50" x-text="formatNumber(stats.prefix_cache?.compression_vs_cache?.tokens_saved_by_compression || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500">tokens removed before send</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-red-300/75">Lost to Cache Busts</div>
|
||
<div data-testid="cvc-bust-value" class="mt-2 text-3xl font-light text-red-50" x-text="formatNumber(stats.prefix_cache?.compression_vs_cache?.tokens_lost_to_cache_bust || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="formatNumber(stats.prefix_cache?.compression_vs_cache?.cache_bust_count || 0) + ' busts observed'"></div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-gray-400">Net</div>
|
||
<div data-testid="cvc-net-value" class="mt-2 text-3xl font-light" :class="compressionVsCacheNet >= 0 ? 'text-emerald-400' : 'text-red-400'" x-text="(compressionVsCacheNet >= 0 ? '+' : '-') + formatNumber(Math.abs(compressionVsCacheNet))"></div>
|
||
<div class="mt-1 text-xs text-gray-500">saved minus bust losses</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-cyan-300/75">Prefix Freeze Net</div>
|
||
<div data-testid="freeze-net-value" class="mt-2 text-3xl font-light" :class="prefixFreezeNet >= 0 ? 'text-cyan-50' : 'text-red-400'" x-text="(prefixFreezeNet >= 0 ? '+' : '-') + formatNumber(Math.abs(prefixFreezeNet))"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="formatNumber(stats.prefix_cache?.prefix_freeze?.busts_avoided || 0) + ' busts avoided, ' + formatNumber(stats.prefix_cache?.prefix_freeze?.compression_foregone_tokens || 0) + ' foregone'"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Cache miss attribution: why expected-cache turns missed (#1313) -->
|
||
<template x-if="hasMissAttribution">
|
||
<div class="mt-5 border-t border-border pt-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide">Cache Miss Attribution</div>
|
||
<div class="text-[11px] text-gray-600 mt-0.5">Why turns that expected a prompt-cache hit missed — TTL lapse (consider a longer TTL) vs the cacheable prefix changing</div>
|
||
</div>
|
||
<div data-testid="miss-attr-headline" class="rounded-full border bg-black/20 px-3 py-1 text-[11px] uppercase tracking-[0.18em]"
|
||
:class="(missAttribution.ttl_expiry_pct || 0) >= (missAttribution.prefix_change_pct || 0) ? 'border-violet-400/25 text-violet-300/90' : 'border-amber-400/25 text-amber-300/90'"
|
||
x-text="(missAttribution.ttl_expiry_pct || 0) >= (missAttribution.prefix_change_pct || 0) ? 'Mostly TTL lapse' : 'Mostly prefix change'"></div>
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-violet-300/75">TTL Expiry</div>
|
||
<div data-testid="miss-attr-ttl-value" class="mt-2 text-3xl font-light text-violet-50" x-text="formatNumber(missAttribution.ttl_expiry || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="(missAttribution.ttl_expiry_pct || 0).toFixed(1) + '% of attributed — idle past cache TTL'"></div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-amber-300/75">Prefix Change</div>
|
||
<div data-testid="miss-attr-prefix-value" class="mt-2 text-3xl font-light text-amber-50" x-text="formatNumber(missAttribution.prefix_change || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500" x-text="(missAttribution.prefix_change_pct || 0).toFixed(1) + '% of attributed — cached prefix shifted'"></div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-gray-400">Unknown</div>
|
||
<div data-testid="miss-attr-unknown-value" class="mt-2 text-3xl font-light text-gray-300" x-text="formatNumber(missAttribution.unknown || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500">stable prefix, within TTL</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div class="text-[11px] uppercase tracking-[0.18em] text-gray-400">Total Misses</div>
|
||
<div data-testid="miss-attr-total-value" class="mt-2 text-3xl font-light text-gray-100" x-text="formatNumber(missAttribution.total || 0)"></div>
|
||
<div class="mt-1 text-xs text-gray-500">expected a cache hit, got none</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Per-provider breakdown -->
|
||
<template x-if="Object.keys(stats.prefix_cache?.by_provider || {}).length > 0">
|
||
<div class="mt-4 border-t border-border pt-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-2">Per-Provider Breakdown</div>
|
||
<div class="space-y-3">
|
||
<template x-for="[prov, pc] in Object.entries(stats.prefix_cache?.by_provider || {})" :key="prov">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<span class="px-2 py-0.5 bg-border rounded text-xs font-mono" x-text="prov"></span>
|
||
<span class="text-xs text-gray-500" x-text="pc.label"></span>
|
||
</div>
|
||
<div class="flex flex-wrap items-center justify-end gap-4 text-xs font-mono">
|
||
<span class="text-emerald-400" x-text="formatNumber(pc.cache_read_tokens) + ' reads (' + pc.read_discount + ' off)'"></span>
|
||
<template x-if="pc.write_premium !== 'none'">
|
||
<span class="text-amber-400" x-text="formatNumber(pc.cache_write_tokens) + ' writes (+' + pc.write_premium + ')'"></span>
|
||
</template>
|
||
<template x-if="pc.observed_ttl_mix">
|
||
<span class="text-cyan-300" x-text="'TTL 1h ' + (pc.observed_ttl_mix['1h_pct'] || 0).toFixed(1) + '% / 5m ' + (pc.observed_ttl_mix['5m_pct'] || 0).toFixed(1) + '%'"></span>
|
||
</template>
|
||
<span :class="pc.bust_count > 0 ? 'text-red-400' : 'text-gray-500'" x-text="pc.bust_count + ' busts'"></span>
|
||
<span class="text-emerald-400" x-text="'$' + formatCurrency(pc.net_savings_usd)"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Traffic & Workload -->
|
||
<!-- Agent Usage -->
|
||
<div class="bg-surface rounded-lg border border-border overflow-hidden mb-6">
|
||
<div class="px-4 py-3 border-b border-border flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<div class="text-sm font-medium text-gray-300">Agent Usage</div>
|
||
<div class="text-xs text-gray-500">Before and after token usage by detected client</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||
<span class="text-gray-500" x-text="'Coverage: ' + agentCoverageLabel"></span>
|
||
<span class="px-2 py-0.5 rounded border border-border bg-card-alt font-mono text-gray-400"
|
||
x-text="formatNumber(stats.agent_usage?.totals?.requests || 0) + ' requests'"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-4">
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-5">
|
||
<div class="rounded-lg border border-border bg-card-alt p-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Before</div>
|
||
<div class="text-2xl font-light tabular-nums" x-text="formatNumber(stats.agent_usage?.totals?.before_tokens || 0)"></div>
|
||
</div>
|
||
<div class="rounded-lg border border-border bg-card-alt p-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">After</div>
|
||
<div class="text-2xl font-light tabular-nums text-gray-200" x-text="formatNumber(stats.agent_usage?.totals?.after_tokens || 0)"></div>
|
||
</div>
|
||
<div class="rounded-lg border border-border bg-card-alt p-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Saved</div>
|
||
<div class="text-2xl font-light tabular-nums text-accent" x-text="formatNumber(stats.agent_usage?.totals?.tokens_saved || 0)"></div>
|
||
</div>
|
||
<div class="rounded-lg border border-border bg-card-alt p-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Savings</div>
|
||
<div class="text-2xl font-light tabular-nums text-emerald-400" x-text="(stats.agent_usage?.totals?.savings_percent || 0).toFixed(1) + '%'"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<template x-if="agentRows.length > 0">
|
||
<div class="space-y-3">
|
||
<template x-for="agent in agentRows" :key="agent.agent">
|
||
<div class="rounded-lg border border-border bg-card-alt p-3">
|
||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(160px,0.9fr)_minmax(260px,1.5fr)_minmax(260px,1.2fr)] lg:items-center">
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="h-2.5 w-2.5 rounded-full" :class="agentDotClass(agent.agent)"></span>
|
||
<span class="text-sm font-medium text-gray-200 truncate" x-text="agent.label"></span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-500">
|
||
<span x-text="formatNumber(agent.requests || 0) + ' requests'"></span>
|
||
<span class="mx-1 text-gray-700">/</span>
|
||
<span x-text="agent.source"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="flex items-center justify-between text-xs mb-1">
|
||
<span class="text-gray-500">Token flow</span>
|
||
<span class="font-mono text-emerald-400" x-text="(agent.savings_percent || 0).toFixed(1) + '% saved'"></span>
|
||
</div>
|
||
<div class="h-3 w-full rounded-full bg-border overflow-hidden flex">
|
||
<div class="h-full bg-emerald-500 transition-all duration-500"
|
||
:style="'width:' + agentSavedWidth(agent) + '%'"></div>
|
||
<div class="h-full bg-accent/60 transition-all duration-500"
|
||
:style="'width:' + agentAfterWidth(agent) + '%'"></div>
|
||
</div>
|
||
<div class="mt-1 flex flex-wrap gap-3 text-xs text-gray-500">
|
||
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-emerald-500"></span>Saved</span>
|
||
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-accent/60"></span>Sent</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-right">
|
||
<div>
|
||
<div class="text-[11px] uppercase tracking-wide text-gray-500">Before</div>
|
||
<div class="font-mono text-sm" x-text="formatNumber(agent.before_tokens || 0)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[11px] uppercase tracking-wide text-gray-500">After</div>
|
||
<div class="font-mono text-sm" x-text="formatNumber(agent.after_tokens || 0)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[11px] uppercase tracking-wide text-gray-500">Saved</div>
|
||
<div class="font-mono text-sm text-accent" x-text="formatNumber(agent.tokens_saved || 0)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[11px] uppercase tracking-wide text-gray-500">Share</div>
|
||
<div class="font-mono text-sm text-gray-300" x-text="(agent.share_of_saved_percent || 0).toFixed(1) + '%'"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="agentRows.length === 0">
|
||
<div class="rounded-lg border border-dashed border-border p-6 text-center text-sm text-gray-500">
|
||
Agent usage appears after Cursor, Claude, Codex, or another client sends traffic through this proxy.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
|
||
<!-- Providers -->
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Providers</div>
|
||
<div class="space-y-2">
|
||
<template x-for="(count, provider) in (stats.requests?.by_provider || {})" :key="provider">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400" x-text="provider"></span>
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-24 h-1.5 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full bg-accent rounded-full"
|
||
:style="'width: ' + getProviderPercent(count) + '%'"></div>
|
||
</div>
|
||
<span class="font-mono text-sm w-12 text-right" x-text="formatNumber(count)"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="Object.keys(stats.requests?.by_provider || {}).length === 0">
|
||
<div class="text-sm text-gray-500 italic">No requests yet</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Per-Model Savings Breakdown -->
|
||
<template x-if="Object.keys(stats.cost?.per_model || {}).length > 0">
|
||
<div class="bg-surface rounded-lg border border-border overflow-hidden lg:col-span-2">
|
||
<div class="px-4 py-3 border-b border-border flex justify-between items-center">
|
||
<span class="text-sm font-medium text-gray-300">Per-Model Token Savings</span>
|
||
<span class="text-xs text-gray-500">Exact tokens saved per model</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm" style="table-layout:fixed">
|
||
<colgroup>
|
||
<col style="width:40%">
|
||
<col style="width:12%">
|
||
<col style="width:18%">
|
||
<col style="width:18%">
|
||
<col style="width:12%">
|
||
</colgroup>
|
||
<thead>
|
||
<tr class="text-xs text-gray-500 uppercase tracking-wide">
|
||
<th class="px-4 py-3 font-medium text-left">Model</th>
|
||
<th class="px-4 py-3 font-medium text-right">Requests</th>
|
||
<th class="px-4 py-3 font-medium text-right">Tokens Saved</th>
|
||
<th class="px-4 py-3 font-medium text-right">Tokens Sent</th>
|
||
<th class="px-4 py-3 font-medium text-right">Reduction</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-border">
|
||
<template x-for="[model, info] in Object.entries(stats.cost?.per_model || {})" :key="model">
|
||
<tr class="hover:bg-border/30 transition-colors">
|
||
<td class="px-4 py-3 text-left">
|
||
<span class="px-2 py-0.5 bg-border rounded text-xs" x-text="truncateModel(model)"></span>
|
||
</td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="info.requests"></td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums text-accent" x-text="formatNumber(info.tokens_saved)"></td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(info.tokens_sent)"></td>
|
||
<td class="px-4 py-3 text-right">
|
||
<span class="text-accent font-mono tabular-nums" x-text="info.reduction_pct.toFixed(1) + '%'"></span>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Recent Requests Table (with expandable rows) -->
|
||
<div class="bg-surface rounded-lg border border-border overflow-hidden mb-6">
|
||
<div class="px-4 py-3 border-b border-border flex justify-between items-center">
|
||
<span class="text-sm font-medium text-gray-300">Recent Requests</span>
|
||
<span class="text-xs text-gray-500">Last 25 — click row to expand</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<div>
|
||
<!-- Header -->
|
||
<div class="grid text-xs text-gray-500 uppercase tracking-wide border-b border-border"
|
||
style="grid-template-columns: 2rem 12fr 22fr 15fr 12fr 10fr 14fr">
|
||
<div class="px-2 py-3"></div>
|
||
<div class="px-4 py-3 font-medium">Time</div>
|
||
<div class="px-4 py-3 font-medium">Model</div>
|
||
<div class="px-4 py-3 font-medium text-right">Input</div>
|
||
<div class="px-4 py-3 font-medium text-right">Output</div>
|
||
<div class="px-4 py-3 font-medium text-right">Saved</div>
|
||
<div class="px-4 py-3 font-medium text-right">Latency</div>
|
||
</div>
|
||
<!-- Data rows -->
|
||
<div class="divide-y divide-border">
|
||
<template x-for="req in (stats.recent_requests || [])" :key="req.request_id">
|
||
<div>
|
||
<div class="cursor-pointer" @click="toggleExpanded(req.request_id)">
|
||
<div class="grid hover:bg-border/30 transition-colors"
|
||
style="grid-template-columns: 2rem 12fr 22fr 15fr 12fr 10fr 14fr">
|
||
<div class="px-2 py-3 text-gray-500 flex items-center justify-center">
|
||
<span x-text="expandedRows[req.request_id] ? '-' : '+'"></span>
|
||
</div>
|
||
<div class="px-4 py-3 font-mono text-gray-400 truncate" x-text="formatTime(req.timestamp)"></div>
|
||
<div class="px-4 py-3 min-w-0">
|
||
<span class="px-2 py-0.5 bg-border rounded text-xs truncate" x-text="truncateModel(req.model)"></span>
|
||
</div>
|
||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.input_tokens_optimized)"></div>
|
||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.output_tokens || 0)"></div>
|
||
<div class="px-4 py-3 text-right">
|
||
<span class="text-accent font-mono tabular-nums" x-text="req.savings_percent.toFixed(0) + '%'"></span>
|
||
</div>
|
||
<div class="px-4 py-3 text-right font-mono tabular-nums text-gray-400" x-text="(req.total_latency_ms || 0).toFixed(0) + 'ms'"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Expanded detail row -->
|
||
<template x-if="expandedRows[req.request_id]">
|
||
<div class="px-8 py-4 border-t border-border" style="background: var(--card-alt-bg);">
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
|
||
<div>
|
||
<div class="text-gray-500 uppercase tracking-wide mb-1">Original Tokens</div>
|
||
<div class="font-mono" x-text="formatNumber(req.input_tokens_original)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-gray-500 uppercase tracking-wide mb-1">Compressed Tokens</div>
|
||
<div class="font-mono" x-text="formatNumber(req.input_tokens_optimized)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-gray-500 uppercase tracking-wide mb-1">Tokens Removed</div>
|
||
<div class="font-mono text-accent" x-text="formatNumber(req.tokens_saved)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-gray-500 uppercase tracking-wide mb-1">Optimization Time</div>
|
||
<div class="font-mono" x-text="(req.optimization_latency_ms || 0).toFixed(0) + 'ms'"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Transforms Applied -->
|
||
<template x-if="(req.transforms_applied || []).length > 0">
|
||
<div class="mt-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Transforms Applied</div>
|
||
<div class="flex flex-wrap gap-1">
|
||
<template x-for="t in req.transforms_applied" :key="t">
|
||
<span class="px-2 py-0.5 bg-border rounded text-xs font-mono" x-text="t"></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Waste Signals for this request -->
|
||
<template x-if="req.waste_signals && Object.keys(req.waste_signals).length > 0">
|
||
<div class="mt-3">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Waste Detected</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<template x-for="[signal, tokens] in Object.entries(req.waste_signals).filter(([,v]) => v > 0)" :key="signal">
|
||
<span class="px-2 py-0.5 rounded text-xs font-mono"
|
||
:class="wasteSignalBadgeColor(signal)"
|
||
x-text="wasteSignalLabel(signal) + ': ' + formatNumber(tokens)"></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="(stats.recent_requests || []).length === 0">
|
||
<div class="px-4 py-8 text-center text-gray-500 italic">
|
||
No requests yet. Start using the proxy to see activity here.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capacity & Quotas -->
|
||
<!-- Anthropic Subscription Window -->
|
||
<template x-if="stats.subscription_window && stats.subscription_window.latest">
|
||
<div class="bg-surface rounded-lg p-4 border border-border mb-6">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<div class="text-sm font-medium text-gray-300">Anthropic Subscription Window</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-2 h-2 rounded-full pulse-live"
|
||
:class="stats.subscription_window.last_active_at ? 'bg-emerald-400' : 'bg-gray-600'"></span>
|
||
<span class="text-xs text-gray-500" x-text="stats.subscription_window.last_active_at ? 'Active' : 'Idle'"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 5h / 7d progress bars -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<!-- 5-hour window -->
|
||
<template x-if="stats.subscription_window.latest.five_hour">
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide">5-Hour Window</span>
|
||
<span class="tabular-nums font-mono"
|
||
:class="stats.subscription_window.latest.five_hour.utilization_pct > 80 ? 'text-red-400' : stats.subscription_window.latest.five_hour.utilization_pct > 60 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="(stats.subscription_window.latest.five_hour.utilization_pct || 0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="stats.subscription_window.latest.five_hour.utilization_pct > 80 ? 'bg-red-500' : stats.subscription_window.latest.five_hour.utilization_pct > 60 ? 'bg-amber-500' : 'bg-emerald-500'"
|
||
:style="'width: ' + Math.min(stats.subscription_window.latest.five_hour.utilization_pct || 0, 100) + '%'"></div>
|
||
</div>
|
||
<div class="text-xs text-gray-500 mt-1"
|
||
x-text="'Resets in ' + formatResetTime(stats.subscription_window.latest.five_hour.seconds_to_reset)"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 7-day window -->
|
||
<template x-if="stats.subscription_window.latest.seven_day">
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide">7-Day Window</span>
|
||
<span class="tabular-nums font-mono"
|
||
:class="stats.subscription_window.latest.seven_day.utilization_pct > 80 ? 'text-red-400' : stats.subscription_window.latest.seven_day.utilization_pct > 60 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="(stats.subscription_window.latest.seven_day.utilization_pct || 0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="stats.subscription_window.latest.seven_day.utilization_pct > 80 ? 'bg-red-500' : stats.subscription_window.latest.seven_day.utilization_pct > 60 ? 'bg-amber-500' : 'bg-emerald-500'"
|
||
:style="'width: ' + Math.min(stats.subscription_window.latest.seven_day.utilization_pct || 0, 100) + '%'"></div>
|
||
</div>
|
||
<div class="text-xs text-gray-500 mt-1"
|
||
x-text="'Resets in ' + formatResetTime(stats.subscription_window.latest.seven_day.seconds_to_reset)"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Overage / extra usage -->
|
||
<template x-if="stats.subscription_window.latest.extra_usage && stats.subscription_window.latest.extra_usage.is_enabled">
|
||
<div class="border-t border-border pt-3 mb-3">
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide">Extra Usage (Overage)</span>
|
||
<span class="text-amber-400 tabular-nums font-mono"
|
||
x-text="'$' + formatCurrency(stats.subscription_window.latest.extra_usage.used_credits_usd || 0) + (stats.subscription_window.latest.extra_usage.monthly_limit_usd ? ' / $' + formatCurrency(stats.subscription_window.latest.extra_usage.monthly_limit_usd) : '')"></span>
|
||
</div>
|
||
<template x-if="stats.subscription_window.latest.extra_usage.utilization_pct != null">
|
||
<div class="w-full h-2 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||
:style="'width: ' + Math.min(stats.subscription_window.latest.extra_usage.utilization_pct || 0, 100) + '%'"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Headroom contribution row -->
|
||
<template x-if="stats.subscription_window.contribution && stats.subscription_window.contribution.tokens_submitted > 0">
|
||
<div class="border-t border-border pt-3">
|
||
<div class="text-xs text-gray-400 uppercase tracking-wide mb-2">Headroom Contribution This Window</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div>
|
||
<div class="text-xs text-gray-500 mb-0.5">Efficiency</div>
|
||
<div class="text-lg font-light tabular-nums text-accent"
|
||
x-text="(stats.subscription_window.contribution.efficiency_pct || 0).toFixed(1) + '%'"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 mb-0.5">Tokens Saved</div>
|
||
<div class="text-lg font-light tabular-nums text-emerald-400"
|
||
x-text="formatNumber(stats.subscription_window.contribution.tokens_saved?.total || 0)"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 mb-0.5">Compression</div>
|
||
<div class="text-sm tabular-nums text-gray-300"
|
||
x-text="formatNumber(stats.subscription_window.contribution.tokens_saved?.compression || 0) + ' tok'"></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs text-gray-500 mb-0.5">Cache Reads</div>
|
||
<div class="text-sm tabular-nums text-cyan-400"
|
||
x-text="formatNumber(stats.subscription_window.contribution.tokens_saved?.cache_reads || 0) + ' tok'"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Efficiency bar: tokens submitted vs what would have been sent without headroom -->
|
||
<template x-if="stats.subscription_window.contribution.raw_without_headroom > 0">
|
||
<div class="mt-3">
|
||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>Raw vs Submitted</span>
|
||
<span x-text="formatNumber(stats.subscription_window.contribution.tokens_submitted) + ' / ' + formatNumber(stats.subscription_window.contribution.raw_without_headroom) + ' tokens forwarded'"></span>
|
||
</div>
|
||
<div class="w-full h-2 bg-border rounded-full overflow-hidden flex">
|
||
<div class="h-full bg-accent rounded-l-full"
|
||
:style="'width: ' + ((stats.subscription_window.contribution.tokens_submitted / stats.subscription_window.contribution.raw_without_headroom) * 100).toFixed(1) + '%'"></div>
|
||
</div>
|
||
<div class="flex gap-4 mt-1 text-xs text-gray-500">
|
||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-accent inline-block"></span>Forwarded to Anthropic</span>
|
||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-border inline-block"></span>Saved by Headroom</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Discrepancies -->
|
||
<template x-if="stats.subscription_window.discrepancies && stats.subscription_window.discrepancies.length > 0">
|
||
<div class="border-t border-border pt-3 mt-3">
|
||
<div class="text-xs text-amber-400 uppercase tracking-wide mb-2">⚠ Anomalies Detected</div>
|
||
<template x-for="d in stats.subscription_window.discrepancies" :key="d.kind + d.description">
|
||
<div class="text-xs mb-1"
|
||
:class="d.severity === 'alert' ? 'text-red-400' : 'text-amber-400'"
|
||
x-text="d.description"></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<!-- Codex Rate-Limit Window -->
|
||
<template x-if="stats.codex_rate_limits && stats.codex_rate_limits.primary">
|
||
<div class="bg-surface rounded-lg p-4 border border-border mb-6">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<div class="text-sm font-medium text-gray-300">OpenAI Codex Rate-Limit Window</div>
|
||
<template x-if="stats.codex_rate_limits.limit_name">
|
||
<span class="text-xs text-gray-500 font-mono" x-text="stats.codex_rate_limits.limit_name"></span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Primary / Secondary window grid (mirrors Anthropic 5h/7d layout) -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<!-- Primary window -->
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide">Primary
|
||
<template x-if="stats.codex_rate_limits.primary.window_label">
|
||
<span class="text-gray-600 normal-case font-mono ml-1"
|
||
x-text="'(' + stats.codex_rate_limits.primary.window_label + ')'"></span>
|
||
</template>
|
||
</span>
|
||
<span class="tabular-nums font-mono"
|
||
:class="(stats.codex_rate_limits.primary.used_percent||0) > 80 ? 'text-red-400' : (stats.codex_rate_limits.primary.used_percent||0) > 60 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="(stats.codex_rate_limits.primary.used_percent||0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="(stats.codex_rate_limits.primary.used_percent||0) > 80 ? 'bg-red-500' : (stats.codex_rate_limits.primary.used_percent||0) > 60 ? 'bg-amber-500' : 'bg-emerald-500'"
|
||
:style="'width: ' + Math.min(stats.codex_rate_limits.primary.used_percent||0, 100) + '%'"></div>
|
||
</div>
|
||
<template x-if="stats.codex_rate_limits.primary.seconds_until_reset !== null">
|
||
<div class="text-xs text-gray-500 mt-1"
|
||
x-text="'Resets in ' + formatResetTime(stats.codex_rate_limits.primary.seconds_until_reset)"></div>
|
||
</template>
|
||
</div>
|
||
<!-- Secondary window -->
|
||
<template x-if="stats.codex_rate_limits.secondary">
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide">Secondary
|
||
<template x-if="stats.codex_rate_limits.secondary.window_label">
|
||
<span class="text-gray-600 normal-case font-mono ml-1"
|
||
x-text="'(' + stats.codex_rate_limits.secondary.window_label + ')'"></span>
|
||
</template>
|
||
</span>
|
||
<span class="tabular-nums font-mono"
|
||
:class="(stats.codex_rate_limits.secondary.used_percent||0) > 80 ? 'text-red-400' : (stats.codex_rate_limits.secondary.used_percent||0) > 60 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="(stats.codex_rate_limits.secondary.used_percent||0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="(stats.codex_rate_limits.secondary.used_percent||0) > 80 ? 'bg-red-500' : (stats.codex_rate_limits.secondary.used_percent||0) > 60 ? 'bg-amber-500' : 'bg-emerald-500'"
|
||
:style="'width: ' + Math.min(stats.codex_rate_limits.secondary.used_percent||0, 100) + '%'"></div>
|
||
</div>
|
||
<template x-if="stats.codex_rate_limits.secondary.seconds_until_reset !== null">
|
||
<div class="text-xs text-gray-500 mt-1"
|
||
x-text="'Resets in ' + formatResetTime(stats.codex_rate_limits.secondary.seconds_until_reset)"></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Credits section (mirrors Anthropic extra-usage section) -->
|
||
<template x-if="stats.codex_rate_limits.credits">
|
||
<div class="border-t border-border pt-3 mb-3">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-xs text-gray-400 uppercase tracking-wide">Credits</span>
|
||
<span class="text-sm font-mono font-medium"
|
||
:class="stats.codex_rate_limits.credits.unlimited ? 'text-purple-400' : stats.codex_rate_limits.credits.has_credits ? 'text-emerald-400' : 'text-red-400'"
|
||
x-text="stats.codex_rate_limits.credits.unlimited ? '∞ Unlimited' : stats.codex_rate_limits.credits.balance ? stats.codex_rate_limits.credits.balance : (stats.codex_rate_limits.credits.has_credits ? 'Active' : 'No Credits')"></span>
|
||
</div>
|
||
<template x-if="stats.codex_rate_limits.credits.balance && !stats.codex_rate_limits.credits.unlimited">
|
||
<div class="text-xs text-gray-500 mt-0.5">Pay-as-you-go credits balance</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Promo message -->
|
||
<template x-if="stats.codex_rate_limits.promo_message">
|
||
<div class="mt-2 text-xs text-blue-400 italic"
|
||
x-text="stats.codex_rate_limits.promo_message"></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- GitHub Copilot Quota -->
|
||
<template x-if="stats.copilot_quota && stats.copilot_quota.latest">
|
||
<div class="bg-surface rounded-lg p-4 border border-border mb-6">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<div class="text-sm font-medium text-gray-300">GitHub Copilot Quota</div>
|
||
<div class="flex items-center gap-2">
|
||
<template x-if="stats.copilot_quota.latest.copilot_plan">
|
||
<span class="text-xs text-gray-500 font-mono capitalize" x-text="stats.copilot_quota.latest.copilot_plan + ' plan'"></span>
|
||
</template>
|
||
<template x-if="stats.copilot_quota.latest.login">
|
||
<span class="text-xs text-gray-400" x-text="'@' + stats.copilot_quota.latest.login"></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Per-category quota grid (3 columns, mirrors Anthropic 5h/7d structure) -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||
<template x-for="[catKey, catLabel] in [['chat','Chat'],['completions','Completions'],['premium_interactions','Premium']]" :key="catKey">
|
||
<template x-if="stats.copilot_quota.latest.categories[catKey]">
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-1">
|
||
<span class="text-gray-400 uppercase tracking-wide" x-text="catLabel"></span>
|
||
<template x-if="stats.copilot_quota.latest.categories[catKey].unlimited">
|
||
<span class="text-purple-400 font-mono">∞</span>
|
||
</template>
|
||
<template x-if="!stats.copilot_quota.latest.categories[catKey].unlimited">
|
||
<span class="tabular-nums font-mono"
|
||
:class="(stats.copilot_quota.latest.categories[catKey].used_percent||0) > 80 ? 'text-red-400' : (stats.copilot_quota.latest.categories[catKey].used_percent||0) > 50 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="(stats.copilot_quota.latest.categories[catKey].used_percent||0).toFixed(0) + '%'"></span>
|
||
</template>
|
||
</div>
|
||
<!-- Used / entitlement count (big number like Anthropic window) -->
|
||
<template x-if="!stats.copilot_quota.latest.categories[catKey].unlimited">
|
||
<div class="flex items-baseline gap-1 mb-1">
|
||
<span class="text-lg font-light tabular-nums"
|
||
:class="(stats.copilot_quota.latest.categories[catKey].used_percent||0) > 80 ? 'text-red-400' : (stats.copilot_quota.latest.categories[catKey].used_percent||0) > 50 ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="stats.copilot_quota.latest.categories[catKey].used ?? '-'"></span>
|
||
<span class="text-xs text-gray-500 font-mono"
|
||
x-text="'/ ' + (stats.copilot_quota.latest.categories[catKey].entitlement ?? '∞')"></span>
|
||
<span class="text-xs text-gray-500 ml-auto font-mono"
|
||
x-text="(stats.copilot_quota.latest.categories[catKey].remaining ?? '') + ' left'"></span>
|
||
</div>
|
||
</template>
|
||
<template x-if="stats.copilot_quota.latest.categories[catKey].unlimited">
|
||
<div class="text-lg font-light text-purple-400 mb-1">Unlimited</div>
|
||
</template>
|
||
<!-- Progress bar -->
|
||
<template x-if="!stats.copilot_quota.latest.categories[catKey].unlimited">
|
||
<div class="w-full h-3 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full rounded-full transition-all duration-500"
|
||
:class="(stats.copilot_quota.latest.categories[catKey].used_percent||0) > 80 ? 'bg-red-500' : (stats.copilot_quota.latest.categories[catKey].used_percent||0) > 50 ? 'bg-amber-500' : 'bg-emerald-500'"
|
||
:style="'width: ' + Math.min(stats.copilot_quota.latest.categories[catKey].used_percent||0, 100) + '%'"></div>
|
||
</div>
|
||
</template>
|
||
<!-- Overage info -->
|
||
<template x-if="stats.copilot_quota.latest.categories[catKey].overage_count > 0">
|
||
<div class="text-xs text-amber-400 mt-1"
|
||
x-text="stats.copilot_quota.latest.categories[catKey].overage_count + ' overage uses'"></div>
|
||
</template>
|
||
<div class="mt-1">
|
||
<span class="text-xs"
|
||
:class="stats.copilot_quota.latest.categories[catKey].overage_permitted ? 'text-gray-500' : 'text-gray-600'"
|
||
x-text="stats.copilot_quota.latest.categories[catKey].overage_permitted ? 'Overage allowed' : 'No overage'"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Monthly reset section (mirrors Anthropic extra-usage border section) -->
|
||
<div class="border-t border-border pt-3">
|
||
<div class="flex justify-between text-xs mb-2">
|
||
<span class="text-gray-400 uppercase tracking-wide">Monthly Reset</span>
|
||
<template x-if="stats.copilot_quota.latest.quota_reset_date_utc">
|
||
<span class="text-gray-400 font-mono"
|
||
x-text="formatMonthlyReset(stats.copilot_quota.latest.quota_reset_date_utc)"></span>
|
||
</template>
|
||
</div>
|
||
<template x-if="stats.copilot_quota.latest.quota_reset_date_utc">
|
||
<div>
|
||
<!-- Month-elapsed bar: filled = time elapsed, empty = time remaining -->
|
||
<div class="w-full h-2 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full bg-accent/50 rounded-full transition-all duration-500"
|
||
:style="'width: ' + (100 - Math.min(100, Math.max(0, (new Date(stats.copilot_quota.latest.quota_reset_date_utc) - new Date()) / (30 * 86400000) * 100))) + '%'"></div>
|
||
</div>
|
||
<div class="flex justify-between text-xs text-gray-600 mt-0.5">
|
||
<span>Month start</span>
|
||
<span x-text="new Date(stats.copilot_quota.latest.quota_reset_date_utc).toLocaleDateString(undefined, {month:'short', day:'numeric'})"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Lifetime view: durable aggregate metrics only -->
|
||
<template x-if="viewMode === 'lifetime'">
|
||
<div>
|
||
<div class="mb-6 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<p class="text-xs text-gray-500" x-text="'Lifetime data since ' + (lifetimeStats.started_at || '—')"></p>
|
||
<p class="text-xs text-gray-500" x-text="'Full metric coverage since ' + (lifetimeStats.full_fidelity_started_at || '—')"></p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-gray-500">
|
||
<span>Durable local savings history from <span class="font-mono">/stats-lifetime</span></span>
|
||
<span aria-hidden="true">-</span>
|
||
<span :class="lifetimeStats.persistence?.healthy === false ? 'text-amber-400' : 'text-emerald-400'"
|
||
x-text="lifetimeStats.persistence?.healthy === false ? ('Persistence degraded: ' + (lifetimeStats.persistence?.error || 'unknown error')) : 'Persistence healthy'"></span>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4 mb-6 md:grid-cols-4">
|
||
<div class="bg-surface rounded-lg p-4 border border-border"><div class="text-xs text-gray-500">Requests</div><div class="text-2xl tabular-nums" x-text="formatNumber(lifetimeStats.requests?.total || 0)"></div></div>
|
||
<div class="bg-surface rounded-lg p-4 border border-border"><div class="text-xs text-gray-500">Failed</div><div class="text-2xl tabular-nums text-red-400" x-text="formatNumber(lifetimeStats.requests?.failed || 0)"></div></div>
|
||
<div class="bg-surface rounded-lg p-4 border border-border"><div class="text-xs text-gray-500">Rate Limited</div><div class="text-2xl tabular-nums text-amber-400" x-text="formatNumber(lifetimeStats.requests?.rate_limited || 0)"></div></div>
|
||
<div class="bg-surface rounded-lg p-4 border border-border"><div class="text-xs text-gray-500">Cached</div><div class="text-2xl tabular-nums text-cyan-400" x-text="formatNumber(lifetimeStats.requests?.cached || 0)"></div></div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Tokens</h3><dl class="space-y-2 text-sm"><div class="flex justify-between"><dt>Input</dt><dd x-text="formatNumber(lifetimeStats.tokens?.input || 0)"></dd></div><div class="flex justify-between"><dt>Output</dt><dd x-text="formatNumber(lifetimeStats.tokens?.output || 0)"></dd></div><div class="flex justify-between"><dt>Attempted Input</dt><dd x-text="formatNumber(lifetimeStats.tokens?.attempted_input || 0)"></dd></div><div class="flex justify-between"><dt>Saved</dt><dd class="text-emerald-400" x-text="formatNumber(lifetimeStats.tokens?.saved || 0)"></dd></div><div class="flex justify-between"><dt>Token Savings</dt><dd x-text="lifetimeStats.tokens?.token_savings_percent == null ? '—' : lifetimeStats.tokens.token_savings_percent.toFixed(1) + '%'"></dd></div></dl></section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Cost</h3><dl class="space-y-2 text-sm"><div class="flex justify-between"><dt>Input cost</dt><dd x-text="'$' + formatCurrency(lifetimeStats.cost?.input_usd || 0)"></dd></div><div class="flex justify-between"><dt>Compression saved</dt><dd class="text-emerald-400" x-text="'$' + formatCurrency(lifetimeStats.cost?.compression_savings_usd || 0)"></dd></div><div class="flex justify-between"><dt>Prefix Cache saved</dt><dd class="text-cyan-400" x-text="'$' + formatCurrency(lifetimeStats.cost?.cache_savings_usd || 0)"></dd></div></dl></section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Prefix Cache</h3><dl class="space-y-2 text-sm"><div class="flex justify-between"><dt>Hits / requests</dt><dd x-text="formatNumber(lifetimeStats.prefix_cache?.hit_requests || 0) + ' / ' + formatNumber(lifetimeStats.prefix_cache?.requests || 0)"></dd></div><div class="flex justify-between"><dt>Hit rate</dt><dd x-text="lifetimeStats.prefix_cache?.cache_hit_rate == null ? '—' : lifetimeStats.prefix_cache.cache_hit_rate.toFixed(1) + '%'"></dd></div><div class="flex justify-between"><dt>Read / write</dt><dd x-text="formatNumber(lifetimeStats.prefix_cache?.cache_read_tokens || 0) + ' / ' + formatNumber(lifetimeStats.prefix_cache?.cache_write_tokens || 0)"></dd></div><div class="flex justify-between"><dt>TTL 1h / 5m</dt><dd x-text="(lifetimeStats.prefix_cache?.ttl_1h_percent == null ? '—' : lifetimeStats.prefix_cache.ttl_1h_percent.toFixed(0) + '%') + ' / ' + (lifetimeStats.prefix_cache?.ttl_5m_percent == null ? '—' : lifetimeStats.prefix_cache.ttl_5m_percent.toFixed(0) + '%')"></dd></div><div class="flex justify-between"><dt>Cache bust</dt><dd x-text="formatNumber(lifetimeStats.prefix_cache?.bust_count || 0) + ' / ' + formatNumber(lifetimeStats.prefix_cache?.bust_tokens || 0)"></dd></div></dl></section>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Cache Miss Attribution</h3><template x-for="[name, count] in Object.entries(lifetimeStats.prefix_cache?.misses_by_reason || {})" :key="name"><div class="flex justify-between text-sm"><span x-text="name"></span><span x-text="formatNumber(count)"></span></div></template></section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Waste Signals</h3><template x-for="[name, count] in Object.entries(lifetimeStats.waste_signals || {})" :key="name"><div class="flex justify-between text-sm"><span x-text="name"></span><span x-text="formatNumber(count)"></span></div></template></section>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-3">
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Providers</h3><template x-for="[name, count] in Object.entries(lifetimeStats.requests?.by_provider || {})" :key="name"><div class="flex justify-between text-sm"><span x-text="name"></span><span x-text="formatNumber(count)"></span></div></template></section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Stacks</h3><template x-for="[name, count] in Object.entries(lifetimeStats.requests?.by_stack || {})" :key="name"><div class="flex justify-between text-sm"><span x-text="name"></span><span x-text="formatNumber(count)"></span></div></template></section>
|
||
<section class="bg-surface rounded-lg p-4 border border-border"><h3 class="mb-3 text-sm text-gray-300">Top Models + Other</h3><template x-for="[name, metric] in Object.entries(lifetimeStats.by_model || {})" :key="name"><div class="flex justify-between text-sm"><span class="truncate pr-3" x-text="name"></span><span x-text="formatNumber(metric.input_tokens + metric.output_tokens)"></span></div></template></section>
|
||
</div>
|
||
<!-- Per-Project Savings Breakdown -->
|
||
<section class="bg-surface rounded-lg border border-border overflow-hidden mt-6">
|
||
<div class="px-4 py-3 border-b border-border flex justify-between items-center">
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-300">Per-Project Savings</span>
|
||
<div class="text-xs text-gray-500 mt-0.5">Lifetime totals — attributed requests only</div>
|
||
</div>
|
||
<span class="text-xs text-gray-500 font-mono"
|
||
x-text="Object.keys(lifetimeStats.projects || {}).length + ' project(s)'"></span>
|
||
</div>
|
||
|
||
<template x-if="Object.keys(lifetimeStats.projects || {}).length === 0">
|
||
<div class="px-4 py-8 text-center">
|
||
<div class="text-xs text-gray-500 mb-3">No per-project data yet.</div>
|
||
<div class="text-xs text-gray-600 font-mono leading-relaxed">
|
||
Route project traffic through
|
||
<span class="text-accent" x-text="window.location.origin + '/p/<project-name>'"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="Object.keys(lifetimeStats.projects || {}).length > 0">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm" style="table-layout:fixed">
|
||
<colgroup>
|
||
<col style="width:25%">
|
||
<col style="width:10%">
|
||
<col style="width:15%">
|
||
<col style="width:12%">
|
||
<col style="width:18%">
|
||
<col style="width:20%">
|
||
</colgroup>
|
||
<thead class="text-xs text-gray-500 tracking-wide">
|
||
<tr>
|
||
<th class="px-4 py-3 font-medium text-left">Project</th>
|
||
<th class="px-4 py-3 font-medium text-right">Requests</th>
|
||
<th class="px-4 py-3 font-medium text-right">Tokens Saved</th>
|
||
<th class="px-4 py-3 font-medium text-right">Saved $</th>
|
||
<th class="px-4 py-3 font-medium text-right">Savings %</th>
|
||
<th class="px-4 py-3 font-medium text-right md:table-cell">Last Active</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-border">
|
||
<template x-for="[project, info] in Object.entries(lifetimeStats.projects || {})" :key="project">
|
||
<tr class="hover:bg-border/30 transition-colors">
|
||
<td class="px-4 py-3 text-left">
|
||
<span class="px-2 py-0.5 bg-border text-xs font-mono" x-text="project"></span>
|
||
</td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums"
|
||
x-text="formatNumber(info.requests || 0)"></td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums text-accent"
|
||
x-text="formatNumber(info.tokens_saved || 0)"></td>
|
||
<td class="px-4 py-3 text-right font-mono tabular-nums text-emerald-400"
|
||
x-text="'$' + formatCurrency(info.compression_savings_usd || 0)"></td>
|
||
<td class="px-4 py-3 text-right">
|
||
<div class="flex items-center justify-end gap-2">
|
||
<div class="w-16 h-1.5 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full bg-accent rounded-full"
|
||
:style="'width:' + Math.min(info.savings_percent || 0, 100) + '%'"></div>
|
||
</div>
|
||
<span class="text-accent font-mono tabular-nums text-xs w-10 text-right"
|
||
x-text="(info.savings_percent || 0).toFixed(1) + '%'"></span>
|
||
</div>
|
||
</td>
|
||
<td class="px-4 py-3 text-right text-xs text-gray-500 font-mono md:table-cell"
|
||
x-text="info.last_activity_at ? new Date(info.last_activity_at).toLocaleString() : '—'"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="viewMode === 'history'">
|
||
<div>
|
||
<div class="flex flex-col gap-4 mb-6 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<div class="text-sm font-medium text-gray-300">Historical Proxy Compression</div>
|
||
<div class="text-xs text-gray-500">
|
||
Durable local savings history from <span class="font-mono">/stats-history</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||
<div class="flex items-center gap-2">
|
||
<button class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
||
@click="downloadHistory('json')">
|
||
Export JSON
|
||
</button>
|
||
<button class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
||
@click="downloadHistory('csv')">
|
||
Export CSV
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Lifetime Compression Savings</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-emerald-400"
|
||
x-text="'$' + formatCurrency(historyStats.lifetime?.compression_savings_usd || 0)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500">
|
||
Proxy compression only
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Lifetime Tokens Saved</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-accent"
|
||
x-text="formatNumber(historyStats.lifetime?.tokens_saved || 0)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500"
|
||
x-text="formatNumber((historyStats.history || []).length) + ' recorded checkpoints'"></div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Active Days</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums"
|
||
x-text="formatNumber((historyStats.series?.daily || []).length)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500" x-text="historyWindowLabel"></div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Average Saved / Day</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums"
|
||
x-text="formatNumber(historyAverageTokensPerDay)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500">Based on persisted daily buckets</div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Average Saved / Week</div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums"
|
||
x-text="formatNumber(historyAverageTokensPerWeek)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500">Based on persisted weekly buckets</div>
|
||
</div>
|
||
|
||
<template x-if="historyStats.cli_filtering && historyCliFilteringAvailable">
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1"
|
||
x-text="(historyStats.cli_filtering?.label || cliFilteringLabel) + ' Lifetime Saved'"></div>
|
||
<div class="flex items-baseline gap-2">
|
||
<span class="text-3xl font-light tabular-nums text-cyan-400"
|
||
x-text="formatNumber(historyStats.cli_filtering?.lifetime?.tokens_saved || 0)"></span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-500">CLI output filtering (lifetime)</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<template x-if="hasHistoricalData">
|
||
<div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Daily Savings</div>
|
||
<template x-if="(historyStats.series?.daily || []).length > 0">
|
||
<div class="space-y-3">
|
||
<template x-for="point in recentDailyHistory" :key="point.timestamp">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="text-sm text-gray-300" x-text="formatDate(point.timestamp)"></div>
|
||
<div class="text-xs text-gray-500"
|
||
x-text="formatNumber(point.total_tokens_saved) + ' cumulative'"></div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="font-mono text-sm text-accent"
|
||
x-text="formatNumber(point.tokens_saved) + ' tokens'"></div>
|
||
<div class="text-xs text-emerald-400"
|
||
x-text="'$' + formatCurrency(point.compression_savings_usd_delta || 0)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="(historyStats.series?.daily || []).length === 0">
|
||
<div class="text-sm text-gray-500 italic py-8 text-center">
|
||
Daily rollups will appear after persisted history spans at least one checkpoint.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Weekly Savings</div>
|
||
<template x-if="(historyStats.series?.weekly || []).length > 0">
|
||
<div class="space-y-3">
|
||
<template x-for="point in recentWeeklyHistory" :key="point.timestamp">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="text-sm text-gray-300" x-text="formatDate(point.timestamp)"></div>
|
||
<div class="text-xs text-gray-500"
|
||
x-text="formatNumber(point.total_tokens_saved) + ' cumulative'"></div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="font-mono text-sm text-accent"
|
||
x-text="formatNumber(point.tokens_saved) + ' tokens'"></div>
|
||
<div class="text-xs text-emerald-400"
|
||
x-text="'$' + formatCurrency(point.compression_savings_usd_delta || 0)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="(historyStats.series?.weekly || []).length === 0">
|
||
<div class="text-sm text-gray-500 italic py-8 text-center">
|
||
Weekly rollups will appear after persisted history spans multiple days.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Monthly Savings</div>
|
||
<template x-if="(historyStats.series?.monthly || []).length > 0">
|
||
<div class="space-y-3">
|
||
<template x-for="point in recentMonthlyHistory" :key="point.timestamp">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="text-sm text-gray-300" x-text="formatMonth(point.timestamp)"></div>
|
||
<div class="text-xs text-gray-500"
|
||
x-text="formatNumber(point.total_tokens_saved) + ' cumulative'"></div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="font-mono text-sm text-accent"
|
||
x-text="formatNumber(point.tokens_saved) + ' tokens'"></div>
|
||
<div class="text-xs text-emerald-400"
|
||
x-text="'$' + formatCurrency(point.compression_savings_usd_delta || 0)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="(historyStats.series?.monthly || []).length === 0">
|
||
<div class="text-sm text-gray-500 italic py-8 text-center">
|
||
Monthly rollups will appear after persisted history spans multiple months.
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||
<div class="bg-surface rounded-lg p-4 border border-border lg:col-span-2">
|
||
<div class="flex flex-col gap-3 mb-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm font-medium text-gray-300">Historical Savings Trend</span>
|
||
<span class="text-xs text-gray-500 font-mono" x-text="historyTrendLabel"></span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 self-start lg:self-auto">
|
||
<div class="inline-flex rounded-lg border border-border p-1" style="background: var(--card-alt-bg);">
|
||
<template x-for="[label, key] in historyGranularityOptions" :key="key">
|
||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||
:class="historyGranularity === key ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||
@click="historyGranularity = key"
|
||
x-text="label"></button>
|
||
</template>
|
||
</div>
|
||
<div class="inline-flex rounded-lg border border-border p-1" style="background: var(--card-alt-bg);">
|
||
<template x-for="[label, key] in historyChartModeOptions" :key="key">
|
||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||
:class="historyChartMode === key ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||
@click="historyChartMode = key"
|
||
x-text="label"></button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-xs text-gray-500 mb-4"
|
||
x-text="'Showing ' + formatNumber(historySelectedPointCount) + ' ' + historySelectedSeriesLabel.toLowerCase() + ' points'"></div>
|
||
<template x-if="historicalTrend.length >= 2">
|
||
<div class="h-48">
|
||
<svg class="w-full h-full" viewBox="0 0 200 64" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="history-trend-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:0.2"/>
|
||
<stop offset="100%" style="stop-color:#22d3ee;stop-opacity:0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<path :d="historyChartMode === 'tokens' && !historyActiveModel ? getObjectTrendArea(historicalTrend, 'total_tokens_saved') : ''"
|
||
fill="url(#history-trend-gradient)"></path>
|
||
<path class="trend-line"
|
||
:d="historyChartMode === 'tokens' && !historyActiveModel ? getObjectTrendLine(historicalTrend, 'total_tokens_saved') : ''"></path>
|
||
<path fill="none" stroke-width="1"
|
||
:stroke="historyModelColor(0)" :d="historyModelLine(0)"></path>
|
||
<path fill="none" stroke-width="1"
|
||
:stroke="historyModelColor(1)" :d="historyModelLine(1)"></path>
|
||
<path fill="none" stroke-width="1"
|
||
:stroke="historyModelColor(2)" :d="historyModelLine(2)"></path>
|
||
<path fill="none" stroke-width="1"
|
||
:stroke="historyModelColor(3)" :d="historyModelLine(3)"></path>
|
||
<path fill="none" stroke-width="1"
|
||
:stroke="historyModelColor(4)" :d="historyModelLine(4)"></path>
|
||
<path fill="none" stroke="#22d3ee" stroke-width="1.5"
|
||
:d="historyCostLine('actual')"></path>
|
||
<path fill="none" stroke="#fbbf24" stroke-width="1.5" stroke-dasharray="3 2"
|
||
:d="historyCostLine('expected')"></path>
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
<template x-if="historicalTrend.length < 2">
|
||
<div class="h-48 flex items-center justify-center text-sm text-gray-500 italic">
|
||
Historical trend data will appear after more saved checkpoints in this granularity.
|
||
</div>
|
||
</template>
|
||
<div class="flex flex-wrap gap-3 mt-3"
|
||
x-show="historyChartMode === 'tokens' && historyGranularity !== 'history' && historyModelChartSeries.length > 0">
|
||
<template x-for="(series, index) in historyModelChartSeries" :key="series.model">
|
||
<button class="flex items-center gap-1.5 text-xs transition-opacity cursor-pointer"
|
||
:class="historyActiveModel && historyActiveModel !== series.model
|
||
? 'text-gray-600 opacity-50'
|
||
: 'text-gray-400 hover:text-gray-200'"
|
||
@click="toggleHistoryModel(series.model)"
|
||
:title="historySelectedModel === series.model
|
||
? 'Show all models'
|
||
: 'Show only this model'">
|
||
<span class="inline-block w-2 h-2 rounded-full"
|
||
:style="'background:' + historyModelColor(index)"></span>
|
||
<span :class="historySelectedModel === series.model ? 'underline' : ''"
|
||
x-text="truncateModel(series.model)"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<div class="flex flex-wrap gap-4 mt-3" x-show="historyChartMode === 'cost'">
|
||
<span class="flex items-center gap-1.5 text-xs text-gray-400">
|
||
<span class="inline-block w-3 h-0.5" style="background:#22d3ee"></span>
|
||
Actual cost (with Headroom)
|
||
</span>
|
||
<span class="flex items-center gap-1.5 text-xs text-gray-400">
|
||
<span class="inline-block w-3 h-0.5"
|
||
style="background:repeating-linear-gradient(90deg,#fbbf24 0 4px,transparent 4px 7px)"></span>
|
||
Expected cost (without Headroom)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<span class="text-sm font-medium text-gray-300">Per-Model Breakdown</span>
|
||
<span class="text-xs text-gray-500 font-mono"
|
||
x-text="historyModelSourceSeriesLabel + ' buckets'"></span>
|
||
</div>
|
||
<template x-if="historyModelBreakdown.length === 0">
|
||
<div class="text-sm text-gray-500 italic">
|
||
Per-model attribution appears for checkpoints recorded after upgrading.
|
||
</div>
|
||
</template>
|
||
<template x-if="historyModelBreakdown.length > 0">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="text-xs text-gray-500 text-left">
|
||
<th class="py-2 pr-4 font-medium">Model</th>
|
||
<th class="py-2 pr-4 font-medium text-right">Tokens saved</th>
|
||
<th class="py-2 pr-4 font-medium text-right">Cost with Headroom</th>
|
||
<th class="py-2 pr-4 font-medium text-right">Expected cost without Headroom</th>
|
||
<th class="py-2 font-medium text-right">Saved</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="row in historyModelBreakdown" :key="row.model">
|
||
<tr class="border-t border-border text-gray-300 cursor-pointer transition-colors"
|
||
:class="historySelectedModel === row.model ? 'bg-card-alt' : 'hover-bg-surface'"
|
||
@click="toggleHistoryModel(row.model)"
|
||
:title="historySelectedModel === row.model
|
||
? 'Show all models'
|
||
: 'Show only this model in the chart'">
|
||
<td class="py-2 pr-4 font-mono text-xs"
|
||
:class="historySelectedModel === row.model ? 'underline' : ''"
|
||
x-text="truncateModel(row.model)"></td>
|
||
<td class="py-2 pr-4 text-right tabular-nums" x-text="formatNumber(row.tokens_saved)"></td>
|
||
<td class="py-2 pr-4 text-right tabular-nums" x-text="'$' + formatCurrency(row.input_cost_usd)"></td>
|
||
<td class="py-2 pr-4 text-right tabular-nums" x-text="'$' + formatCurrency(row.expected_cost_usd)"></td>
|
||
<td class="py-2 text-right tabular-nums text-accent" x-text="'$' + formatCurrency(row.savings_usd)"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Historical Summary</div>
|
||
<div class="space-y-3">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Latest total</span>
|
||
<span class="font-mono text-sm"
|
||
x-text="formatNumber(historyStats.lifetime?.tokens_saved || 0) + ' tokens'"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Selected series</span>
|
||
<span class="font-mono text-sm"
|
||
x-text="historySelectedSeriesLabel"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Selected points</span>
|
||
<span class="font-mono text-sm"
|
||
x-text="formatNumber(historySelectedPointCount)"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Average / month</span>
|
||
<span class="font-mono text-sm"
|
||
x-text="formatNumber(historyAverageTokensPerMonth) + ' tokens'"></span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-gray-400">Retention</span>
|
||
<span class="font-mono text-sm"
|
||
x-text="(historyStats.retention?.max_history_age_days || 0) + 'd / ' + formatNumber(historyStats.retention?.max_history_points || 0)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-surface rounded-lg p-4 border border-border">
|
||
<div class="text-sm font-medium mb-4 text-gray-300">Recent Historical Checkpoints</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<template x-for="point in recentHistoricalPoints" :key="point.timestamp">
|
||
<div class="flex items-center justify-between rounded-lg border border-border px-3 py-3">
|
||
<div>
|
||
<div class="text-sm text-gray-300" x-text="formatDateTime(point.timestamp)"></div>
|
||
<div class="text-xs text-gray-500">Cumulative proxy compression savings</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="font-mono text-sm text-accent"
|
||
x-text="formatNumber(point.total_tokens_saved) + ' tokens'"></div>
|
||
<div class="text-xs text-emerald-400"
|
||
x-text="'$' + formatCurrency(point.compression_savings_usd || 0)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="!hasHistoricalData">
|
||
<div class="bg-surface rounded-lg border border-border p-10 text-center">
|
||
<div class="text-lg text-gray-300 mb-2">No persisted savings history yet</div>
|
||
<div class="text-sm text-gray-500 max-w-2xl mx-auto">
|
||
Historical data is written locally after proxy requests save tokens. Keep using Headroom and this view will fill in automatically across restarts.
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
</main>
|
||
|
||
<!-- Live Feed Sidebar Drawer -->
|
||
<div x-show="feedOpen"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="translate-x-full"
|
||
x-transition:enter-end="translate-x-0"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="translate-x-0"
|
||
x-transition:leave-end="translate-x-full"
|
||
@click.away="feedOpen = false"
|
||
class="fixed top-0 right-0 h-full w-[520px] bg-surface border-l border-border z-50 flex flex-col"
|
||
style="display: none;">
|
||
|
||
<!-- Drawer Header -->
|
||
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm font-medium text-gray-200">Message Transformations</span>
|
||
<span class="text-xs text-gray-500 font-mono" x-text="transformations.length + ' msgs'"></span>
|
||
</div>
|
||
<button @click="feedOpen = false" class="text-gray-500 hover:text-gray-200 transition-colors p-1">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 16 16" fill="currentColor">
|
||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- New messages indicator -->
|
||
<div x-show="feedScrolled_ && feedNewCount > 0"
|
||
@click="scrollToFeedTop()"
|
||
class="absolute top-16 right-4 px-3 py-2 bg-accent text-black text-sm rounded-lg shadow-lg cursor-pointer z-50 font-medium">
|
||
<span x-text="feedNewCount"></span> new message<span x-show="feedNewCount > 1">s</span>
|
||
</div>
|
||
|
||
<!-- Drawer Content (virtual scroll container) -->
|
||
<div class="flex-1 overflow-y-auto" id="feed-container"
|
||
@scroll="handleFeedScroll()">
|
||
<div id="feed-virtual-list" class="relative"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<footer class="border-t border-border px-6 py-4 mt-8">
|
||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||
<div>
|
||
Press <kbd class="px-1.5 py-0.5 bg-border rounded text-gray-400">R</kbd> to refresh
|
||
</div>
|
||
<div>
|
||
<a href="https://headroom-docs.vercel.app/docs" target="_blank" class="hover:text-gray-300 transition-colors">Documentation</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<script>
|
||
function dashboard() {
|
||
return {
|
||
stats: {},
|
||
lifetimeStats: {},
|
||
historyStats: {},
|
||
healthy: true,
|
||
version: 'loading',
|
||
lastUpdate: 'never',
|
||
viewMode: 'session',
|
||
historyGranularity: 'daily',
|
||
historyChartMode: 'tokens',
|
||
historySelectedModel: null,
|
||
requestHistory: [],
|
||
savingsHistory: [],
|
||
expandedRows: {},
|
||
pollInterval: null,
|
||
statsPollMs: 5000,
|
||
lifetimePollMs: 30000,
|
||
historyPollMs: 30000,
|
||
feedPollMs: 5000,
|
||
lastLifetimeFetchMs: 0,
|
||
lastHistoryFetchMs: 0,
|
||
lastFeedFetchMs: 0,
|
||
feedOpen: false,
|
||
transformations: [],
|
||
feedScrolled_: false,
|
||
feedNewCount: 0,
|
||
feedScrollY: 0,
|
||
feedItemHeight: 160,
|
||
feedBuffer: 5,
|
||
log_full_messages: false,
|
||
|
||
async init() {
|
||
await this.fetchStats();
|
||
|
||
this.pollInterval = setInterval(() => {
|
||
this.pollDashboard();
|
||
}, this.statsPollMs);
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'r' || e.key === 'R') {
|
||
this.pollDashboard(true);
|
||
}
|
||
});
|
||
},
|
||
|
||
async pollDashboard(force = false) {
|
||
if (!force && document.hidden) return;
|
||
|
||
await this.fetchStats();
|
||
|
||
const now = Date.now();
|
||
if (this.viewMode === 'lifetime' && (force || now - this.lastLifetimeFetchMs >= this.lifetimePollMs)) {
|
||
await this.fetchLifetimeStats();
|
||
}
|
||
if (this.viewMode === 'history' && (force || now - this.lastHistoryFetchMs >= this.historyPollMs)) {
|
||
await this.fetchHistoryStats();
|
||
}
|
||
if (this.feedOpen && (force || now - this.lastFeedFetchMs >= this.feedPollMs)) {
|
||
await this.fetchTransformations();
|
||
}
|
||
},
|
||
|
||
async setViewMode(mode) {
|
||
this.viewMode = mode;
|
||
if (mode === 'lifetime') {
|
||
await this.fetchLifetimeStats();
|
||
}
|
||
if (mode === 'history') {
|
||
await this.fetchHistoryStats();
|
||
}
|
||
},
|
||
|
||
async toggleFeed() {
|
||
this.feedOpen = !this.feedOpen;
|
||
if (this.feedOpen) {
|
||
await this.fetchTransformations();
|
||
}
|
||
},
|
||
|
||
formatVersion(value) {
|
||
const label = String(value || 'unknown').trim();
|
||
if (label === 'loading' || label === 'unknown') return label;
|
||
return /^\d+\.\d+\.\d+$/.test(label) ? 'v' + label : label;
|
||
},
|
||
|
||
async fetchStats() {
|
||
try {
|
||
const [statsRes, healthRes] = await Promise.all([
|
||
fetch('/stats?cached=1'),
|
||
fetch('/health')
|
||
]);
|
||
|
||
this.stats = await statsRes.json();
|
||
const health = await healthRes.json();
|
||
this.healthy = health.status === 'healthy';
|
||
this.version = health.version || 'unknown';
|
||
this.log_full_messages = this.stats.log_full_messages || false;
|
||
|
||
// Update history for sparklines
|
||
this.requestHistory.push(this.stats.requests?.total || 0);
|
||
this.savingsHistory.push(this.stats.tokens?.saved || 0);
|
||
|
||
// Keep last 30 points
|
||
if (this.requestHistory.length > 30) this.requestHistory.shift();
|
||
if (this.savingsHistory.length > 30) this.savingsHistory.shift();
|
||
|
||
this.lastUpdate = new Date().toLocaleTimeString();
|
||
} catch (e) {
|
||
console.error('Failed to fetch stats:', e);
|
||
this.healthy = false;
|
||
}
|
||
},
|
||
|
||
async fetchLifetimeStats() {
|
||
try {
|
||
const response = await fetch('/stats-lifetime');
|
||
if (response.ok) {
|
||
this.lifetimeStats = await response.json();
|
||
this.lastLifetimeFetchMs = Date.now();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch lifetime stats:', e);
|
||
}
|
||
},
|
||
async fetchHistoryStats() {
|
||
try {
|
||
const response = await fetch('/stats-history');
|
||
if (response.ok) {
|
||
this.historyStats = await response.json();
|
||
this.lastHistoryFetchMs = Date.now();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch history stats:', e);
|
||
}
|
||
},
|
||
|
||
async fetchTransformations() {
|
||
try {
|
||
const prevLen = this.transformations.length;
|
||
const response = await fetch('/transformations/feed?limit=50');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const newLen = (data.transformations || []).length;
|
||
if (this.feedScrolled_ && newLen > prevLen) {
|
||
this.feedNewCount = newLen - prevLen;
|
||
}
|
||
this.transformations = data.transformations || [];
|
||
this.log_full_messages = data.log_full_messages ?? this.log_full_messages;
|
||
this.lastFeedFetchMs = Date.now();
|
||
this.renderTransformations();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch transformations:', e);
|
||
}
|
||
},
|
||
|
||
scrollToFeedTop() {
|
||
const container = document.getElementById('feed-container');
|
||
if (container) {
|
||
container.scrollTop = 0;
|
||
this.feedScrolled_ = false;
|
||
this.feedNewCount = 0;
|
||
this.renderTransformations();
|
||
}
|
||
},
|
||
|
||
handleFeedScroll() {
|
||
const container = document.getElementById('feed-container');
|
||
if (!container) return;
|
||
|
||
this.feedScrollY = container.scrollTop;
|
||
this.feedScrolled_ = container.scrollTop > 50;
|
||
this.renderTransformations();
|
||
},
|
||
|
||
renderTransformations() {
|
||
const container = document.getElementById('feed-virtual-list');
|
||
if (!container) return;
|
||
|
||
const scrollTop = this.feedScrollY;
|
||
const viewportHeight = (document.getElementById('feed-container')?.clientHeight || 600);
|
||
|
||
const totalHeight = this.transformations.length * this.feedItemHeight;
|
||
container.style.height = totalHeight + 'px';
|
||
|
||
// Calculate visible range with buffer
|
||
const startIdx = Math.max(0, Math.floor(scrollTop / this.feedItemHeight) - this.feedBuffer);
|
||
const endIdx = Math.min(
|
||
this.transformations.length,
|
||
Math.ceil((scrollTop + viewportHeight) / this.feedItemHeight) + this.feedBuffer
|
||
);
|
||
|
||
const visible = this.transformations.slice(startIdx, endIdx);
|
||
const offsetTop = startIdx * this.feedItemHeight;
|
||
|
||
let html = `<div style="position: absolute; top: ${offsetTop}px; width: 100%;">`;
|
||
html += visible.map((t, i) => this.renderTransformationCard(t, startIdx + i)).join('');
|
||
html += '</div>';
|
||
|
||
container.innerHTML = html;
|
||
},
|
||
|
||
renderTransformationCard(t, idx) {
|
||
const msgs = (t.request_messages || []).map(m => m.content || '').join('');
|
||
const response = t.response_content || '';
|
||
const hasMessages = msgs.length > 0 || response.length > 0;
|
||
const before = msgs.substring(0, 2000) + (msgs.length > 2000 ? '\n\n[truncated]' : '');
|
||
const after = response.substring(0, 2000) + (response.length > 2000 ? '\n\n[truncated]' : '');
|
||
|
||
const time = t.timestamp ? new Date(t.timestamp).toLocaleTimeString() : '--:--:--';
|
||
const model = (t.model || 'unknown').replace(/^(anthropic\.|openai\.)/, '').substring(0, 25);
|
||
const tokensSaved = t.tokens_saved || 0;
|
||
const savingsPct = ((t.savings_percent || 0)).toFixed(0);
|
||
|
||
const emptyState = '<span class="text-gray-600 italic">Enable HEADROOM_LOG_MESSAGES=true to see content</span>';
|
||
const beforeContent = hasMessages ? this.escapeHtml(before) : emptyState;
|
||
const afterContent = hasMessages ? this.escapeHtml(after) : emptyState;
|
||
|
||
return `
|
||
<div class="transformation-card border-b border-border p-3" data-idx="${idx}" style="height: ${this.feedItemHeight}px; box-sizing: border-box;">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<span class="text-xs font-mono text-gray-400 truncate">${this.escapeHtml(model)}</span>
|
||
<span class="text-xs text-gray-600 shrink-0">·</span>
|
||
<span class="text-xs text-emerald-400 shrink-0">${tokensSaved} tok</span>
|
||
<span class="text-xs text-gray-600 shrink-0">(${savingsPct}%)</span>
|
||
${(t.tool_schema_saved_tokens || 0) > 0 ? `<span class="text-xs text-cyan-400 shrink-0" title="Tool-definition tokens deferred out of context">+${t.tool_schema_saved_tokens} tool-schema</span>` : ''}
|
||
</div>
|
||
<span class="text-xs text-gray-600 shrink-0">${time}</span>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2" style="height: 115px;">
|
||
<div class="rounded border border-red-900/30 overflow-hidden flex flex-col">
|
||
<div class="diff-before-header px-2 py-1 border-b border-red-900/30 shrink-0">
|
||
<span class="text-[10px] text-red-400 uppercase tracking-wide font-semibold">Before</span>
|
||
</div>
|
||
<div class="diff-before p-2 font-mono text-[11px] text-gray-300 overflow-auto flex-1"
|
||
>${beforeContent}</div>
|
||
</div>
|
||
<div class="rounded border border-emerald-900/30 overflow-hidden flex flex-col">
|
||
<div class="diff-after-header px-2 py-1 border-b border-emerald-900/30 shrink-0">
|
||
<span class="text-[10px] text-emerald-400 uppercase tracking-wide font-semibold">After</span>
|
||
</div>
|
||
<div class="diff-after p-2 font-mono text-[11px] text-gray-300 overflow-auto flex-1"
|
||
>${afterContent}</div>
|
||
</div>
|
||
</div>
|
||
${(t.transforms_applied || []).length > 0 ? `
|
||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||
${t.transforms_applied.slice(0, 4).map(tr => `<span class="text-[9px] px-1.5 py-0.5 bg-border rounded font-mono text-gray-500">${this.escapeHtml(tr)}</span>`).join('')}
|
||
${t.transforms_applied.length > 4 ? `<span class="text-[9px] text-gray-600">+${t.transforms_applied.length - 4}</span>` : ''}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
},
|
||
|
||
// --- Formatting ---
|
||
|
||
formatNumber(n) {
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||
return n.toString();
|
||
},
|
||
|
||
formatCurrency(n) {
|
||
if (n < 0) return '-' + this.formatCurrency(-n);
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||
if (n >= 1) return n.toFixed(2);
|
||
if (n >= 0.01) return n.toFixed(3);
|
||
if (n > 0) return n.toFixed(4);
|
||
return '0.00';
|
||
},
|
||
|
||
formatResetTime(seconds) {
|
||
if (seconds == null || seconds <= 0) return 'now';
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
if (h > 0) return h + 'h ' + m + 'm';
|
||
if (m > 0) return m + 'm ' + Math.floor(seconds % 60) + 's';
|
||
return Math.floor(seconds) + 's';
|
||
},
|
||
|
||
formatTime(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
const now = new Date();
|
||
const diff = (now - d) / 1000;
|
||
if (diff < 60) return Math.floor(diff) + 's ago';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
return d.toLocaleTimeString();
|
||
},
|
||
|
||
formatDate(ts) {
|
||
if (!ts) return '-';
|
||
return new Date(ts).toLocaleDateString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
});
|
||
},
|
||
|
||
formatMonthlyReset(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const d = new Date(dateStr);
|
||
const days = Math.ceil((d - new Date()) / 86400000);
|
||
if (days <= 0) return 'today';
|
||
if (days === 1) return 'tomorrow';
|
||
return 'in ' + days + ' days (' + d.toLocaleDateString(undefined, {month: 'short', day: 'numeric'}) + ')';
|
||
},
|
||
|
||
formatMonth(ts) {
|
||
if (!ts) return '-';
|
||
return new Date(ts).toLocaleDateString(undefined, {
|
||
month: 'short',
|
||
year: 'numeric',
|
||
});
|
||
},
|
||
|
||
formatDateTime(ts) {
|
||
if (!ts) return '-';
|
||
return new Date(ts).toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
},
|
||
|
||
truncateModel(model) {
|
||
if (!model) return '-';
|
||
return model.replace(/^(anthropic\.|openai\.|bedrock\/)/, '')
|
||
.replace(/-\d{8}$/, '')
|
||
.substring(0, 20);
|
||
},
|
||
|
||
// --- Agent Usage ---
|
||
|
||
get agentRows() {
|
||
return this.stats.agent_usage?.agents || [];
|
||
},
|
||
|
||
get agentCoverageLabel() {
|
||
const coverage = this.stats.agent_usage?.coverage || {};
|
||
if (coverage.mode === 'request_logs') {
|
||
return this.formatNumber(coverage.logged_requests || 0) + ' logged requests';
|
||
}
|
||
return 'aggregate fallback';
|
||
},
|
||
|
||
agentSavedWidth(agent) {
|
||
const before = agent.before_tokens || 0;
|
||
if (before <= 0) return 0;
|
||
return Math.min(100, Math.max(0, (agent.tokens_saved || 0) / before * 100)).toFixed(1);
|
||
},
|
||
|
||
agentAfterWidth(agent) {
|
||
const before = agent.before_tokens || 0;
|
||
if (before <= 0) return 0;
|
||
return Math.min(100, Math.max(0, (agent.after_tokens || 0) / before * 100)).toFixed(1);
|
||
},
|
||
|
||
agentDotClass(agent) {
|
||
const colors = {
|
||
'claude-code': 'bg-orange-400',
|
||
claude: 'bg-orange-400',
|
||
codex: 'bg-emerald-400',
|
||
cursor: 'bg-cyan-400',
|
||
copilot: 'bg-violet-400',
|
||
openai: 'bg-sky-400',
|
||
anthropic: 'bg-orange-400',
|
||
gemini: 'bg-rose-400',
|
||
aider: 'bg-amber-400',
|
||
unknown: 'bg-gray-500',
|
||
};
|
||
return colors[agent] || 'bg-gray-400';
|
||
},
|
||
|
||
// --- Historical View ---
|
||
|
||
get historyGranularityOptions() {
|
||
return [
|
||
['Daily', 'daily'],
|
||
['Weekly', 'weekly'],
|
||
['Monthly', 'monthly'],
|
||
['Checkpoints', 'history'],
|
||
];
|
||
},
|
||
|
||
get historyChartModeOptions() {
|
||
return [
|
||
['Tokens', 'tokens'],
|
||
['Cost', 'cost'],
|
||
];
|
||
},
|
||
|
||
get historyModelSourceSeries() {
|
||
// Rollup buckets carrying by_model attribution. Raw checkpoints
|
||
// have no by_model, so the checkpoint view falls back to daily.
|
||
const key = this.historySelectedSeriesKey === 'history'
|
||
? 'daily'
|
||
: this.historySelectedSeriesKey;
|
||
return this.historyStats.series?.[key] || [];
|
||
},
|
||
|
||
get historyModelChartSeries() {
|
||
const buckets = this.historyModelSourceSeries;
|
||
const totals = {};
|
||
for (const bucket of buckets) {
|
||
for (const [model, entry] of Object.entries(bucket.by_model || {})) {
|
||
totals[model] = (totals[model] || 0) + (entry.tokens_saved || 0);
|
||
}
|
||
}
|
||
const topModels = Object.entries(totals)
|
||
.filter(([, saved]) => saved > 0)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.map(([model]) => model);
|
||
// A breakdown-row selection outside the top 5 takes the
|
||
// last chart slot so the filter works for every row (the
|
||
// template renders a fixed set of line slots).
|
||
const selected = this.historySelectedModel;
|
||
if (
|
||
selected &&
|
||
(totals[selected] || 0) > 0 &&
|
||
topModels.length > 0 &&
|
||
!topModels.includes(selected)
|
||
) {
|
||
topModels[topModels.length - 1] = selected;
|
||
}
|
||
return topModels.map(model => {
|
||
let running = 0;
|
||
return {
|
||
model,
|
||
values: buckets.map(bucket => {
|
||
running += bucket.by_model?.[model]?.tokens_saved || 0;
|
||
return running;
|
||
}),
|
||
};
|
||
});
|
||
},
|
||
|
||
get historyModelBreakdown() {
|
||
const totals = {};
|
||
for (const bucket of this.historyModelSourceSeries) {
|
||
for (const [model, entry] of Object.entries(bucket.by_model || {})) {
|
||
const row = totals[model] || (totals[model] = {
|
||
model,
|
||
tokens_saved: 0,
|
||
savings_usd: 0,
|
||
input_cost_usd: 0,
|
||
});
|
||
row.tokens_saved += entry.tokens_saved || 0;
|
||
row.savings_usd += entry.compression_savings_usd_delta || 0;
|
||
row.input_cost_usd += entry.total_input_cost_usd_delta || 0;
|
||
}
|
||
}
|
||
return Object.values(totals)
|
||
.map(row => ({
|
||
...row,
|
||
expected_cost_usd: row.input_cost_usd + row.savings_usd,
|
||
}))
|
||
.sort((a, b) => b.tokens_saved - a.tokens_saved);
|
||
},
|
||
|
||
get historyActiveModel() {
|
||
// A model filter only applies while that model is present in
|
||
// the charted series; otherwise fall back to showing all.
|
||
// Raw checkpoint view plots no per-model lines (they are
|
||
// derived from rollup buckets), so the filter must not
|
||
// suppress the aggregate line there.
|
||
if (this.historySelectedSeriesKey === 'history') return null;
|
||
const model = this.historySelectedModel;
|
||
if (!model) return null;
|
||
return this.historyModelChartSeries.some(series => series.model === model)
|
||
? model
|
||
: null;
|
||
},
|
||
|
||
toggleHistoryModel(model) {
|
||
this.historySelectedModel = this.historySelectedModel === model ? null : model;
|
||
},
|
||
|
||
get historyCostTrend() {
|
||
return this.historicalTrend.map(point => {
|
||
const actual = point?.total_input_cost_usd || 0;
|
||
const saved = point?.compression_savings_usd || 0;
|
||
return { actual, expected: actual + saved };
|
||
});
|
||
},
|
||
|
||
historyModelColor(index) {
|
||
const palette = ['#a78bfa', '#34d399', '#fbbf24', '#f87171', '#60a5fa'];
|
||
return palette[index % palette.length];
|
||
},
|
||
|
||
historyModelLine(index) {
|
||
if (this.historyChartMode !== 'tokens') return '';
|
||
// Checkpoint view plots raw checkpoints; daily-derived model
|
||
// lines would not share its x-axis.
|
||
if (this.historySelectedSeriesKey === 'history') return '';
|
||
const allSeries = this.historyModelChartSeries;
|
||
const series = allSeries[index];
|
||
if (!series || series.values.length < 2) return '';
|
||
const activeModel = this.historyActiveModel;
|
||
if (activeModel && series.model !== activeModel) return '';
|
||
// A single filtered model gets its own scale; the full set
|
||
// shares one so the lines stay comparable.
|
||
const scaleSeries = activeModel ? [series] : allSeries;
|
||
const max = Math.max(...scaleSeries.flatMap(s => s.values), 1);
|
||
return this.buildTrendPath(series.values, 0, max);
|
||
},
|
||
|
||
historyCostLine(kind) {
|
||
if (this.historyChartMode !== 'cost') return '';
|
||
const trend = this.historyCostTrend;
|
||
if (trend.length < 2) return '';
|
||
const all = trend.flatMap(point => [point.actual, point.expected]);
|
||
const min = Math.min(...all);
|
||
const max = Math.max(...all);
|
||
return this.buildTrendPath(trend.map(point => point[kind]), min, max);
|
||
},
|
||
|
||
buildTrendPath(values, min, max) {
|
||
if (!values || values.length < 2) return '';
|
||
const range = max - min || 1;
|
||
const points = values.map((value, index) => {
|
||
const x = (index / (values.length - 1)) * 200;
|
||
const y = 60 - ((value - min) / range) * 56;
|
||
return `${x},${y}`;
|
||
});
|
||
return 'M' + points.join(' L');
|
||
},
|
||
|
||
get hasHistoricalData() {
|
||
return (this.historyStats.history || []).length > 0;
|
||
},
|
||
|
||
get historySelectedSeriesKey() {
|
||
return this.historyGranularity === 'history' ? 'history' : this.historyGranularity;
|
||
},
|
||
|
||
get historySelectedSeriesLabel() {
|
||
const labels = {
|
||
history: 'Checkpoints',
|
||
daily: 'Daily',
|
||
weekly: 'Weekly',
|
||
monthly: 'Monthly',
|
||
};
|
||
return labels[this.historySelectedSeriesKey] || 'History';
|
||
},
|
||
|
||
get historyModelSourceSeriesLabel() {
|
||
// historyModelSourceSeries substitutes the daily rollup
|
||
// at raw checkpoint granularity; label what is shown.
|
||
return this.historySelectedSeriesKey === 'history'
|
||
? 'Daily'
|
||
: this.historySelectedSeriesLabel;
|
||
},
|
||
|
||
get historySelectedPointCount() {
|
||
if (this.historySelectedSeriesKey === 'history') {
|
||
return (this.historyStats.history || []).length;
|
||
}
|
||
return (this.historyStats.series?.[this.historySelectedSeriesKey] || []).length;
|
||
},
|
||
|
||
get historicalTrend() {
|
||
if (this.historySelectedSeriesKey === 'history') {
|
||
return this.historyStats.history || [];
|
||
}
|
||
return this.historyStats.series?.[this.historySelectedSeriesKey] || [];
|
||
},
|
||
|
||
get historyTrendLabel() {
|
||
const labels = {
|
||
history: 'Checkpoint history',
|
||
daily: 'Daily cumulative savings',
|
||
weekly: 'Weekly cumulative savings',
|
||
monthly: 'Monthly cumulative savings',
|
||
};
|
||
return labels[this.historySelectedSeriesKey] || 'Historical savings';
|
||
},
|
||
|
||
get historyWindowLabel() {
|
||
const history = this.historyStats.history || [];
|
||
if (history.length === 0) return 'Waiting for saved requests';
|
||
const first = history[0]?.timestamp;
|
||
const last = history[history.length - 1]?.timestamp;
|
||
if (!first || !last) return 'Persisted locally';
|
||
return this.formatDate(first) + ' to ' + this.formatDate(last);
|
||
},
|
||
|
||
get historyAverageTokensPerDay() {
|
||
const daily = this.historyStats.series?.daily || [];
|
||
const lifetime = this.historyStats.lifetime?.tokens_saved || 0;
|
||
if (daily.length === 0) return 0;
|
||
return Math.round(lifetime / daily.length);
|
||
},
|
||
|
||
get historyAverageTokensPerWeek() {
|
||
const weekly = this.historyStats.series?.weekly || [];
|
||
const lifetime = this.historyStats.lifetime?.tokens_saved || 0;
|
||
if (weekly.length === 0) return 0;
|
||
return Math.round(lifetime / weekly.length);
|
||
},
|
||
|
||
get historyAverageTokensPerMonth() {
|
||
const monthly = this.historyStats.series?.monthly || [];
|
||
const lifetime = this.historyStats.lifetime?.tokens_saved || 0;
|
||
if (monthly.length === 0) return 0;
|
||
return Math.round(lifetime / monthly.length);
|
||
},
|
||
|
||
get recentDailyHistory() {
|
||
return [...(this.historyStats.series?.daily || [])].slice(-7).reverse();
|
||
},
|
||
|
||
get recentWeeklyHistory() {
|
||
return [...(this.historyStats.series?.weekly || [])].slice(-6).reverse();
|
||
},
|
||
|
||
get recentMonthlyHistory() {
|
||
return [...(this.historyStats.series?.monthly || [])].slice(-6).reverse();
|
||
},
|
||
|
||
get recentHistoricalPoints() {
|
||
return [...(this.historyStats.history || [])].slice(-8).reverse();
|
||
},
|
||
|
||
async downloadHistory(format = 'json', series = null) {
|
||
const selectedSeries = series || this.historySelectedSeriesKey;
|
||
const params = new URLSearchParams({ format, series: selectedSeries });
|
||
if (format === 'json' && selectedSeries === 'history') {
|
||
params.set('history_mode', 'full');
|
||
}
|
||
const response = await fetch('/stats-history?' + params.toString());
|
||
if (!response.ok) throw new Error('Failed to export history');
|
||
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `headroom-stats-history-${selectedSeries}.${format}`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
},
|
||
|
||
// --- Prefix Cache ---
|
||
|
||
get cacheSessionActive() {
|
||
return (this.stats.prefix_cache?.totals?.requests || 0) > 0;
|
||
},
|
||
|
||
get cacheSavingsPercent() {
|
||
const t = this.stats.prefix_cache?.totals || {};
|
||
const total = (t.cache_read_tokens || 0) + (t.cache_write_tokens || 0);
|
||
if (total === 0) return 0;
|
||
return Math.round((t.cache_read_tokens || 0) / total * 100);
|
||
},
|
||
|
||
get cacheWritePercent() {
|
||
const t = this.stats.prefix_cache?.totals || {};
|
||
const total = (t.cache_read_tokens || 0) + (t.cache_write_tokens || 0);
|
||
if (total === 0) return 0;
|
||
return Math.round((t.cache_write_tokens || 0) / total * 100);
|
||
},
|
||
|
||
get hasObservedTtlBuckets() {
|
||
const buckets = this.stats.prefix_cache?.totals?.observed_ttl_buckets || {};
|
||
return ((buckets['5m']?.tokens || 0) + (buckets['1h']?.tokens || 0)) > 0;
|
||
},
|
||
|
||
get observedTtlHeadline() {
|
||
const mix = this.stats.prefix_cache?.totals?.observed_ttl_mix || {};
|
||
const oneHour = mix['1h_pct'] || 0;
|
||
const fiveMinute = mix['5m_pct'] || 0;
|
||
if (oneHour === fiveMinute) return 'Balanced';
|
||
return oneHour > fiveMinute ? '1h leaning' : '5m leaning';
|
||
},
|
||
|
||
get observedTtlWindowLabel() {
|
||
const mix = this.stats.prefix_cache?.totals?.observed_ttl_mix || {};
|
||
const active = mix.active_buckets || [];
|
||
if (!active.length) return 'No TTL bucket data';
|
||
return active.length === 1 ? active[0] + ' only' : active.join(' / ');
|
||
},
|
||
|
||
get compressionVsCacheNet() {
|
||
const cvc = this.stats.prefix_cache?.compression_vs_cache || {};
|
||
return cvc.net_tokens ?? ((cvc.tokens_saved_by_compression || 0) - (cvc.tokens_lost_to_cache_bust || 0));
|
||
},
|
||
|
||
get prefixFreezeNet() {
|
||
const pf = this.stats.prefix_cache?.prefix_freeze || {};
|
||
return pf.net_benefit_tokens ?? ((pf.tokens_preserved || 0) - (pf.compression_foregone_tokens || 0));
|
||
},
|
||
|
||
get hasCompressionVsCache() {
|
||
const cvc = this.stats.prefix_cache?.compression_vs_cache || {};
|
||
const pf = this.stats.prefix_cache?.prefix_freeze || {};
|
||
return (cvc.tokens_saved_by_compression || 0) > 0
|
||
|| (cvc.tokens_lost_to_cache_bust || 0) > 0
|
||
|| (pf.tokens_preserved || 0) > 0
|
||
|| (pf.compression_foregone_tokens || 0) > 0;
|
||
},
|
||
|
||
// --- Cache miss attribution (#1313) ---
|
||
|
||
get missAttribution() {
|
||
return this.stats.prefix_cache?.miss_attribution?.totals || {};
|
||
},
|
||
|
||
get hasMissAttribution() {
|
||
return (this.missAttribution.total || 0) > 0;
|
||
},
|
||
|
||
// --- Waste Signals ---
|
||
|
||
wasteSignalLabel(signal) {
|
||
const labels = {
|
||
json_bloat: 'JSON Bloat',
|
||
html_noise: 'HTML Noise',
|
||
base64: 'Base64 Blobs',
|
||
whitespace: 'Whitespace',
|
||
dynamic_date: 'Dynamic Dates',
|
||
repetition: 'Repetition',
|
||
reread: 'Re-read Tool Results',
|
||
reread_compressed: 'Re-read After Compression',
|
||
};
|
||
return labels[signal] || signal;
|
||
},
|
||
|
||
wasteSignalColor(signal) {
|
||
const colors = {
|
||
json_bloat: 'bg-amber-500',
|
||
html_noise: 'bg-orange-500',
|
||
base64: 'bg-red-500',
|
||
whitespace: 'bg-blue-500',
|
||
dynamic_date: 'bg-purple-500',
|
||
repetition: 'bg-pink-500',
|
||
reread: 'bg-teal-500',
|
||
reread_compressed: 'bg-rose-500',
|
||
};
|
||
return colors[signal] || 'bg-gray-500';
|
||
},
|
||
|
||
wasteSignalBadgeColor(signal) {
|
||
const colors = {
|
||
json_bloat: 'bg-amber-500/20 text-amber-400',
|
||
html_noise: 'bg-orange-500/20 text-orange-400',
|
||
base64: 'bg-red-500/20 text-red-400',
|
||
whitespace: 'bg-blue-500/20 text-blue-400',
|
||
dynamic_date: 'bg-purple-500/20 text-purple-400',
|
||
repetition: 'bg-pink-500/20 text-pink-400',
|
||
reread: 'bg-teal-500/20 text-teal-400',
|
||
reread_compressed: 'bg-rose-500/20 text-rose-400',
|
||
};
|
||
return colors[signal] || 'bg-gray-500/20 text-gray-400';
|
||
},
|
||
|
||
get sortedWasteSignals() {
|
||
const signals = this.stats.waste_signals || {};
|
||
return Object.entries(signals)
|
||
.filter(([, v]) => v > 0)
|
||
.sort((a, b) => b[1] - a[1]);
|
||
},
|
||
|
||
getWastePercent(tokens) {
|
||
const signals = this.stats.waste_signals || {};
|
||
const max = Math.max(...Object.values(signals), 1);
|
||
return Math.min((tokens / max) * 100, 100);
|
||
},
|
||
|
||
get compressionTotalBefore() {
|
||
return this.stats.tokens?.total_before_compression || 0;
|
||
},
|
||
|
||
get proxyShareOfTotal() {
|
||
const total = this.compressionTotalBefore;
|
||
if (total <= 0) return 0;
|
||
return (this.stats.tokens?.proxy_compression_saved || 0) / total * 100;
|
||
},
|
||
|
||
get cliFilteringLabel() {
|
||
const raw = this.stats.savings?.by_layer?.cli_filtering?.label
|
||
|| this.stats.context_tool?.label
|
||
|| this.stats.context_tool?.configured
|
||
|| 'Context Tool';
|
||
if (String(raw).toLowerCase() === 'lean-ctx') return 'Lean-ctx';
|
||
return String(raw);
|
||
},
|
||
|
||
get cliFilteringSaved() {
|
||
return this.stats.tokens?.cli_filtering_saved
|
||
?? this.stats.tokens?.cli_tokens_avoided
|
||
?? this.stats.tokens?.rtk_saved
|
||
?? 0;
|
||
},
|
||
|
||
get cliFilteringShareOfTotal() {
|
||
const total = this.compressionTotalBefore;
|
||
if (total <= 0) return 0;
|
||
return this.cliFilteringSaved / total * 100;
|
||
},
|
||
|
||
get cliFilteringLifetime() {
|
||
return this.stats.savings?.by_layer?.cli_filtering?.lifetime?.tokens_saved ?? 0;
|
||
},
|
||
|
||
get cliFilteringSessionPctDisplay() {
|
||
const p = this.stats.savings?.by_layer?.cli_filtering?.session_savings_pct;
|
||
return (p === null || p === undefined) ? this.cliFilteringShareOfTotal : p;
|
||
},
|
||
|
||
get cliFilteringAvailable() {
|
||
const ct = this.stats.context_tool;
|
||
if (ct && typeof ct.available === 'boolean') return ct.available;
|
||
return true;
|
||
},
|
||
|
||
get historyCliFilteringAvailable() {
|
||
const hf = this.historyStats?.cli_filtering;
|
||
if (hf && typeof hf.available === 'boolean') return hf.available;
|
||
return true;
|
||
},
|
||
|
||
// --- Headline savings percent ---
|
||
//
|
||
get headlineSavingsPercent() {
|
||
return this.stats.tokens?.savings_percent
|
||
?? this.stats.tokens?.proxy_savings_percent
|
||
?? 0;
|
||
},
|
||
|
||
get headlineSavingsTitle() {
|
||
return 'Of total wire input tokens';
|
||
},
|
||
|
||
// --- Expandable Rows ---
|
||
|
||
toggleExpanded(id) {
|
||
this.expandedRows[id] = !this.expandedRows[id];
|
||
},
|
||
|
||
// --- Charts ---
|
||
|
||
getProviderPercent(count) {
|
||
const total = this.stats.requests?.total || 1;
|
||
return Math.min((count / total) * 100, 100);
|
||
},
|
||
|
||
getSparkline(data) {
|
||
if (!data || data.length < 2) return '';
|
||
const min = Math.min(...data);
|
||
const max = Math.max(...data);
|
||
const range = max - min || 1;
|
||
|
||
const points = data.map((v, i) => {
|
||
const x = (i / (data.length - 1)) * 100;
|
||
const y = 32 - ((v - min) / range) * 28;
|
||
return `${x},${y}`;
|
||
});
|
||
|
||
return 'M' + points.join(' L');
|
||
},
|
||
|
||
getSparklineArea(data) {
|
||
if (!data || data.length < 2) return '';
|
||
const line = this.getSparkline(data);
|
||
if (!line) return '';
|
||
return line + ` L100,32 L0,32 Z`;
|
||
},
|
||
|
||
getTrendLine(history) {
|
||
if (!history || history.length < 2) return '';
|
||
const values = history.map(h => Array.isArray(h) ? h[1] : (h?.total_tokens_saved || 0));
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const range = max - min || 1;
|
||
|
||
const points = values.map((v, i) => {
|
||
const x = (i / (values.length - 1)) * 200;
|
||
const y = 60 - ((v - min) / range) * 56;
|
||
return `${x},${y}`;
|
||
});
|
||
|
||
return 'M' + points.join(' L');
|
||
},
|
||
|
||
getTrendArea(history) {
|
||
if (!history || history.length < 2) return '';
|
||
const line = this.getTrendLine(history);
|
||
if (!line) return '';
|
||
return line + ` L200,64 L0,64 Z`;
|
||
},
|
||
|
||
getObjectTrendLine(history, valueKey) {
|
||
if (!history || history.length < 2) return '';
|
||
const values = history.map(point => point?.[valueKey] || 0);
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const range = max - min || 1;
|
||
|
||
const points = values.map((value, index) => {
|
||
const x = (index / (values.length - 1)) * 200;
|
||
const y = 60 - ((value - min) / range) * 56;
|
||
return `${x},${y}`;
|
||
});
|
||
|
||
return 'M' + points.join(' L');
|
||
},
|
||
|
||
getObjectTrendArea(history, valueKey) {
|
||
if (!history || history.length < 2) return '';
|
||
const line = this.getObjectTrendLine(history, valueKey);
|
||
if (!line) return '';
|
||
return line + ` L200,64 L0,64 Z`;
|
||
},
|
||
};
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const isDark = document.documentElement.classList.toggle('dark');
|
||
localStorage.setItem('headroom-theme', isDark ? 'dark' : 'light');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|