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:
87
packages/dashboard/app/components/ReliabilityView.css
Normal file
87
packages/dashboard/app/components/ReliabilityView.css
Normal 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);
|
||||
}
|
||||
}
|
||||
124
packages/dashboard/app/components/ReliabilityView.tsx
Normal file
124
packages/dashboard/app/components/ReliabilityView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user