Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion dashboard/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react';
import MetricsStrip from './components/MetricsStrip.jsx';
import CostChart from './components/CostChart.jsx';
import CacheEfficiencyChart from './components/CacheEfficiencyChart.jsx';
import TopCostChart from './components/TopCostChart.jsx';
import SessionsTable from './components/SessionsTable.jsx';
import SessionDetail from './components/SessionDetail.jsx';
import SettingsPanel from './components/SettingsPanel.jsx';
Expand Down Expand Up @@ -100,7 +102,17 @@ export default function App() {
<MetricsStrip sessions={sessions} vatRate={vatRate} pricingDb={pricingDb} />
<div className="body-columns">
<div className="col-left">
<CostChart sessions={sessions} vatRate={vatRate} pricingDb={pricingDb} />
<div className="chart-row">
<CostChart sessions={sessions} vatRate={vatRate} pricingDb={pricingDb} />
<CacheEfficiencyChart sessions={sessions} />
</div>
<TopCostChart
sessions={sessions}
vatRate={vatRate}
pricingDb={pricingDb}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<div className="section-header">Sessions</div>
<SessionsTable
sessions={sessions}
Expand Down
106 changes: 106 additions & 0 deletions dashboard/src/components/CacheEfficiencyChart.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useMemo } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from 'recharts';

function getCacheRead(usage) {
if (!usage) return 0;
return usage.cache_read_tokens ?? usage.cache_read_input_tokens ?? usage.cached_input_tokens ?? 0;
}

function aggregateByDay(sessions) {
const map = new Map();

for (const s of sessions) {
const date = (s.started_at || s.completed_at || '').slice(0, 10);
if (!date || !s.usage) continue;
const u = s.usage;
const cacheRead = getCacheRead(u);
const freshInput = u.input_tokens ?? 0;
if (freshInput + cacheRead === 0) continue;

const prev = map.get(date) ?? { cacheRead: 0, freshInput: 0 };
map.set(date, {
cacheRead: prev.cacheRead + cacheRead,
freshInput: prev.freshInput + freshInput,
});
}

return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, v]) => {
const total = v.cacheRead + v.freshInput;
return {
date,
label: formatLabel(date),
rate: total > 0 ? parseFloat(((v.cacheRead / total) * 100).toFixed(1)) : 0,
};
});
}

function formatLabel(dateStr) {
const [, month, day] = dateStr.split('-');
return `${parseInt(month, 10)}/${parseInt(day, 10)}`;
}

function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
return (
<div className="custom-tooltip">
<div className="custom-tooltip__label">{label}</div>
<div className="custom-tooltip__value">{payload[0].value}% cached</div>
</div>
);
}

export default function CacheEfficiencyChart({ sessions }) {
const data = useMemo(() => aggregateByDay(sessions), [sessions]);
const hasData = data.some(d => d.rate > 0);

if (!hasData || data.length === 0) {
return (
<div className="chart-section">
<div className="chart-section__title">Cache Hit Rate</div>
<div style={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>No cache data available</span>
</div>
</div>
);
}

return (
<div className="chart-section">
<div className="chart-section__title">Cache Hit Rate</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#f3f4f6" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={v => `${v}%`}
width={40}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={80} stroke="#e5e7eb" strokeDasharray="4 2" />
<Line
type="monotone"
dataKey="rate"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3, fill: '#10b981', strokeWidth: 0 }}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
21 changes: 21 additions & 0 deletions dashboard/src/components/MetricsStrip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ function formatTokens(value) {
return String(value);
}

function cacheHitRate(sessions) {
let totalCacheRead = 0;
let totalFresh = 0;
for (const s of sessions) {
const u = s.usage;
if (!u) continue;
totalCacheRead += u.cache_read_tokens ?? u.cache_read_input_tokens ?? u.cached_input_tokens ?? 0;
totalFresh += u.input_tokens ?? 0;
}
const total = totalCacheRead + totalFresh;
return total > 0 ? (totalCacheRead / total) * 100 : null;
}

export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null }) {
const costResults = sessions.map(s => sessionCost(s, pricingDb));
const costs = costResults.map(c => c.value).filter(c => c != null);
Expand All @@ -26,6 +39,7 @@ export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null }
const sessionCount = sessions.length;
const avgCost = costs.length > 0 ? totalCost / costs.length : null;
const isEstimated = costResults.some(c => c.value != null && c.estimated);
const hitRate = cacheHitRate(sessions);

return (
<div className="metrics-strip">
Expand All @@ -51,6 +65,13 @@ export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null }
</div>
<div className="metric-card__sub">per run</div>
</div>
<div className="metric-card">
<div className="metric-card__label">Cache Hit Rate</div>
<div className="metric-card__value">
{hitRate != null ? `${hitRate.toFixed(0)}%` : '—'}
</div>
<div className="metric-card__sub">input tokens served from cache</div>
</div>
</div>
);
}
36 changes: 23 additions & 13 deletions dashboard/src/components/SessionDetail.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { lookupPrice, sessionCost } from '../pricing.js';
import TurnChart from './TurnChart.jsx';

