feat(web): PR5 — surface Incomplete/AwaitingUser in the UI

- sessionStatus.ts: isTerminalStatus includes Incomplete.
- SessionStatusBadge: Incomplete = neutral slate (clearly not a red failure),
  AwaitingUser = sky/blue 'still going'; clock affordance extended to Incomplete.
- Fleet Health: FleetHealthStats.Incomplete (model + BuildFleetHealthPayload) +
  web interface + a dedicated slate Incomplete stat card (FleetStatCard gained a
  'slate' color), grid widened to 5.
- Main dashboard KPI row unchanged (its Success Rate is already terminal-only/honest).

Verified: web tsc --noEmit clean; backend Functions.Tests 2709 pass.
This commit is contained in:
Claude
2026-07-08 15:30:47 +00:00
parent 96ad65f715
commit a2a5d2fc0e
8 changed files with 44 additions and 13 deletions
@@ -89,13 +89,17 @@ timeline AND let the backend terminalize precisely instead of guessing with grac
- [ ] **Deferred to PR5:** Fleet Health payload Incomplete count (its SuccessRate is documented as
"over all sessions" with equivalence tests — fold the Incomplete surfacing in with the web work).
## PR5 — Web surface
## PR5 — Web surface ✅ done (typecheck clean)
- [ ] Mirror enum + `isTerminalStatus` (`utils/sessionStatus.ts`): `Incomplete` terminal,
`AwaitingUser` non-terminal.
- [ ] `SessionStatusBadge.tsx` colours/labels for both new states.
- [ ] Dashboard / Fleet Health cards render the third bucket; failure-rate copy updated.
- [ ] `lib/__tests__/sessionStatus.test.ts` updated.
- [x] `utils/sessionStatus.ts`: `isTerminalStatus` includes `Incomplete`; docstring notes AwaitingUser non-terminal.
- [x] `SessionStatusBadge.tsx`: `Incomplete` = neutral slate (clearly not red), `AwaitingUser` = sky/blue
("still going"); ⏱️ affordance extended to Incomplete (the silence family).
- [x] Fleet Health: `FleetHealthStats.Incomplete` (C# model + `MetricsMath.BuildFleetHealthPayload`) +
web interface + a dedicated slate "Incomplete" stat card (`FleetStatCard` gained a `slate` color);
grid widened to 5.
- [x] Main dashboard KPI row left as-is (its Success Rate is already the honest terminal rate); the third
state is surfaced via the badge everywhere, the Fleet Health card, and the metrics summary `incomplete`.
- [x] Verified: `tsc --noEmit` clean; backend suite 2709 pass.
## PR6 — Backfill (optional, one-shot)
@@ -99,7 +99,7 @@ public static class MetricsMath
/// </summary>
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
{
int succeeded = 0, failed = 0, inProgress = 0;
int succeeded = 0, failed = 0, inProgress = 0, incomplete = 0;
long completedDurationSeconds = 0;
int completedWithDurationCount = 0;
@@ -110,6 +110,7 @@ public static class MetricsMath
case SessionStatus.Succeeded: succeeded++; break;
case SessionStatus.Failed: failed++; break;
case SessionStatus.InProgress: inProgress++; break;
case SessionStatus.Incomplete: incomplete++; break;
}
if (s.Status != SessionStatus.InProgress && s.DurationSeconds is int d && d > 0)
@@ -126,6 +127,7 @@ public static class MetricsMath
Succeeded = succeeded,
Failed = failed,
InProgress = inProgress,
Incomplete = incomplete,
SuccessRate = total > 0 ? Math.Round((double)succeeded / total * 100, 1) : 0,
AvgDurationMinutes = completedWithDurationCount > 0
? (int)Math.Round((double)completedDurationSeconds / completedWithDurationCount / 60.0, MidpointRounding.AwayFromZero)
@@ -34,6 +34,8 @@ namespace AutopilotMonitor.Shared.Models
public int Succeeded { get; set; }
public int Failed { get; set; }
public int InProgress { get; set; }
/// <summary>Terminal, non-failure sessions (timeout reclassification). Surfaced as its own count; not a failure.</summary>
public int Incomplete { get; set; }
/// <summary>Succeeded / total * 100 (one decimal). Note: over all sessions, not just terminal.</summary>
public double SuccessRate { get; set; }
/// <summary>Average duration in minutes over non-in-progress sessions that carry a duration.</summary>
@@ -9,13 +9,14 @@ export default function FleetStatCard({
title: string;
value: string;
subtitle: string;
color: "green" | "blue" | "red" | "yellow";
color: "green" | "blue" | "red" | "yellow" | "slate";
}) {
const colorClasses = {
green: "border-green-500 bg-green-50",
blue: "border-blue-500 bg-blue-50",
red: "border-red-500 bg-red-50",
yellow: "border-yellow-500 bg-yellow-50",
slate: "border-slate-400 bg-slate-50",
};
const valueColors = {
@@ -23,6 +24,7 @@ export default function FleetStatCard({
blue: "text-blue-700",
red: "text-red-700",
yellow: "text-yellow-700",
slate: "text-slate-600",
};
return (
@@ -14,6 +14,8 @@ export interface FleetHealthStats {
succeeded: number;
failed: number;
inProgress: number;
/** Terminal, non-failure (timeout reclassification). Surfaced separately; not counted as a failure. */
incomplete: number;
successRate: number;
avgDurationMinutes: number;
}
@@ -124,6 +124,7 @@ export default function FleetHealthPage() {
succeeded: s?.succeeded ?? 0,
failed: s?.failed ?? 0,
inProgress: s?.inProgress ?? 0,
incomplete: s?.incomplete ?? 0,
successRate: s?.successRate ?? 0,
avgDuration: s?.avgDurationMinutes ?? 0,
};
@@ -223,7 +224,7 @@ export default function FleetHealthPage() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Top Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-5 mb-8">
<FleetStatCard
title="Success Rate"
value={`${stats.successRate.toFixed(1)}%`}
@@ -248,6 +249,12 @@ export default function FleetHealthPage() {
subtitle="Needs attention"
color="red"
/>
<FleetStatCard
title="Incomplete"
value={stats.incomplete.toString()}
subtitle="No completion signal — not a failure"
color="slate"
/>
<FleetStatCard
title="Active Now"
value={stats.inProgress.toString()}
@@ -10,8 +10,12 @@ const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
InProgress: { color: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", text: "Pending" },
Stalled: { color: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", text: "Stalled" },
// Non-terminal: Device Setup done, waiting on the user / Account-Setup phase. Blue-ish "still going".
AwaitingUser: { color: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300", text: "Awaiting User" },
Succeeded: { color: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300", text: "Failed" },
// Terminal but NOT a failure — no completion or explicit failure signal. Neutral slate, clearly not red.
Incomplete: { color: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300", text: "Incomplete" },
Unknown: { color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", text: "Unknown" },
};
@@ -26,7 +30,11 @@ export function SessionStatusBadge({
adminMarkedAction?: string | null;
}) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.Unknown;
const isTimeout = status === "Failed" && !!failureReason?.toLowerCase().includes("timed out");
// The ⏱️ affordance marks the silence/timeout family: a "timed out" Failed, or any Incomplete
// (which is, by definition, a session that went silent without a completion).
const isTimeout =
(status === "Failed" && !!failureReason?.toLowerCase().includes("timed out")) ||
status === "Incomplete";
const pill = (
<span
@@ -1,8 +1,12 @@
// Session status helpers. Mirror of the backend SessionStatus enum
// (src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs).
// Non-terminal: InProgress, Pending, Stalled, Unknown.
// Terminal: Succeeded, Failed.
// Non-terminal: InProgress, Pending, Stalled, AwaitingUser, Unknown.
// Terminal: Succeeded, Failed, Incomplete.
//
// AwaitingUser (Device Setup done, user/Account-Setup phase pending) and Incomplete
// (terminal, non-failure — no completion or explicit failure was observed) come from the
// timeout reclassification, see docs/design/enrollment-status-reclassification.md.
export function isTerminalStatus(status: string | null | undefined): boolean {
return status === "Succeeded" || status === "Failed";
return status === "Succeeded" || status === "Failed" || status === "Incomplete";
}