toknmtr-selfhost/src/routes/+page.svelte
megaproxy 71a60ab054 toknmtr — self-hostable Claude Code usage & analytics dashboard
Server (SvelteKit + SQLite Docker container) + per-machine agent that parses
Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript
view + search gated behind SHOW_TRANSCRIPTS (hidden by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:33:55 +01:00

1191 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import type { PageData } from './$types';
import { resolve } from '$app/paths';
import RangePicker from '$lib/components/RangePicker.svelte';
import TimeSeriesChart from '$lib/components/TimeSeriesChart.svelte';
import ModelSeriesChart from '$lib/components/ModelSeriesChart.svelte';
import HourOfDayChart from '$lib/components/HourOfDayChart.svelte';
import CumulativeCostChart from '$lib/components/CumulativeCostChart.svelte';
import ModelDonut from '$lib/components/ModelDonut.svelte';
import CacheEfficiencyPanel from '$lib/components/CacheEfficiencyPanel.svelte';
import InfoTip from '$lib/components/InfoTip.svelte';
import ByProject from '$lib/components/dashboard/ByProject.svelte';
import WebUsage from '$lib/components/dashboard/WebUsage.svelte';
import ToolErrors from '$lib/components/dashboard/ToolErrors.svelte';
import TopActivity from '$lib/components/dashboard/TopActivity.svelte';
import GaugeHistory from '$lib/components/dashboard/GaugeHistory.svelte';
import LatencyTrends from '$lib/components/dashboard/LatencyTrends.svelte';
import ActivityCalendar from '$lib/components/dashboard/ActivityCalendar.svelte';
import Punchcard from '$lib/components/dashboard/Punchcard.svelte';
import {
fmtInt,
fmtCompact,
fmtUsd,
fmtPct,
fmtMs,
fmtBytes,
fmtDateShort,
relativeTime,
modelLabel,
basename
} from '$lib/format';
let { data }: { data: PageData } = $props();
const range = $derived(data.range);
const ov = $derived(data.overview);
const series = $derived(data.series);
const byModel = $derived(data.byModel);
const modelSet = $derived(data.modelSet);
const hourly = $derived(data.hourly);
const cache = $derived(data.cache);
const tools = $derived(data.topTools);
const sessions = $derived(data.recentSessions);
const gauges = $derived(data.gauges);
const projects = $derived(data.projects);
const webUsage = $derived(data.webUsage);
const toolErrors = $derived(data.toolErrors);
const topActivity = $derived(data.topActivity);
const gaugeHistory = $derived(data.gaugeHistory);
const speed = $derived(data.speed);
const activityCalendar = $derived(data.activityCalendar);
const punchcard = $derived(data.punchcard);
// client-side view toggles (no reload — pure presentation)
let seriesMetric = $state<'tokens' | 'cost'>('tokens');
let seriesType = $state<'bars' | 'area'>('bars');
let modelView = $state<'share' | 'donut'>('donut');
let donutMetric = $state<'cost' | 'tokens'>('cost');
let modelSeriesMetric = $state<'tokens' | 'cost'>('cost');
let modelSeriesType = $state<'bars' | 'area'>('area');
let hourMetric = $state<'tokens' | 'cost'>('tokens');
// ---- hero sparkline (per-bucket cost) ----
const SPARK_W = 240;
const SPARK_H = 46;
const spark = $derived.by(() => {
const vals = series.map((b) => b.costUsd);
const n = vals.length;
if (n === 0) return null;
const max = Math.max(...vals, 0.000001);
const pts = vals.map((v, i) => {
const x = n === 1 ? SPARK_W : (i / (n - 1)) * SPARK_W;
const y = SPARK_H - 3 - (v / max) * (SPARK_H - 6);
return { x, y };
});
const line = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join(' ');
const area = `M0,${SPARK_H} ${pts.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')} L${SPARK_W},${SPARK_H} Z`;
return { line, area };
});
// ---- arc gauge geometry ----
const ARC_R = 44;
const ARC_LEN = Math.PI * ARC_R; // semicircle length
function arcOffset(pct: number | null): number {
const p = Math.min(100, Math.max(0, pct ?? 0));
return ARC_LEN * (1 - p / 100);
}
function arcColor(pct: number | null): string {
if (pct === null) return 'var(--border)';
if (pct >= 90) return 'var(--danger)';
if (pct >= 70) return 'var(--amber)';
return 'var(--accent-2)';
}
</script>
{#snippet seg(items: { value: string; label: string }[], active: string, pick: (v: string) => void)}
<div class="tg" role="group">
{#each items as it (it.value)}
<button type="button" class:active={active === it.value} onclick={() => pick(it.value)}
>{it.label}</button
>
{/each}
</div>
{/snippet}
{#snippet arcGauge(label: string, pct: number | null)}
<div class="arc">
<svg
viewBox="0 0 100 60"
class="arc-svg"
role="img"
aria-label="{label}: {pct === null ? 'no data' : Math.round(pct) + '%'}"
>
<path
class="arc-track"
d="M6,52 A{ARC_R},{ARC_R} 0 0 1 94,52"
fill="none"
stroke-width="8"
stroke-linecap="round"
/>
<path
d="M6,52 A{ARC_R},{ARC_R} 0 0 1 94,52"
fill="none"
stroke={arcColor(pct)}
stroke-width="8"
stroke-linecap="round"
stroke-dasharray={ARC_LEN}
stroke-dashoffset={arcOffset(pct)}
style="transition: stroke-dashoffset 500ms cubic-bezier(0.16,0.84,0.44,1)"
/>
</svg>
<span class="arc-pct mono">{pct === null ? '—' : `${Math.round(pct)}%`}</span>
<span class="arc-label">{label}</span>
</div>
{/snippet}
<svelte:head><title>toknmtr — dashboard</title></svelte:head>
<div class="dash">
<header class="page-head reveal" style="--d:0">
<div class="head-row">
<div>
<h1>Dashboard</h1>
<p class="subtitle">
{#if ov.dateRange.earliest && ov.dateRange.latest}
{range.label} · activity {fmtDateShort(ov.dateRange.earliest)} {fmtDateShort(
ov.dateRange.latest
)}
{:else}
{range.label} · no activity in this window yet.
{/if}
</p>
</div>
<RangePicker {range} />
</div>
</header>
<!-- ============ HERO: featured cost + primary chart ============ -->
<section class="hero reveal" style="--d:1">
<div class="hero-cost">
<div class="hero-cost-glow" aria-hidden="true"></div>
<span class="hero-eyebrow">Notional cost · {range.label}</span>
<span class="hero-value mono">{fmtUsd(ov.totalCostUsd)}</span>
<span class="hero-note">API-equivalent · subscription is flat-rate</span>
{#if spark}
<svg
class="spark"
viewBox="0 0 {SPARK_W} {SPARK_H}"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--amber)" stop-opacity="0.35" />
<stop offset="1" stop-color="var(--amber)" stop-opacity="0" />
</linearGradient>
</defs>
<path d={spark.area} fill="url(#spark-fill)" />
<path
d={spark.line}
fill="none"
stroke="var(--amber)"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
{/if}
<div class="hero-mini">
<div class="mini">
<span class="mini-val mono">{fmtCompact(ov.totalTokens)}</span>
<span class="mini-lbl">tokens</span>
</div>
<div class="mini">
<span class="mini-val mono">{fmtInt(ov.sessionCount)}</span>
<span class="mini-lbl">sessions</span>
</div>
<div class="mini">
<span class="mini-val mono">{fmtCompact(ov.eventCount)}</span>
<span class="mini-lbl">events</span>
</div>
</div>
</div>
<section class="panel hero-chart">
<div class="panel-head">
<h2>Usage over time</h2>
<div class="panel-controls">
{@render seg(
[
{ value: 'tokens', label: 'Tokens' },
{ value: 'cost', label: 'Cost' }
],
seriesMetric,
(v) => (seriesMetric = v as 'tokens' | 'cost')
)}
{@render seg(
[
{ value: 'bars', label: 'Bars' },
{ value: 'area', label: 'Area' }
],
seriesType,
(v) => (seriesType = v as 'bars' | 'area')
)}
</div>
</div>
<TimeSeriesChart {series} bucket={range.bucket} metric={seriesMetric} type={seriesType} />
</section>
</section>
<!-- ============ KPI strip ============ -->
<section class="kpi-row reveal" style="--d:2">
<div class="kpi">
<span class="kpi-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none">
<path
d="M12 3 3 8l9 5 9-5-9-5Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
/>
<path
d="M3 12l9 5 9-5M3 16l9 5 9-5"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</span>
<div class="kpi-body">
<span class="kpi-val mono">{fmtCompact(ov.totalTokens)}</span>
<span class="kpi-lbl">Tokens</span>
</div>
<span class="kpi-sub mono"
>{fmtCompact(ov.totalInputTokens)}{fmtCompact(ov.totalOutputTokens)}</span
>
</div>
<div class="kpi">
<span class="kpi-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none">
<path
d="M4 5.5h16v10H9.5L5 19v-3.5H4Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
/>
</svg>
</span>
<div class="kpi-body">
<span class="kpi-val mono">{fmtInt(ov.sessionCount)}</span>
<span class="kpi-lbl">Sessions</span>
</div>
<span class="kpi-sub">in window</span>
</div>
<div class="kpi">
<span class="kpi-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none">
<path
d="M3 12h4l2-7 4 14 2-7h6"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<div class="kpi-body">
<span class="kpi-val mono">{fmtCompact(ov.eventCount)}</span>
<span class="kpi-lbl">Events</span>
</div>
<span class="kpi-sub mono">{fmtInt(ov.eventCount)}</span>
</div>
<div class="kpi">
<span class="kpi-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none">
<path
d="M14.7 6.3a4 4 0 0 0-5.4 4.6L4 16.2V20h3.8l5.3-5.3a4 4 0 0 0 4.6-5.4l-2.7 2.7-2-2 2.7-2.7Z"
stroke="currentColor"
stroke-width="1.4"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</span>
<div class="kpi-body">
<span class="kpi-val mono">{fmtCompact(ov.toolCallCount)}</span>
<span class="kpi-lbl">Tool calls</span>
</div>
<span class="kpi-sub mono">{fmtInt(ov.toolCallCount)}</span>
</div>
</section>
<!-- ============ bento: model · cache · subscription ============ -->
<section class="bento reveal" style="--d:3">
<div class="panel col-5">
<div class="panel-head">
<h2>By model</h2>
<div class="panel-controls">
{#if modelView === 'donut'}
{@render seg(
[
{ value: 'cost', label: '$' },
{ value: 'tokens', label: 'Tokens' }
],
donutMetric,
(v) => (donutMetric = v as 'cost' | 'tokens')
)}
{/if}
{@render seg(
[
{ value: 'donut', label: 'Donut' },
{ value: 'share', label: 'Bars' }
],
modelView,
(v) => (modelView = v as 'share' | 'donut')
)}
</div>
</div>
{#if modelView === 'donut'}
<ModelDonut {byModel} metric={donutMetric} />
{:else if byModel.length}
<div class="model-list">
{#each byModel as m (m.model)}
<div class="model-row">
<div class="model-row-top">
<span class="model-name">{modelLabel(m.model)}</span>
<span class="model-cost mono accent-amber">{fmtUsd(m.costUsd)}</span>
</div>
<div class="model-bar-track">
<div class="model-bar-fill" style="width:{(m.costShare * 100).toFixed(2)}%"></div>
</div>
<div class="model-row-bottom mono">
<span>{fmtPct(m.costShare)} of cost</span>
<span>{fmtCompact(m.totalTokens)} tok</span>
<span>{fmtInt(m.eventCount)} events</span>
</div>
</div>
{/each}
</div>
{:else}
<p class="empty">No model usage in this window.</p>
{/if}
</div>
<div class="panel col-4">
<div class="panel-head">
<h2>
Cache efficiency
<InfoTip label="What is cache efficiency?">
<p>
Prompt caching stores the stable prefix of your prompt (system prompt, tools, earlier
turns) so repeat requests skip reprocessing it.
</p>
<p>
<strong>Cache write</strong> — the first time a chunk of context is cached (≈1.25× the input
rate).
</p>
<p>
<strong>Cache read</strong> — that context reused on later turns (≈0.1× the input rate,
~10× cheaper).
</p>
<p>
Claude Code resends the whole conversation each turn, so most of the input side
becomes cheap cache <em>reads</em>. <strong>$ saved</strong> is those reads priced at the
read rate vs. what they'd cost at the full input rate.
</p>
</InfoTip>
</h2>
</div>
<CacheEfficiencyPanel {cache} />
</div>
<div class="panel col-3">
<div class="panel-head">
<h2>Subscription</h2>
<span class="panel-sub">/usage</span>
</div>
{#if gauges.length}
<div class="gauge-stack">
{#each gauges as g (g.host)}
<div class="gauge-host-block">
<span class="gauge-host mono">{g.host}</span>
<div class="arc-row">
{@render arcGauge('Session', g.sessionPct)}
{@render arcGauge('Week', g.weekAllPct)}
{@render arcGauge('Sonnet', g.weekSonnetPct)}
</div>
</div>
{/each}
</div>
{:else}
<p class="empty">No usage gauges yet.</p>
{/if}
</div>
</section>
<!-- ============ by model over time (full width) ============ -->
<section class="panel reveal" style="--d:4">
<div class="panel-head">
<h2>By model over time</h2>
<div class="panel-controls">
{@render seg(
[
{ value: 'cost', label: 'Cost' },
{ value: 'tokens', label: 'Tokens' }
],
modelSeriesMetric,
(v) => (modelSeriesMetric = v as 'tokens' | 'cost')
)}
{@render seg(
[
{ value: 'area', label: 'Area' },
{ value: 'bars', label: 'Bars' }
],
modelSeriesType,
(v) => (modelSeriesType = v as 'bars' | 'area')
)}
</div>
</div>
<ModelSeriesChart
{series}
{modelSet}
bucket={range.bucket}
metric={modelSeriesMetric}
type={modelSeriesType}
/>
</section>
<!-- ============ activity + cumulative ============ -->
<div class="split reveal" style="--d:5">
<section class="panel">
<div class="panel-head">
<h2>Activity by hour</h2>
<div class="panel-controls">
{@render seg(
[
{ value: 'tokens', label: 'Tokens' },
{ value: 'cost', label: 'Cost' }
],
hourMetric,
(v) => (hourMetric = v as 'tokens' | 'cost')
)}
</div>
</div>
<HourOfDayChart {hourly} metric={hourMetric} />
</section>
<section class="panel">
<div class="panel-head">
<h2>Cumulative cost</h2>
<span class="panel-sub">running total · {range.label}</span>
</div>
<CumulativeCostChart {series} bucket={range.bucket} />
</section>
</div>
<!-- ============ tools + sessions ============ -->
<div class="split-wide reveal" style="--d:6">
<section class="panel">
<div class="panel-head">
<h2>Top tools</h2>
<span class="panel-sub">by call count</span>
</div>
{#if tools.length}
<table class="data-table">
<thead>
<tr>
<th>Tool</th>
<th class="num">Calls</th>
<th class="num">Errors</th>
<th class="num">Avg latency</th>
<th class="num">Output</th>
</tr>
</thead>
<tbody>
{#each tools as t (t.toolName)}
<tr>
<td>{t.toolName}</td>
<td class="num mono">{fmtInt(t.callCount)}</td>
<td class="num mono" class:danger={t.errorCount > 0}>
{t.errorCount > 0
? `${fmtInt(t.errorCount)} (${fmtPct(t.errorCount / t.callCount)})`
: '—'}
</td>
<td class="num mono">{fmtMs(t.avgDurationMs)}</td>
<td class="num mono">{fmtBytes(t.totalResultBytes)}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="empty">No tool calls in this window.</p>
{/if}
</section>
<section class="panel">
<div class="panel-head">
<h2>Recent sessions</h2>
<a class="view-all" href={resolve('/sessions')}>View all →</a>
</div>
{#if sessions.length}
<div class="session-list">
{#each sessions as s (s.host + '/' + s.sessionId)}
<div class="session-row">
<div class="session-main">
{#if data.showTranscripts}
<a
class="session-link"
href={`/sessions/${encodeURIComponent(s.host)}/${encodeURIComponent(s.sessionId)}`}
>
<span class="session-project">{basename(s.project)}</span>
</a>
{:else}
<span class="session-project">{basename(s.project)}</span>
{/if}
{#if s.gitBranch}<span class="session-branch mono">{s.gitBranch}</span>{/if}
</div>
<div class="session-meta">
<span class="session-host mono">{s.host}</span>
<span class="session-when">{relativeTime(s.lastEventAt)}</span>
</div>
<div class="session-stats mono">
<span>{fmtInt(s.eventCount)} ev</span>
<span>{fmtInt(s.toolCallCount)} tools</span>
<span>{fmtCompact(s.totalTokens)} tok</span>
<span class="accent-amber">{fmtUsd(s.costUsd)}</span>
</div>
</div>
{/each}
</div>
{:else}
<p class="empty">No sessions in this window.</p>
{/if}
</section>
</div>
<!-- ============ by project (full width) ============ -->
<section class="reveal" style="--d:7">
<ByProject {projects} />
</section>
<!-- ============ web usage · tool errors ============ -->
<section class="bento reveal" style="--d:8">
<div class="panel col-4">
<div class="panel-head">
<h2>Web usage</h2>
<span class="panel-sub">searches &amp; fetches</span>
</div>
<WebUsage web={webUsage} />
</div>
<div class="panel col-8">
<div class="panel-head">
<h2>Which tools fail, and when</h2>
<span class="panel-sub">errors over time</span>
</div>
<ToolErrors {toolErrors} />
</div>
</section>
<!-- ============ what I do the most (full width) ============ -->
<TopActivity activity={topActivity} />
<!-- ============ activity calendar (full width) ============ -->
<ActivityCalendar days={activityCalendar} />
<!-- ============ when do I work (punchcard, full width) ============ -->
<section class="panel reveal" style="--d:9">
<div class="panel-head">
<h2>When do I work</h2>
<span class="panel-sub">events by day &amp; hour · UTC</span>
</div>
<Punchcard {punchcard} metric="events" />
</section>
<!-- ============ gauge history (full width) ============ -->
<GaugeHistory data={gaugeHistory} d={10} />
<!-- ============ latency trends (full width) ============ -->
<LatencyTrends trends={speed} d={11} />
</div>
<style>
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.accent-amber {
color: var(--amber);
}
.danger {
color: var(--danger);
}
.dash {
display: flex;
flex-direction: column;
}
/* ---- load-in motion ---- */
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.reveal {
animation: reveal-up 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
animation-delay: calc(var(--d, 0) * 70ms);
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.arc svg path {
animation: none !important;
transition: none !important;
}
}
.page-head {
margin-bottom: 1.6rem;
}
.head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-head h1 {
margin: 0;
font-family: var(--font-sans);
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.subtitle {
margin: 0.35rem 0 0;
color: var(--text-dim);
font-size: 0.9rem;
}
.empty {
color: var(--text-faint);
font-size: 0.88rem;
padding: 1.75rem 0;
text-align: center;
}
/* ---- toggle groups ---- */
.panel-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tg {
display: inline-flex;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 3px;
gap: 1px;
}
.tg button {
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
font: inherit;
font-family: var(--font-sans);
font-size: 0.73rem;
font-weight: 500;
padding: 0.28rem 0.66rem;
border-radius: 999px;
cursor: pointer;
transition:
color 150ms ease,
background 150ms ease,
box-shadow 150ms ease;
}
.tg button:hover {
color: var(--text);
}
.tg button.active {
background: var(--bg-raised);
color: var(--text);
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--border),
var(--shadow-card);
}
/* ============ HERO ============ */
.hero {
display: grid;
grid-template-columns: minmax(260px, 340px) 1fr;
gap: 1.35rem;
margin-bottom: 1.35rem;
}
@media (max-width: 880px) {
.hero {
grid-template-columns: 1fr;
}
}
.hero-cost {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 1.5rem 1.6rem;
border-radius: var(--radius);
background: linear-gradient(
155deg,
color-mix(in srgb, var(--accent) 16%, var(--bg-panel)) 0%,
var(--bg-panel) 58%
);
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border-soft));
box-shadow: var(--shadow-card);
}
.hero-cost-glow {
position: absolute;
top: -40%;
right: -25%;
width: 75%;
height: 120%;
background: radial-gradient(circle, var(--glow), transparent 68%);
pointer-events: none;
}
.hero-eyebrow {
position: relative;
font-family: var(--font-sans);
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.hero-value {
position: relative;
margin-top: 0.5rem;
font-size: 2.9rem;
font-weight: 700;
line-height: 1;
color: var(--amber);
letter-spacing: -0.02em;
}
.hero-note {
position: relative;
margin-top: 0.45rem;
font-size: 0.76rem;
color: var(--text-faint);
}
.spark {
position: relative;
width: 100%;
height: 46px;
margin: 1rem 0 0.2rem;
overflow: visible;
}
.hero-mini {
position: relative;
display: flex;
gap: 1.4rem;
margin-top: auto;
padding-top: 1rem;
}
.mini {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.mini-val {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.mini-lbl {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.hero-chart {
margin-bottom: 0;
display: flex;
flex-direction: column;
}
/* ============ KPI strip ============ */
.kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.35rem;
}
@media (max-width: 760px) {
.kpi-row {
grid-template-columns: repeat(2, 1fr);
}
}
.kpi {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.9rem 1rem;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
transition:
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.kpi:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-pop);
border-color: var(--border);
}
.kpi-icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--accent);
}
.kpi-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.kpi-val {
font-size: 1.3rem;
font-weight: 700;
line-height: 1.05;
color: var(--text);
}
.kpi-lbl {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.kpi-sub {
margin-left: auto;
font-size: 0.72rem;
color: var(--text-dim);
white-space: nowrap;
}
/* ============ panels ============ */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.1rem;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.005em;
display: flex;
align-items: center;
gap: 0.4rem;
}
.panel-sub {
font-family: var(--font-sans);
font-size: 0.78rem;
color: var(--text-faint);
}
.view-all {
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
color: var(--accent);
transition: opacity 120ms ease;
}
.view-all:hover {
opacity: 0.75;
}
/* ============ bento grid ============ */
.bento {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.35rem;
margin-bottom: 1.35rem;
}
.bento .panel {
margin-bottom: 0;
min-width: 0;
}
.col-8 {
grid-column: span 8;
}
.col-5 {
grid-column: span 5;
}
.col-4 {
grid-column: span 4;
}
.col-3 {
grid-column: span 3;
}
@media (max-width: 1080px) {
.bento {
grid-template-columns: 1fr 1fr;
}
.col-8,
.col-5,
.col-4,
.col-3 {
grid-column: auto;
}
.bento .col-5 {
grid-column: span 2;
}
}
@media (max-width: 720px) {
.bento {
grid-template-columns: 1fr;
}
.bento .col-5 {
grid-column: auto;
}
}
.split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.35rem;
}
.split .panel {
margin-bottom: 0;
}
.split {
margin-bottom: 1.35rem;
}
.split-wide {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.35rem;
}
.split-wide .panel {
margin-bottom: 0;
}
@media (max-width: 900px) {
.split-wide {
grid-template-columns: 1fr;
}
}
/* ---- model breakdown ---- */
.model-list {
display: flex;
flex-direction: column;
gap: 1.05rem;
}
.model-row-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.4rem;
}
.model-name {
font-family: var(--font-sans);
font-size: 0.92rem;
font-weight: 500;
}
.model-cost {
font-size: 0.92rem;
font-weight: 600;
}
.model-bar-track {
height: 8px;
border-radius: 4px;
background: var(--bg-raised);
overflow: hidden;
}
.model-bar-fill {
height: 100%;
border-radius: 4px;
background: var(--grad-accent);
transition: width 300ms ease;
}
.model-row-bottom {
display: flex;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.76rem;
color: var(--text-dim);
}
/* ---- tables ---- */
.data-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-sans);
font-size: 0.85rem;
}
.data-table th,
.data-table td {
text-align: left;
padding: 0.6rem 0.65rem;
border-bottom: 1px solid var(--border-soft);
}
.data-table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
font-weight: 600;
}
.data-table tbody tr {
transition: background 120ms ease;
}
.data-table tbody tr:hover {
background: var(--bg-raised);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.num {
text-align: right;
}
/* ---- sessions ---- */
.session-list {
display: flex;
flex-direction: column;
}
.session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.75rem;
margin: 0 -0.75rem;
border-radius: var(--radius-sm);
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
transition: background 120ms ease;
}
.session-row:hover {
background: var(--bg-raised);
}
.session-row:last-child {
border-bottom: none;
}
.session-main {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.session-link {
text-decoration: none;
min-width: 0;
}
.session-link:hover .session-project {
color: var(--accent);
text-decoration: underline;
}
.session-project {
font-family: var(--font-sans);
font-weight: 500;
font-size: 0.9rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-branch {
font-size: 0.74rem;
color: var(--text-dim);
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 0.1rem 0.55rem;
}
.session-meta {
display: flex;
gap: 0.7rem;
font-family: var(--font-sans);
font-size: 0.78rem;
color: var(--text-faint);
}
.session-when {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.session-stats {
display: flex;
gap: 1rem;
font-size: 0.82rem;
color: var(--text-dim);
}
/* ============ arc gauges ============ */
.gauge-stack {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.gauge-host {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
display: block;
margin-bottom: 0.35rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arc-row {
display: flex;
gap: 0.4rem;
justify-content: space-between;
}
.arc {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 0;
}
.arc-svg {
width: 100%;
max-width: 74px;
height: auto;
overflow: visible;
}
.arc-track {
stroke: var(--bg-raised);
}
.arc-pct {
margin-top: -0.35rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--text);
}
.arc-label {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
</style>