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
1 change: 1 addition & 0 deletions content/docs/user-dashboard-stats.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,6 @@ Cost data is visualized directly within the Usage Graph and Composition views, f
The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available.

It is backed by:
- `GET /api/stats/header-throughput?projectId=...&window=...` β€” compact app and selected-project token throughput for dashboard header metrics.
- `GET /api/projects/:projectId/stats?window=...` β€” aggregated metrics for charts and summaries.
- `GET /api/projects/:projectId/execution/invocations` β€” raw MCP invocation log.
10 changes: 10 additions & 0 deletions dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import type {
ExecutionUsageTotals,
ExecutionUsageBucketSummary,
ExecutionStatsEntitySummary,
HeaderTokenThroughputProjectSnapshot,
HeaderTokenThroughputQuery,
HeaderTokenThroughputSnapshot,
HeaderTokenThroughputTotals,
HeaderTokenThroughputWindow,
ProjectExecutionStatsChartSeries,
ProjectExecutionStatsSnapshot,
ProjectStatsQuery,
Expand Down Expand Up @@ -132,6 +137,11 @@ export type {
ExecutionUsageTotals,
ExecutionUsageBucketSummary,
ExecutionStatsEntitySummary,
HeaderTokenThroughputProjectSnapshot,
HeaderTokenThroughputQuery,
HeaderTokenThroughputSnapshot,
HeaderTokenThroughputTotals,
HeaderTokenThroughputWindow,
ProjectExecutionStatsChartSeries,
ProjectExecutionStatsSnapshot,
ProjectStatsQuery,
Expand Down
50 changes: 38 additions & 12 deletions dashboard/src/v2/components/DockerStatusMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { FunctionComponent } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { Box, Info, Play, Square, Terminal } from "lucide-preact";
import { Box, CircleAlert, Play, Square, Terminal } from "lucide-preact";
import { useFocusTrap } from "../hooks/use-focus-trap";
import { fetchOnboardingReadiness } from "../../lib/api/dashboard-api.js";
import type { OnboardingRuntimeReadiness } from "../../types.js";
Expand Down Expand Up @@ -127,13 +127,33 @@ export const DockerStatusMenu: FunctionComponent = () => {

const activeContainers = containers.filter(c => c.state === "running");
const clusterNotReady = readiness?.cluster.status === "not_ready";
const dockerStatusLabel = clusterNotReady
? `Docker Status: runtime not ready, ${activeContainers.length} active containers`
: `Docker Status: ${activeContainers.length} active containers`;

return (
<div className="relative inline-flex items-center gap-2" ref={containerRef}>
{clusterNotReady ? (
<div className="hidden h-9 items-center gap-1.5 rounded-full border border-status-amber/25 bg-status-amber/10 px-3 text-[10px] font-bold uppercase tracking-[0.14em] text-status-amber md:inline-flex">
<Info className="h-3.5 w-3.5" strokeWidth={2.4} />
Cluster not ready
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="sr-only rounded-full border border-status-red/35 bg-status-red/[0.12] px-3 text-[10px] font-bold uppercase tracking-[0.14em] text-status-red shadow-[0_0_18px_rgba(211,47,47,0.16)] dark:bg-status-red/15 md:not-sr-only md:inline-flex md:h-9 md:items-center md:gap-2"
>
<span
className="relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white"
aria-hidden="true"
data-testid="runtime-critical-marker"
>
<span className="absolute inset-0 rounded-full bg-status-red/35 motion-safe:animate-ping motion-reduce:animate-none" />
<span
className="relative flex h-5 w-5 items-center justify-center rounded-full bg-status-red text-[13px] font-black leading-none text-white shadow-[0_0_10px_rgba(211,47,47,0.36)] motion-safe:animate-pulse motion-reduce:animate-none"
data-testid="runtime-critical-glyph"
>
!
</span>
</span>
Runtime not ready
</div>
) : null}
<div
Expand All @@ -145,7 +165,7 @@ export const DockerStatusMenu: FunctionComponent = () => {
<button
type="button"
data-tour-id="docker-containers"
aria-label={`Docker Status: ${activeContainers.length} active containers`}
aria-label={dockerStatusLabel}
aria-haspopup="dialog"
aria-expanded={interactionState !== 'closed'}
aria-controls={interactionState !== 'closed' ? menuId : undefined}
Expand Down Expand Up @@ -180,11 +200,17 @@ export const DockerStatusMenu: FunctionComponent = () => {
>
<div className="relative">
<Box aria-hidden="true" className={`w-4 h-4 transition-colors ${
activeContainers.length > 0
clusterNotReady
? "text-status-red"
: activeContainers.length > 0
? "text-signal-500 dark:text-signal-400"
: "text-slate-500 dark:text-slate-400"
} group-hover:text-slate-900 dark:group-hover:text-white`} strokeWidth={1.5} />
{activeContainers.length > 0 && (
{clusterNotReady ? (
<span className="absolute -top-2 -right-2 flex h-4 w-4 items-center justify-center rounded-full bg-status-red text-[10px] font-black leading-none text-white shadow-[0_0_10px_rgba(211,47,47,0.35)] ring-1 ring-[#F9F8F4] motion-safe:animate-pulse motion-reduce:animate-none dark:ring-void-900" aria-hidden="true">
!
</span>
) : activeContainers.length > 0 && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-signal-500 shadow-[0_0_6px_rgba(0,224,160,0.8)] ring-1 ring-[#F9F8F4] dark:ring-void-900" />
)}
</div>
Expand All @@ -204,8 +230,8 @@ export const DockerStatusMenu: FunctionComponent = () => {
Docker Containers
</span>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-black/[0.03] dark:bg-white/[0.03]">
<span className={`w-1.5 h-1.5 rounded-full ${clusterNotReady ? "bg-status-amber" : "bg-signal-500 motion-safe:animate-pulse"}`} />
<span className="text-[10px] font-mono font-medium text-slate-500 dark:text-slate-400">
<span className={`w-1.5 h-1.5 rounded-full ${clusterNotReady ? "bg-status-red motion-safe:animate-pulse motion-reduce:animate-none" : "bg-signal-500 motion-safe:animate-pulse"}`} />
<span className={`text-[10px] font-mono font-medium ${clusterNotReady ? "text-status-red" : "text-slate-500 dark:text-slate-400"}`}>
{clusterNotReady ? "Not Ready" : `${activeContainers.length} Active`}
</span>
</div>
Expand All @@ -218,8 +244,8 @@ export const DockerStatusMenu: FunctionComponent = () => {
</div>
) : clusterNotReady ? (
<div className="flex flex-col gap-3 px-4 py-5">
<div className="flex items-start gap-3 rounded-xl border border-status-amber/20 bg-status-amber/10 p-3">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-status-amber" strokeWidth={2.4} />
<div className="flex items-start gap-3 rounded-xl border border-status-red/25 bg-status-red/10 p-3">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-status-red" strokeWidth={2.4} aria-hidden="true" />
<div>
<div className="text-sm font-bold text-slate-800 dark:text-slate-100">Docker is mandatory</div>
<div className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Expand All @@ -231,7 +257,7 @@ export const DockerStatusMenu: FunctionComponent = () => {
<div key={dependency.id} className="rounded-xl border border-black/[0.05] bg-black/[0.02] p-3 dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs font-bold text-slate-700 dark:text-slate-200">{dependency.label}</span>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${dependency.status === "ready" ? "bg-signal-500/10 text-signal-600 dark:text-signal-300" : "bg-status-amber/10 text-status-amber"}`}>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${dependency.status === "ready" ? "bg-signal-500/10 text-signal-600 dark:text-signal-300" : "bg-status-red/10 text-status-red"}`}>
{dependency.status}
</span>
</div>
Expand Down
133 changes: 104 additions & 29 deletions dashboard/src/v2/components/top-nav/TelemetryStats.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { FunctionComponent } from "preact";
import { useMemo } from "preact/hooks";
import type { HeaderTokenThroughputScopeViewModel } from "../../lib/header-token-throughput.js";
import { buildHeaderTokenThroughputViewModel } from "../../lib/header-token-throughput.js";
import { useHeaderTokenThroughput } from "../../hooks/use-header-token-throughput.js";
import { useProjectTasks } from "../../hooks/use-project-tasks.js";
import { RollingNumber } from "../ui/RollingNumber.js";
import type { Sprint, Task } from "../../types.js";
Expand All @@ -9,49 +12,121 @@ interface TelemetryStatsProps {
sprints: Sprint[];
}

const intensityClasses: Record<HeaderTokenThroughputScopeViewModel["intensity"], string> = {
idle: "bg-slate-300 dark:bg-slate-600",
low: "bg-signal-400 shadow-[0_0_10px_rgba(0,224,160,0.25)]",
medium: "bg-signal-500 shadow-[0_0_14px_rgba(0,224,160,0.36)]",
high: "bg-ember-400 shadow-[0_0_16px_rgba(255,184,0,0.4)]",
};

const countDotClasses = {
running: "bg-emerald-500",
queued: "bg-amber-400",
idle: "bg-slate-300 dark:bg-slate-600",
};

const ThroughputBars: FunctionComponent<{ metric: HeaderTokenThroughputScopeViewModel }> = ({ metric }) => {
const heightClass = metric.intensity === "high"
? ["h-2", "h-3", "h-4"]
: metric.intensity === "medium"
? ["h-1.5", "h-2.5", "h-3.5"]
: metric.intensity === "low"
? ["h-1", "h-2", "h-2.5"]
: ["h-1", "h-1", "h-1"];

return (
<span aria-hidden="true" className="flex h-4 w-5 shrink-0 items-end justify-center gap-0.5">
{heightClass.map((height, index) => (
<span
key={`${metric.scope}-${index}`}
className={`w-1 rounded-full ${height} ${intensityClasses[metric.intensity]} ${
metric.hasActivity ? "motion-safe:animate-pulse motion-reduce:animate-none" : ""
}`}
/>
))}
</span>
);
};

const ThroughputMetric: FunctionComponent<{ metric: HeaderTokenThroughputScopeViewModel; compact?: boolean }> = ({ metric, compact }) => (
<div className={`flex min-w-0 items-center gap-2 px-2 ${compact ? "max-w-[5.5rem]" : "max-w-[8.5rem] xl:max-w-[10rem]"}`}>
<ThroughputBars metric={metric} />
<div className="grid min-w-0 grid-rows-[auto_auto] gap-0.5">
<div className="flex min-w-0 items-baseline gap-1">
<span className="min-w-0 truncate font-mono text-[13px] font-black leading-none text-slate-800 tabular-nums dark:text-slate-100">
{metric.rateValueLabel}
</span>
<span className="shrink-0 text-[9px] font-bold uppercase leading-none text-slate-400 dark:text-slate-500">
{metric.rateUnitLabel}
</span>
</div>
<div className="flex min-w-0 items-center gap-1 text-[9px] font-bold uppercase leading-none text-slate-400 dark:text-slate-500">
<span className="min-w-0 truncate">{metric.label}</span>
<span className="hidden min-w-0 truncate 2xl:inline">{metric.hasActivity ? metric.totalLabel : metric.emptyLabel}</span>
</div>
</div>
</div>
);

const TaskCountMetric: FunctionComponent<{
label: "running" | "queued";
value: number;
}> = ({ label, value }) => (
<div className="flex min-w-0 items-center gap-2 px-2">
<span className="relative flex h-2 w-2 shrink-0">
{label === "running" && value > 0 && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 motion-safe:animate-ping motion-reduce:animate-none" />
)}
<span className={`relative inline-flex h-2 w-2 rounded-full ${value > 0 ? countDotClasses[label] : countDotClasses.idle}`} />
</span>
<div className="flex min-w-0 items-baseline gap-1">
<span className="font-mono text-sm font-semibold leading-none text-slate-700 tabular-nums dark:text-slate-200">
<RollingNumber value={value} />
</span>
<span className="hidden text-[10px] font-medium leading-none text-slate-400 xl:inline">{label}</span>
<span className="text-[10px] font-medium leading-none text-slate-400 xl:hidden">{label === "running" ? "run" : "queue"}</span>
</div>
</div>
);

export const TelemetryStats: FunctionComponent<TelemetryStatsProps> = ({ projectId, sprints }) => {
const activeSprintIds = useMemo(
() => new Set((sprints || []).filter((s) => s.status === "running").map((s) => s.id)),
[sprints],
);
const throughput = useHeaderTokenThroughput(projectId, "1h");
const { tasks } = useProjectTasks(projectId, [], sprints, null, {
enabled: activeSprintIds.size > 0,
});

const allTasks = tasks || [];
const runningCount = allTasks.filter((t: Task) => t.status === "in_progress" && activeSprintIds.has(t.sprintId)).length;
const queuedCount = allTasks.filter((t: Task) => t.status === "pending" && activeSprintIds.has(t.sprintId)).length;
const tokenView = useMemo(() => buildHeaderTokenThroughputViewModel({
snapshot: throughput.snapshot,
projectId,
window: "1h",
loading: throughput.loading,
error: throughput.error,
}), [projectId, throughput.error, throughput.loading, throughput.snapshot]);

return (
<div className="hidden h-9 items-center gap-0.5 rounded-xl border border-black/[0.06] bg-black/[0.02] px-1 dark:border-white/[0.06] dark:bg-white/[0.02] xl:flex">
{/* Running tasks */}
<div className="flex items-center gap-2 px-2.5">
<span className="relative flex h-2 w-2">
{runningCount > 0 && (
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-emerald-400 opacity-75" />
)}
<span className={`relative inline-flex h-2 w-2 rounded-full ${runningCount > 0 ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600"}`} />
</span>
<div className="flex items-baseline gap-1.5">
<span className="font-mono text-sm font-semibold leading-none text-slate-700 dark:text-slate-200">
<RollingNumber value={runningCount} />
</span>
<span className="text-[10px] font-medium leading-none text-slate-400">running</span>
</div>
</div>

<div className="h-4 w-px bg-black/[0.06] dark:bg-white/[0.06]" />

{/* Queued tasks */}
<div className="flex items-center gap-2 px-2.5">
<span className={`inline-flex h-2 w-2 rounded-full ${queuedCount > 0 ? "bg-amber-400" : "bg-slate-300 dark:bg-slate-600"}`} />
<div className="flex items-baseline gap-1.5">
<span className="font-mono text-sm font-semibold leading-none text-slate-700 dark:text-slate-200">
<RollingNumber value={queuedCount} />
</span>
<span className="text-[10px] font-medium leading-none text-slate-400">queued</span>
</div>
</div>
<div
role="group"
aria-label={tokenView.ariaLabel}
aria-busy={tokenView.isLoading ? "true" : "false"}
className="hidden h-9 min-w-0 max-w-[min(48vw,29rem)] items-center gap-0.5 overflow-hidden rounded-xl border border-black/[0.06] bg-black/[0.025] px-1 dark:border-white/[0.06] dark:bg-white/[0.025] lg:flex 2xl:max-w-none"
>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{tokenView.statusLabel}
</span>
<ThroughputMetric metric={tokenView.app} />
<div className="h-4 w-px shrink-0 bg-black/[0.06] dark:bg-white/[0.06]" />
<ThroughputMetric metric={tokenView.project} compact />
<div className="hidden h-4 w-px shrink-0 bg-black/[0.06] dark:bg-white/[0.06] xl:block" />
<TaskCountMetric label="running" value={runningCount} />
<div className="h-4 w-px shrink-0 bg-black/[0.06] dark:bg-white/[0.06]" />
<TaskCountMetric label="queued" value={queuedCount} />
</div>
);
};
Loading