function formatDate(isoString) {
if (!isoString) return '—';
Expand All @@ -11,15 +12,6 @@ function formatDate(isoString) {
});
}

function formatDuration(ms) {
if (ms == null || isNaN(ms)) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}

function formatCost(value, estimated = false) {
if (value == null || isNaN(value)) return '—';
if (value === 0) return '—';
Expand Down Expand Up @@ -60,6 +52,15 @@ function buildWarnings(session) {
if (session.git?.after?.dirty) {
warnings.push('Working tree was dirty after session completed');
}
const u = session.usage;
if (u) {
const cacheWrite = u.cache_creation_tokens ?? 0;
const output = u.output_tokens ?? 0;
if (cacheWrite > 0 && output > 0 && cacheWrite > output * 8) {
const ratio = (cacheWrite / output).toFixed(0);
warnings.push(`Context bloat: ${ratio}× more cache writes than output — large context loaded with little output`);
}
}
return warnings;
}

Expand Down Expand Up @@ -202,6 +203,7 @@ function LabelEditor({ sessionId, value, onChange }) {
}

export default function SessionDetail({ session, vatRate = 0, pricingDb = null, onLabelChange }) {
const hasTurns = Array.isArray(session?.turns) && session.turns.length > 1;
const [activeTab, setActiveTab] = useState('transcript');
const [transcript, setTranscript] = useState(null);
const [diff, setDiff] = useState(null);
Expand Down Expand Up @@ -283,10 +285,6 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null,
<div className="detail-meta-item__label">{session.started_at ? 'Started' : 'Completed'}</div>
<div className="detail-meta-item__value">{formatDate(session.started_at || session.completed_at)}</div>
</div>
<div className="detail-meta-item">
<div className="detail-meta-item__label">Duration</div>
<div className="detail-meta-item__value">{formatDuration(session.duration_ms)}</div>
</div>
<div className="detail-meta-item">
<div className="detail-meta-item__label">Model</div>
<div className="detail-meta-item__value" style={{ fontFamily: 'Menlo, monospace', fontSize: 12 }}>{session.model || '—'}</div>
Expand Down Expand Up @@ -379,6 +377,14 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null,
>
Diff
</button>
{hasTurns && (
<button
className={`detail-tab ${activeTab === 'turns' ? 'detail-tab--active' : ''}`}
onClick={() => setActiveTab('turns')}
>
Turns
</button>
)}
</div>

{activeTab === 'transcript' && (
Expand All @@ -392,6 +398,10 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null,
? <div className="loading">Loading...</div>
: <DiffView content={diff} />
)}

{activeTab === 'turns' && (
<TurnChart turns={session.turns} />
)}
</div>
);
}
11 changes: 0 additions & 11 deletions dashboard/src/components/SessionsTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,11 @@ function formatTokens(value) {
return String(value);
}

function formatDuration(ms) {
if (ms == null || isNaN(ms)) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}

const COLUMNS = [
{ key: 'started_at', label: 'Date', sortFn: (a, b) => (a.started_at || a.completed_at || '').localeCompare(b.started_at || b.completed_at || '') },
{ key: 'model', label: 'Model', sortFn: (a, b) => (a.model || '').localeCompare(b.model || '') },
{ key: 'total_tokens', label: 'Tokens', sortFn: (a, b) => (effectiveTokens(a.usage) ?? -1) - (effectiveTokens(b.usage) ?? -1) },
{ key: 'files_changed', label: 'Files', sortFn: (a, b) => (a.diff?.files_changed ?? -1) - (b.diff?.files_changed ?? -1) },
{ key: 'duration_ms', label: 'Duration', sortFn: (a, b) => (a.duration_ms ?? -1) - (b.duration_ms ?? -1) },
{ key: 'success', label: 'Status', sortFn: (a, b) => Number(b.success) - Number(a.success) },
];

Expand Down Expand Up @@ -127,7 +117,6 @@ export default function SessionsTable({ sessions, selectedId, onSelect, vatRate
<td className="muted">{formatTokens(effectiveTokens(session.usage))}</td>
<td className="muted">{(() => { const { value, estimated } = sessionCost(session, pricingDb); const v = value != null ? value * (1 + vatRate / 100) : null; return formatCost(v, estimated); })()}</td>
<td className="muted">{session.diff?.files_changed ?? '—'}</td>
<td className="muted">{formatDuration(session.duration_ms)}</td>
<td>
<span className={`status-badge ${session.success !== false && session.exit_code === 0 ? 'status-badge--ok' : 'status-badge--fail'}`}>
{session.success !== false && session.exit_code === 0 ? 'ok' : `exit ${session.exit_code ?? '?'}`}
Expand Down
Loading
Loading