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 TaskBoardTab from './TaskBoardTab.svelte';
|
||||||
import ArchitectureTab from './ArchitectureTab.svelte';
|
import ArchitectureTab from './ArchitectureTab.svelte';
|
||||||
import TestingTab from './TestingTab.svelte';
|
import TestingTab from './TestingTab.svelte';
|
||||||
|
import MetricsPanel from './MetricsPanel.svelte';
|
||||||
import { getTerminalTabs, getActiveGroup } from '../../stores/workspace.svelte';
|
import { getTerminalTabs, getActiveGroup } from '../../stores/workspace.svelte';
|
||||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||||
|
|
@ -34,7 +35,7 @@
|
||||||
let mainSessionId = $state<string | null>(null);
|
let mainSessionId = $state<string | null>(null);
|
||||||
let terminalExpanded = $state(false);
|
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 activeTab = $state<ProjectTab>('model');
|
||||||
|
|
||||||
let activeGroup = $derived(getActiveGroup());
|
let activeGroup = $derived(getActiveGroup());
|
||||||
|
|
@ -156,6 +157,11 @@
|
||||||
class:active={activeTab === 'memories'}
|
class:active={activeTab === 'memories'}
|
||||||
onclick={() => switchTab('memories')}
|
onclick={() => switchTab('memories')}
|
||||||
>Memory</button>
|
>Memory</button>
|
||||||
|
<button
|
||||||
|
class="ptab"
|
||||||
|
class:active={activeTab === 'metrics'}
|
||||||
|
onclick={() => switchTab('metrics')}
|
||||||
|
>Metrics</button>
|
||||||
{#if isAgent && agentRole === 'manager'}
|
{#if isAgent && agentRole === 'manager'}
|
||||||
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
|
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -199,6 +205,11 @@
|
||||||
<MemoriesTab />
|
<MemoriesTab />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if everActivated['tasks'] && activeGroup}
|
||||||
<div class="content-pane" style:display={activeTab === 'tasks' ? 'flex' : 'none'}>
|
<div class="content-pane" style:display={activeTab === 'tasks' ? 'flex' : 'none'}>
|
||||||
<TaskBoardTab groupId={activeGroup.id} projectId={project.id} />
|
<TaskBoardTab groupId={activeGroup.id} projectId={project.id} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue