feat(FN-4360): complete Step 3 — add ReliabilityView component

Fusion-Task-Id: FN-4360
Fusion-Task-Lineage: 50e17cef-29b0-4adc-aa1c-bffdebe4b43c
This commit is contained in:
Fusion
2026-05-13 22:10:31 -07:00
committed by gsxdsm
parent 98d69a5736
commit e2dc8997bf
4 changed files with 272 additions and 1 deletions

View File

@@ -0,0 +1,87 @@
.reliability-view {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.reliability-card {
padding: var(--space-lg);
border: var(--border-width-thin, 0.0625rem) solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.reliability-headline-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.reliability-headline {
font-size: var(--font-size-2xl, 1.75rem);
font-weight: 700;
}
.reliability-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-lg);
}
.reliability-table {
width: 100%;
border-collapse: collapse;
margin-top: var(--space-sm);
}
.reliability-table th,
.reliability-table td {
text-align: left;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--border-width-thin, 0.0625rem) solid var(--border);
}
.reliability-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xs) 0;
}
.reliability-muted {
color: var(--text-muted);
}
.reliability-histogram {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.reliability-histogram li {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 6fr) minmax(0, 1fr);
align-items: center;
gap: var(--space-sm);
}
.reliability-histogram-bar-wrap {
width: 100%;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-info) 15%, transparent);
overflow: hidden;
}
.reliability-histogram-bar {
height: var(--space-md);
background: var(--color-info);
}
@media (max-width: 768px) {
.reliability-grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import "./ReliabilityView.css";
type ReliabilityResponse = {
windowDays: number;
generatedAt: string;
headline: { inReviewFailureRate7d: number | null; reason?: string };
perDay: Array<{
date: string;
tasksEnteredInReview: number;
tasksBouncedToInProgress: number;
postMergeAuditFailures: { block: number; warn: number; off: number } | null;
fileScopeInvariantFailures: number | null;
recoverAlreadyMergedReviewTasksRecoveries: number | null;
}>;
duration: { p50Ms: number | null; p95Ms: number | null; sampleCount: number; reason?: string };
mergeAttempts: { mean: number | null; max: number | null; histogram: Record<string, number>; reason?: string };
};
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatDuration(value: number | null): string {
if (value === null) {
return "—";
}
const minutes = value / 60_000;
return `${minutes.toFixed(1)}m`;
}
export function ReliabilityView() {
const [data, setData] = useState<ReliabilityResponse | null>(null);
const load = useCallback(async () => {
const response = await fetch("/api/health/reliability");
if (!response.ok) {
throw new Error(`Failed to load reliability metrics (${response.status})`);
}
const payload = (await response.json()) as ReliabilityResponse;
setData(payload);
}, []);
useEffect(() => {
void load();
const pollInterval = setInterval(() => {
void load();
}, 60_000);
return () => clearInterval(pollInterval);
}, [load]);
const headlineColorVar = useMemo(() => {
const rate = data?.headline.inReviewFailureRate7d;
if (rate === null || rate === undefined) {
return "var(--text-muted)";
}
if (rate < 0.05) {
return "var(--color-success)";
}
if (rate < 0.1) {
return "var(--color-warning)";
}
return "var(--color-error)";
}, [data]);
return (
<section className="reliability-view">
<div className="card reliability-card reliability-headline-card">
<h2>Reliability</h2>
<div className="reliability-headline" style={{ color: headlineColorVar }}>
{data?.headline.inReviewFailureRate7d === null || data?.headline.inReviewFailureRate7d === undefined
? `Insufficient data — ${data?.headline.reason ?? "unknown"}`
: formatPercent(data.headline.inReviewFailureRate7d)}
</div>
</div>
<div className="reliability-grid">
<div className="card reliability-card">
<h3>In-review flow</h3>
<table className="reliability-table">
<thead>
<tr>
<th>Date</th>
<th>Entered</th>
<th>Bounced</th>
</tr>
</thead>
<tbody>
{data?.perDay.map((row) => (
<tr key={row.date}>
<td>{row.date}</td>
<td>{row.tasksEnteredInReview}</td>
<td>{row.tasksBouncedToInProgress}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card reliability-card">
<h3>Duration</h3>
<div className="reliability-stat-row"><span>P50</span><strong>{formatDuration(data?.duration.p50Ms ?? null)}</strong></div>
<div className="reliability-stat-row"><span>P95</span><strong>{formatDuration(data?.duration.p95Ms ?? null)}</strong></div>
<div className="reliability-muted">Samples: {data?.duration.sampleCount ?? 0}</div>
</div>
<div className="card reliability-card">
<h3>Merge attempts</h3>
<div className="reliability-stat-row"><span>Mean</span><strong>{data?.mergeAttempts.mean?.toFixed(2) ?? "—"}</strong></div>
<div className="reliability-stat-row"><span>Max</span><strong>{data?.mergeAttempts.max ?? "—"}</strong></div>
<ul className="reliability-histogram">
{Object.entries(data?.mergeAttempts.histogram ?? {}).map(([bucket, count]) => (
<li key={bucket}>
<span>{bucket}</span>
<div className="reliability-histogram-bar-wrap"><div className="reliability-histogram-bar" style={{ width: `${Math.min(count * 20, 100)}%` }} /></div>
<strong>{count}</strong>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,60 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReliabilityView } from "../ReliabilityView";
describe("ReliabilityView", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders headline percent and per-day row", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
windowDays: 7,
generatedAt: "2026-05-13T00:00:00.000Z",
headline: { inReviewFailureRate7d: 0.2 },
perDay: [
{
date: "2026-05-13",
tasksEnteredInReview: 10,
tasksBouncedToInProgress: 2,
postMergeAuditFailures: null,
fileScopeInvariantFailures: null,
recoverAlreadyMergedReviewTasksRecoveries: null,
},
],
duration: { p50Ms: 60_000, p95Ms: 120_000, sampleCount: 3 },
mergeAttempts: { mean: 1.2, max: 2, histogram: { "1": 1 } },
}),
} as Response);
render(<ReliabilityView />);
await waitFor(() => {
expect(screen.getByText("20.0%")).toBeInTheDocument();
});
expect(screen.getByText("2026-05-13")).toBeInTheDocument();
});
it("renders null headline reason gracefully", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
windowDays: 7,
generatedAt: "2026-05-13T00:00:00.000Z",
headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" },
perDay: [],
duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" },
mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" },
}),
} as Response);
render(<ReliabilityView />);
await waitFor(() => {
expect(screen.getByText("Insufficient data — no-in-review-entries")).toBeInTheDocument();
});
});
});

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",