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>
1191 lines
29 KiB
Svelte
1191 lines
29 KiB
Svelte
<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 & 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 & 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>
|