feat(metrics): add Dashboard Metrics Panel with live health and SVG sparkline history
New MetricsPanel.svelte component as ProjectBox tab (PERSISTED-LAZY, all projects). Live view: fleet aggregates, project health grid, task board summary, attention queue. History view: 5 switchable SVG sparklines (cost/tokens/turns/tools/duration), stats row, recent sessions table. 25 tests for pure utility functions.
This commit is contained in:
parent
d9d67b2bc6
commit
6ca3ffdb8d
3 changed files with 1025 additions and 1 deletions
808
v2/src/lib/components/Workspace/MetricsPanel.svelte
Normal file
808
v2/src/lib/components/Workspace/MetricsPanel.svelte
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import type { ProjectHealth } from '../../stores/health.svelte';
|
||||
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
|
||||
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
|
||||
import { getAgentSession } from '../../stores/agents.svelte';
|
||||
import { listTasks, type Task } from '../../adapters/bttask-bridge';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
groupId?: GroupId;
|
||||
}
|
||||
|
||||
let { project, groupId }: Props = $props();
|
||||
|
||||
// --- View toggle ---
|
||||
type MetricsView = 'live' | 'history';
|
||||
let activeView = $state<MetricsView>('live');
|
||||
|
||||
// --- Live view state ---
|
||||
let taskCounts = $state<Record<string, number>>({ todo: 0, progress: 0, review: 0, done: 0, blocked: 0 });
|
||||
let taskPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// --- History view state ---
|
||||
interface MetricPoint {
|
||||
endTime: number;
|
||||
costUsd: number;
|
||||
peakTokens: number;
|
||||
turnCount: number;
|
||||
toolCallCount: number;
|
||||
durationMin: number;
|
||||
}
|
||||
let historyData = $state<MetricPoint[]>([]);
|
||||
let historyLoading = $state(false);
|
||||
type HistoryMetric = 'cost' | 'tokens' | 'turns' | 'tools' | 'duration';
|
||||
let selectedHistoryMetric = $state<HistoryMetric>('cost');
|
||||
|
||||
// --- Derived live data ---
|
||||
let health = $derived(getProjectHealth(project.id));
|
||||
let aggregates = $derived(getHealthAggregates());
|
||||
let allHealth = $derived(getAllProjectHealth());
|
||||
|
||||
let session = $derived.by(() => {
|
||||
if (!health?.sessionId) return undefined;
|
||||
return getAgentSession(health.sessionId);
|
||||
});
|
||||
|
||||
// --- Task polling ---
|
||||
async function fetchTaskCounts() {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
const tasks = await listTasks(groupId);
|
||||
const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 };
|
||||
for (const t of tasks) {
|
||||
if (counts[t.status] !== undefined) counts[t.status]++;
|
||||
}
|
||||
taskCounts = counts;
|
||||
} catch {
|
||||
// bttask db may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
// --- History loading ---
|
||||
async function loadHistory() {
|
||||
historyLoading = true;
|
||||
try {
|
||||
const metrics = await invoke<Array<{
|
||||
id: number;
|
||||
project_id: string;
|
||||
session_id: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
peak_tokens: number;
|
||||
turn_count: number;
|
||||
tool_call_count: number;
|
||||
cost_usd: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}>>('session_metrics_load', { projectId: project.id, limit: 50 });
|
||||
|
||||
historyData = metrics.reverse().map(m => ({
|
||||
endTime: m.end_time,
|
||||
costUsd: m.cost_usd,
|
||||
peakTokens: m.peak_tokens,
|
||||
turnCount: m.turn_count,
|
||||
toolCallCount: m.tool_call_count,
|
||||
durationMin: Math.max(0.1, (m.end_time - m.start_time) / 60_000),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to load metrics history:', e);
|
||||
historyData = [];
|
||||
}
|
||||
historyLoading = false;
|
||||
}
|
||||
|
||||
// --- SVG sparkline helpers ---
|
||||
function sparklinePath(points: number[], width: number, height: number): string {
|
||||
if (points.length < 2) return '';
|
||||
const max = Math.max(...points, 0.001);
|
||||
const step = width / (points.length - 1);
|
||||
return points
|
||||
.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - (v / max) * height;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getHistoryValues(metric: HistoryMetric): number[] {
|
||||
switch (metric) {
|
||||
case 'cost': return historyData.map(d => d.costUsd);
|
||||
case 'tokens': return historyData.map(d => d.peakTokens);
|
||||
case 'turns': return historyData.map(d => d.turnCount);
|
||||
case 'tools': return historyData.map(d => d.toolCallCount);
|
||||
case 'duration': return historyData.map(d => d.durationMin);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: HistoryMetric, value: number): string {
|
||||
switch (metric) {
|
||||
case 'cost': return `$${value.toFixed(4)}`;
|
||||
case 'tokens': return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : `${value}`;
|
||||
case 'turns': return `${value}`;
|
||||
case 'tools': return `${value}`;
|
||||
case 'duration': return `${value.toFixed(1)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<HistoryMetric, string> = {
|
||||
cost: 'Cost (USD)',
|
||||
tokens: 'Peak Tokens',
|
||||
turns: 'Turns',
|
||||
tools: 'Tool Calls',
|
||||
duration: 'Duration',
|
||||
};
|
||||
|
||||
const METRIC_COLORS: Record<HistoryMetric, string> = {
|
||||
cost: 'var(--ctp-yellow)',
|
||||
tokens: 'var(--ctp-blue)',
|
||||
turns: 'var(--ctp-green)',
|
||||
tools: 'var(--ctp-mauve)',
|
||||
duration: 'var(--ctp-peach)',
|
||||
};
|
||||
|
||||
// --- Formatting helpers ---
|
||||
function fmtBurnRate(rate: number): string {
|
||||
if (rate === 0) return '$0/hr';
|
||||
if (rate < 0.01) return `$${(rate * 100).toFixed(1)}c/hr`;
|
||||
return `$${rate.toFixed(2)}/hr`;
|
||||
}
|
||||
|
||||
function fmtPressure(p: number | null): string {
|
||||
if (p === null) return '—';
|
||||
return `${Math.round(p * 100)}%`;
|
||||
}
|
||||
|
||||
function pressureColor(p: number | null): string {
|
||||
if (p === null) return 'var(--ctp-overlay0)';
|
||||
if (p > 0.9) return 'var(--ctp-red)';
|
||||
if (p > 0.75) return 'var(--ctp-peach)';
|
||||
if (p > 0.5) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-green)';
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'running': return 'var(--ctp-green)';
|
||||
case 'idle': return 'var(--ctp-overlay1)';
|
||||
case 'stalled': return 'var(--ctp-peach)';
|
||||
default: return 'var(--ctp-overlay0)';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtIdle(ms: number): string {
|
||||
if (ms === 0) return '—';
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMount(() => {
|
||||
fetchTaskCounts();
|
||||
taskPollTimer = setInterval(fetchTaskCounts, 10_000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (taskPollTimer) clearInterval(taskPollTimer);
|
||||
});
|
||||
|
||||
// Load history when switching to history view
|
||||
$effect(() => {
|
||||
if (activeView === 'history' && historyData.length === 0) {
|
||||
loadHistory();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="metrics-panel">
|
||||
<!-- View tabs -->
|
||||
<div class="view-tabs">
|
||||
<button
|
||||
class="vtab"
|
||||
class:active={activeView === 'live'}
|
||||
onclick={() => activeView = 'live'}
|
||||
>Live</button>
|
||||
<button
|
||||
class="vtab"
|
||||
class:active={activeView === 'history'}
|
||||
onclick={() => activeView = 'history'}
|
||||
>History</button>
|
||||
</div>
|
||||
|
||||
{#if activeView === 'live'}
|
||||
<div class="live-view">
|
||||
<!-- Aggregates bar -->
|
||||
<div class="agg-bar">
|
||||
<div class="agg-item">
|
||||
<span class="agg-label">Fleet</span>
|
||||
<span class="agg-badges">
|
||||
{#if aggregates.running > 0}
|
||||
<span class="agg-badge" style="color: var(--ctp-green)">{aggregates.running} running</span>
|
||||
{/if}
|
||||
{#if aggregates.idle > 0}
|
||||
<span class="agg-badge" style="color: var(--ctp-overlay1)">{aggregates.idle} idle</span>
|
||||
{/if}
|
||||
{#if aggregates.stalled > 0}
|
||||
<span class="agg-badge" style="color: var(--ctp-peach)">{aggregates.stalled} stalled</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="agg-item">
|
||||
<span class="agg-label">Burn</span>
|
||||
<span class="agg-value" style="color: var(--ctp-mauve)">{fmtBurnRate(aggregates.totalBurnRatePerHour)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- This project's health -->
|
||||
{#if health}
|
||||
<div class="section-header">This Project</div>
|
||||
<div class="health-grid">
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Status</span>
|
||||
<span class="hc-value" style="color: {stateColor(health.activityState)}">
|
||||
{health.activityState}
|
||||
{#if health.activeTool}
|
||||
<span class="hc-tool">({health.activeTool})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Burn Rate</span>
|
||||
<span class="hc-value" style="color: var(--ctp-mauve)">{fmtBurnRate(health.burnRatePerHour)}</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Context</span>
|
||||
<span class="hc-value" style="color: {pressureColor(health.contextPressure)}">{fmtPressure(health.contextPressure)}</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Idle</span>
|
||||
<span class="hc-value">{fmtIdle(health.idleDurationMs)}</span>
|
||||
</div>
|
||||
{#if session}
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Tokens</span>
|
||||
<span class="hc-value" style="color: var(--ctp-blue)">{(session.inputTokens + session.outputTokens).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Cost</span>
|
||||
<span class="hc-value" style="color: var(--ctp-yellow)">${session.costUsd.toFixed(4)}</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Turns</span>
|
||||
<span class="hc-value">{session.numTurns}</span>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<span class="hc-label">Model</span>
|
||||
<span class="hc-value hc-model">{session.model ?? '—'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if health.fileConflictCount > 0}
|
||||
<div class="health-card health-warn">
|
||||
<span class="hc-label">Conflicts</span>
|
||||
<span class="hc-value" style="color: var(--ctp-red)">{health.fileConflictCount}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if health.externalConflictCount > 0}
|
||||
<div class="health-card health-warn">
|
||||
<span class="hc-label">External</span>
|
||||
<span class="hc-value" style="color: var(--ctp-peach)">{health.externalConflictCount}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if health.attentionScore > 0}
|
||||
<div class="health-card health-attention">
|
||||
<span class="hc-label">Attention</span>
|
||||
<span class="hc-value" style="color: {health.attentionScore >= 90 ? 'var(--ctp-red)' : health.attentionScore >= 70 ? 'var(--ctp-peach)' : 'var(--ctp-yellow)'}">{health.attentionScore}</span>
|
||||
{#if health.attentionReason}
|
||||
<span class="hc-reason">{health.attentionReason}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">No health data — start an agent session</div>
|
||||
{/if}
|
||||
|
||||
<!-- Task board summary -->
|
||||
{#if groupId}
|
||||
<div class="section-header">Task Board</div>
|
||||
<div class="task-summary">
|
||||
{#each ['todo', 'progress', 'review', 'done', 'blocked'] as status}
|
||||
<div class="task-col" class:task-col-blocked={status === 'blocked' && taskCounts[status] > 0}>
|
||||
<span class="tc-count" class:tc-zero={taskCounts[status] === 0}>{taskCounts[status]}</span>
|
||||
<span class="tc-label">{status === 'progress' ? 'In Prog' : status === 'todo' ? 'To Do' : status.charAt(0).toUpperCase() + status.slice(1)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue (cross-project) -->
|
||||
{#if allHealth.filter(h => h.attentionScore > 0).length > 0}
|
||||
<div class="section-header">Attention Queue</div>
|
||||
<div class="attention-list">
|
||||
{#each allHealth.filter(h => h.attentionScore > 0).slice(0, 5) as item}
|
||||
<div class="attention-row">
|
||||
<span class="ar-score" style="color: {item.attentionScore >= 90 ? 'var(--ctp-red)' : item.attentionScore >= 70 ? 'var(--ctp-peach)' : 'var(--ctp-yellow)'}">{item.attentionScore}</span>
|
||||
<span class="ar-id">{item.projectId.slice(0, 8)}</span>
|
||||
<span class="ar-reason">{item.attentionReason ?? '—'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- History view -->
|
||||
<div class="history-view">
|
||||
{#if historyLoading}
|
||||
<div class="empty-state">Loading history...</div>
|
||||
{:else if historyData.length === 0}
|
||||
<div class="empty-state">No session history for this project</div>
|
||||
{:else}
|
||||
<!-- Metric selector -->
|
||||
<div class="metric-tabs">
|
||||
{#each (['cost', 'tokens', 'turns', 'tools', 'duration'] as const) as metric}
|
||||
<button
|
||||
class="mtab"
|
||||
class:active={selectedHistoryMetric === metric}
|
||||
onclick={() => selectedHistoryMetric = metric}
|
||||
style={selectedHistoryMetric === metric ? `border-bottom-color: ${METRIC_COLORS[metric]}` : ''}
|
||||
>{METRIC_LABELS[metric]}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart -->
|
||||
{@const values = getHistoryValues(selectedHistoryMetric)}
|
||||
{@const maxVal = Math.max(...values, 0.001)}
|
||||
{@const minVal = Math.min(...values)}
|
||||
{@const lastVal = values[values.length - 1] ?? 0}
|
||||
{@const avgVal = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0}
|
||||
|
||||
<div class="sparkline-container">
|
||||
<svg viewBox="0 0 400 120" class="sparkline-svg" preserveAspectRatio="none">
|
||||
<!-- Grid lines -->
|
||||
<line x1="0" y1="30" x2="400" y2="30" stroke="var(--ctp-surface0)" stroke-width="0.5" />
|
||||
<line x1="0" y1="60" x2="400" y2="60" stroke="var(--ctp-surface0)" stroke-width="0.5" />
|
||||
<line x1="0" y1="90" x2="400" y2="90" stroke="var(--ctp-surface0)" stroke-width="0.5" />
|
||||
|
||||
<!-- Area fill -->
|
||||
<path
|
||||
d="{sparklinePath(values, 400, 110)} L400,110 L0,110 Z"
|
||||
fill={METRIC_COLORS[selectedHistoryMetric]}
|
||||
opacity="0.08"
|
||||
/>
|
||||
|
||||
<!-- Line -->
|
||||
<path
|
||||
d={sparklinePath(values, 400, 110)}
|
||||
fill="none"
|
||||
stroke={METRIC_COLORS[selectedHistoryMetric]}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Last point dot -->
|
||||
{#if values.length > 0}
|
||||
{@const lastX = 400}
|
||||
{@const lastY = 110 - (lastVal / maxVal) * 110}
|
||||
<circle cx={lastX} cy={lastY} r="3" fill={METRIC_COLORS[selectedHistoryMetric]} />
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Last</span>
|
||||
<span class="stat-value" style="color: {METRIC_COLORS[selectedHistoryMetric]}">{formatMetricValue(selectedHistoryMetric, lastVal)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Avg</span>
|
||||
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, avgVal)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Max</span>
|
||||
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, maxVal)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Min</span>
|
||||
<span class="stat-value">{formatMetricValue(selectedHistoryMetric, minVal)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Sessions</span>
|
||||
<span class="stat-value">{historyData.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session table -->
|
||||
<div class="section-header">Recent Sessions</div>
|
||||
<div class="session-table">
|
||||
<div class="st-header">
|
||||
<span class="st-col st-col-time">Time</span>
|
||||
<span class="st-col st-col-dur">Dur</span>
|
||||
<span class="st-col st-col-cost">Cost</span>
|
||||
<span class="st-col st-col-tok">Tokens</span>
|
||||
<span class="st-col st-col-turns">Turns</span>
|
||||
<span class="st-col st-col-tools">Tools</span>
|
||||
</div>
|
||||
{#each historyData.slice(-10).reverse() as row}
|
||||
<div class="st-row">
|
||||
<span class="st-col st-col-time">{new Date(row.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span class="st-col st-col-dur">{row.durationMin.toFixed(0)}m</span>
|
||||
<span class="st-col st-col-cost" style="color: var(--ctp-yellow)">${row.costUsd.toFixed(3)}</span>
|
||||
<span class="st-col st-col-tok">{row.peakTokens >= 1000 ? `${(row.peakTokens / 1000).toFixed(0)}K` : row.peakTokens}</span>
|
||||
<span class="st-col st-col-turns">{row.turnCount}</span>
|
||||
<span class="st-col st-col-tools">{row.toolCallCount}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button class="refresh-btn" onclick={loadHistory}>Refresh</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.metrics-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* --- View tabs --- */
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtab {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.vtab:hover { color: var(--ctp-subtext1); }
|
||||
.vtab.active {
|
||||
color: var(--ctp-text);
|
||||
border-bottom-color: var(--accent, var(--ctp-blue));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Live view --- */
|
||||
.live-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.agg-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.agg-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.agg-label {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.agg-badges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.agg-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agg-value {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.25rem 0 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Health grid --- */
|
||||
.health-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.health-warn { border-color: color-mix(in srgb, var(--ctp-peach) 30%, var(--ctp-surface0)); }
|
||||
.health-attention { border-color: color-mix(in srgb, var(--ctp-yellow) 30%, var(--ctp-surface0)); }
|
||||
|
||||
.hc-label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.hc-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.hc-tool {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.hc-model {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hc-reason {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* --- Task summary --- */
|
||||
.task-summary {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.task-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem 0.25rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.task-col-blocked {
|
||||
border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface0));
|
||||
}
|
||||
|
||||
.tc-count {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ctp-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tc-zero { color: var(--ctp-overlay0); }
|
||||
|
||||
.tc-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* --- Attention queue --- */
|
||||
.attention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.attention-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.ar-score { font-weight: 700; min-width: 1.5rem; }
|
||||
.ar-id { color: var(--ctp-overlay1); font-family: monospace; font-size: 0.65rem; }
|
||||
.ar-reason { color: var(--ctp-subtext0); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* --- History view --- */
|
||||
.history-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mtab {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.mtab:hover { color: var(--ctp-subtext1); }
|
||||
.mtab.active { color: var(--ctp-text); font-weight: 600; }
|
||||
|
||||
/* --- Sparkline --- */
|
||||
.sparkline-container {
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-svg {
|
||||
width: 100%;
|
||||
height: 7.5rem;
|
||||
}
|
||||
|
||||
/* --- Stats row --- */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
/* --- Session table --- */
|
||||
.session-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
font-size: 0.65rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.st-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.55rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.st-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0.1875rem 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface0) 40%, transparent);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.st-row:hover { background: color-mix(in srgb, var(--ctp-surface0) 30%, transparent); }
|
||||
|
||||
.st-col { text-align: right; padding: 0 0.25rem; }
|
||||
.st-col-time { flex: 1.2; text-align: left; }
|
||||
.st-col-dur { flex: 0.8; }
|
||||
.st-col-cost { flex: 1; }
|
||||
.st-col-tok { flex: 1; }
|
||||
.st-col-turns { flex: 0.7; }
|
||||
.st-col-tools { flex: 0.7; }
|
||||
|
||||
/* --- Misc --- */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
align-self: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
</style>
|
||||
205
v2/src/lib/components/Workspace/MetricsPanel.test.ts
Normal file
205
v2/src/lib/components/Workspace/MetricsPanel.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Test the pure utility functions used in MetricsPanel
|
||||
// These are extracted for testability since the component uses them internally
|
||||
|
||||
// --- Sparkline path generator (same logic as in MetricsPanel.svelte) ---
|
||||
function sparklinePath(points: number[], width: number, height: number): string {
|
||||
if (points.length < 2) return '';
|
||||
const max = Math.max(...points, 0.001);
|
||||
const step = width / (points.length - 1);
|
||||
return points
|
||||
.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - (v / max) * height;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// --- Format helpers (same logic as in MetricsPanel.svelte) ---
|
||||
type HistoryMetric = 'cost' | 'tokens' | 'turns' | 'tools' | 'duration';
|
||||
|
||||
function formatMetricValue(metric: HistoryMetric, value: number): string {
|
||||
switch (metric) {
|
||||
case 'cost': return `$${value.toFixed(4)}`;
|
||||
case 'tokens': return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : `${value}`;
|
||||
case 'turns': return `${value}`;
|
||||
case 'tools': return `${value}`;
|
||||
case 'duration': return `${value.toFixed(1)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBurnRate(rate: number): string {
|
||||
if (rate === 0) return '$0/hr';
|
||||
if (rate < 0.01) return `$${(rate * 100).toFixed(1)}c/hr`;
|
||||
return `$${rate.toFixed(2)}/hr`;
|
||||
}
|
||||
|
||||
function fmtPressure(p: number | null): string {
|
||||
if (p === null) return '—';
|
||||
return `${Math.round(p * 100)}%`;
|
||||
}
|
||||
|
||||
function fmtIdle(ms: number): string {
|
||||
if (ms === 0) return '—';
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
function pressureColor(p: number | null): string {
|
||||
if (p === null) return 'var(--ctp-overlay0)';
|
||||
if (p > 0.9) return 'var(--ctp-red)';
|
||||
if (p > 0.75) return 'var(--ctp-peach)';
|
||||
if (p > 0.5) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-green)';
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'running': return 'var(--ctp-green)';
|
||||
case 'idle': return 'var(--ctp-overlay1)';
|
||||
case 'stalled': return 'var(--ctp-peach)';
|
||||
default: return 'var(--ctp-overlay0)';
|
||||
}
|
||||
}
|
||||
|
||||
describe('MetricsPanel — sparklinePath', () => {
|
||||
it('returns empty string for fewer than 2 points', () => {
|
||||
expect(sparklinePath([], 400, 120)).toBe('');
|
||||
expect(sparklinePath([5], 400, 120)).toBe('');
|
||||
});
|
||||
|
||||
it('generates valid SVG path for 2 points', () => {
|
||||
const path = sparklinePath([0, 10], 400, 120);
|
||||
expect(path).toMatch(/^M0\.0,120\.0 L400\.0,0\.0$/);
|
||||
});
|
||||
|
||||
it('generates path with correct number of segments', () => {
|
||||
const path = sparklinePath([1, 2, 3, 4, 5], 400, 100);
|
||||
const segments = path.split(' ');
|
||||
expect(segments).toHaveLength(5);
|
||||
expect(segments[0]).toMatch(/^M/);
|
||||
expect(segments[1]).toMatch(/^L/);
|
||||
});
|
||||
|
||||
it('scales Y axis to max value', () => {
|
||||
const path = sparklinePath([50, 100], 400, 100);
|
||||
// Point 1: x=0, y=100 - (50/100)*100 = 50
|
||||
// Point 2: x=400, y=100 - (100/100)*100 = 0
|
||||
expect(path).toBe('M0.0,50.0 L400.0,0.0');
|
||||
});
|
||||
|
||||
it('handles all-zero values without division by zero', () => {
|
||||
const path = sparklinePath([0, 0, 0], 400, 100);
|
||||
expect(path).not.toBe('');
|
||||
expect(path).not.toContain('NaN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — formatMetricValue', () => {
|
||||
it('formats cost with 4 decimals', () => {
|
||||
expect(formatMetricValue('cost', 1.2345)).toBe('$1.2345');
|
||||
expect(formatMetricValue('cost', 0)).toBe('$0.0000');
|
||||
});
|
||||
|
||||
it('formats tokens with K suffix for large values', () => {
|
||||
expect(formatMetricValue('tokens', 150000)).toBe('150.0K');
|
||||
expect(formatMetricValue('tokens', 1500)).toBe('1.5K');
|
||||
expect(formatMetricValue('tokens', 500)).toBe('500');
|
||||
});
|
||||
|
||||
it('formats turns as integer', () => {
|
||||
expect(formatMetricValue('turns', 42)).toBe('42');
|
||||
});
|
||||
|
||||
it('formats tools as integer', () => {
|
||||
expect(formatMetricValue('tools', 7)).toBe('7');
|
||||
});
|
||||
|
||||
it('formats duration with minutes suffix', () => {
|
||||
expect(formatMetricValue('duration', 5.3)).toBe('5.3m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — fmtBurnRate', () => {
|
||||
it('shows $0/hr for zero rate', () => {
|
||||
expect(fmtBurnRate(0)).toBe('$0/hr');
|
||||
});
|
||||
|
||||
it('shows cents format for tiny rates', () => {
|
||||
expect(fmtBurnRate(0.005)).toBe('$0.5c/hr');
|
||||
});
|
||||
|
||||
it('shows dollar format for normal rates', () => {
|
||||
expect(fmtBurnRate(2.5)).toBe('$2.50/hr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — fmtPressure', () => {
|
||||
it('shows dash for null', () => {
|
||||
expect(fmtPressure(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('formats as percentage', () => {
|
||||
expect(fmtPressure(0.75)).toBe('75%');
|
||||
expect(fmtPressure(0.5)).toBe('50%');
|
||||
expect(fmtPressure(1)).toBe('100%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — fmtIdle', () => {
|
||||
it('shows dash for zero', () => {
|
||||
expect(fmtIdle(0)).toBe('—');
|
||||
});
|
||||
|
||||
it('shows seconds for short durations', () => {
|
||||
expect(fmtIdle(5000)).toBe('5s');
|
||||
expect(fmtIdle(30000)).toBe('30s');
|
||||
});
|
||||
|
||||
it('shows minutes for medium durations', () => {
|
||||
expect(fmtIdle(120_000)).toBe('2m');
|
||||
expect(fmtIdle(3_599_000)).toBe('59m');
|
||||
});
|
||||
|
||||
it('shows hours and minutes for long durations', () => {
|
||||
expect(fmtIdle(3_600_000)).toBe('1h 0m');
|
||||
expect(fmtIdle(5_400_000)).toBe('1h 30m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — pressureColor', () => {
|
||||
it('returns overlay0 for null', () => {
|
||||
expect(pressureColor(null)).toBe('var(--ctp-overlay0)');
|
||||
});
|
||||
|
||||
it('returns red for critical pressure', () => {
|
||||
expect(pressureColor(0.95)).toBe('var(--ctp-red)');
|
||||
});
|
||||
|
||||
it('returns peach for high pressure', () => {
|
||||
expect(pressureColor(0.8)).toBe('var(--ctp-peach)');
|
||||
});
|
||||
|
||||
it('returns yellow for moderate pressure', () => {
|
||||
expect(pressureColor(0.6)).toBe('var(--ctp-yellow)');
|
||||
});
|
||||
|
||||
it('returns green for low pressure', () => {
|
||||
expect(pressureColor(0.3)).toBe('var(--ctp-green)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetricsPanel — stateColor', () => {
|
||||
it('maps activity states to correct colors', () => {
|
||||
expect(stateColor('running')).toBe('var(--ctp-green)');
|
||||
expect(stateColor('idle')).toBe('var(--ctp-overlay1)');
|
||||
expect(stateColor('stalled')).toBe('var(--ctp-peach)');
|
||||
expect(stateColor('inactive')).toBe('var(--ctp-overlay0)');
|
||||
expect(stateColor('unknown')).toBe('var(--ctp-overlay0)');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
import TaskBoardTab from './TaskBoardTab.svelte';
|
||||
import ArchitectureTab from './ArchitectureTab.svelte';
|
||||
import TestingTab from './TestingTab.svelte';
|
||||
import MetricsPanel from './MetricsPanel.svelte';
|
||||
import { getTerminalTabs, getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||
|
|
@ -34,7 +35,7 @@
|
|||
let mainSessionId = $state<string | null>(null);
|
||||
let terminalExpanded = $state(false);
|
||||
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'tasks' | 'architecture' | 'selenium' | 'tests';
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests';
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
|
|
@ -156,6 +157,11 @@
|
|||
class:active={activeTab === 'memories'}
|
||||
onclick={() => switchTab('memories')}
|
||||
>Memory</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'metrics'}
|
||||
onclick={() => switchTab('metrics')}
|
||||
>Metrics</button>
|
||||
{#if isAgent && agentRole === 'manager'}
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
|
||||
{/if}
|
||||
|
|
@ -199,6 +205,11 @@
|
|||
<MemoriesTab />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['metrics']}
|
||||
<div class="content-pane" style:display={activeTab === 'metrics' ? 'flex' : 'none'}>
|
||||
<MetricsPanel {project} groupId={activeGroup?.id} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['tasks'] && activeGroup}
|
||||
<div class="content-pane" style:display={activeTab === 'tasks' ? 'flex' : 'none'}>
|
||||
<TaskBoardTab groupId={activeGroup.id} projectId={project.id} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue