Rebuild the frontend as a product rather than an internal tool
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s

The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.

A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.

Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.

Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.

The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.

Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.

Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
This commit is contained in:
Logan Cusano
2026-08-16 19:34:47 -04:00
parent 6d5eb4c5f2
commit 53965e1a19
35 changed files with 2395 additions and 225 deletions
+185 -171
View File
@@ -1,49 +1,42 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents";
import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types";
import { useState } from "react";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { severityBadge, severityRank } from "@/lib/severity";
import { TypeBadge } from "@/components/IncidentBadges";
const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300",
};
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
// `severityBadge()` already returns null for the legacy "unknown" value.
const SEVERITY_COLORS: Record<string, string> = {
major: "bg-red-950 text-red-400",
moderate: "bg-orange-950 text-orange-400",
minor: "bg-gray-800 text-gray-400",
};
type SeverityFilter = "all" | "minor" | "moderate" | "major";
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "minor", label: "Minor+" },
{ key: "moderate", label: "Moderate+" },
{ key: "major", label: "Major only" },
];
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
function severityBadge(severity: string | null | undefined) {
if (!severity || severity === "unknown") return null;
const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400";
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{severity}
</span>
);
}
function typeBadge(type: string | null) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{type ?? "other"}
</span>
);
}
type SortMode = "recent" | "severity";
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
// ---------------------------------------------------------------------------
// Rows / cards
// ---------------------------------------------------------------------------
function IncidentRow({ incident, isAdmin, onResolve }: {
incident: IncidentRecord;
isAdmin: boolean;
@@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
return (
<tr
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
>
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
incident.status === "active"
? "bg-green-900 text-green-300"
: "bg-gray-800 text-gray-400"
}`}>
{incident.status}
</span>
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
</td>
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
@@ -73,26 +60,98 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
<td className="px-4 py-3">
{isAdmin && incident.status === "active" && (
<button
<Button
size="sm" variant="secondary"
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
>
Resolve
</button>
</Button>
)}
</td>
</tr>
);
}
function CreateModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (body: object) => Promise<void>;
function IncidentCards({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
const [title, setTitle] = useState("");
const [type, setType] = useState("other");
const router = useRouter();
return (
<div className="space-y-2">
{incidents.map((inc) => (
<Card
key={inc.incident_id}
padding="sm"
hover
className="cursor-pointer active:bg-gray-800"
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<TypeBadge type={inc.type} />
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
</div>
{isAdmin && inc.status === "active" && (
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
Resolve
</Button>
)}
</div>
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
<div className="flex items-center gap-2 mt-1">
{severityBadge(inc.severity)}
<p className="text-gray-500 text-xs font-mono">
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
</Card>
))}
</div>
);
}
function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
return (
<>
<div className="sm:hidden">
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
</div>
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Severity</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
))}
</tbody>
</table>
</div>
</>
);
}
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
const [title, setTitle] = useState("");
const [type, setType] = useState("other");
const [summary, setSummary] = useState("");
const [saving, setSaving] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
>
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
<h2 className="text-white font-bold">Create Incident</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Title</label>
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
/>
</div>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? "Creating…" : "Create"}
</button>
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
</div>
</form>
</div>
);
}
function IncidentCards({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
const router = useRouter();
return (
<div className="space-y-2">
{incidents.map((inc) => (
<div
key={inc.incident_id}
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
{typeBadge(inc.type)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
}`}>{inc.status}</span>
</div>
{isAdmin && inc.status === "active" && (
<button
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
>
Resolve
</button>
)}
</div>
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
<div className="flex items-center gap-2 mt-1">
{severityBadge(inc.severity)}
<p className="text-gray-500 text-xs font-mono">
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
</div>
))}
</div>
);
}
function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
return (
<>
{/* Mobile card view */}
<div className="sm:hidden">
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
</div>
{/* Desktop table view */}
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Severity</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onResolve={onResolve}
/>
))}
</tbody>
</table>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function IncidentsPage() {
const { isAdmin } = useAuth();
const { incidents, loading } = useIncidents();
const { isAdmin } = useAuth();
const { incidents, loading } = useIncidents();
const [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const active = incidents.filter((i) => i.status === "active");
const resolved = incidents.filter((i) => i.status === "resolved");
const filtered = useMemo(() => {
const threshold = FILTER_THRESHOLD[severityFilter];
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
if (sortMode === "severity") {
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
}
return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode]);
const active = filtered.filter((i) => i.status === "active");
const resolved = filtered.filter((i) => i.status === "resolved");
const hiddenCount = incidents.length - filtered.length;
async function handleResolve(id: string) {
try { await c2api.updateIncident(id, { status: "resolved" }); }
@@ -253,30 +232,53 @@ export default function IncidentsPage() {
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
{active.length > 0 && (
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
{active.length} active
</span>
)}
<div className="space-y-6">
<PageHeader
title="Incidents"
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
/>
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => (
<button
key={key}
onClick={() => setSeverityFilter(key)}
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{label}
</button>
))}
</div>
{isAdmin && (
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
Sort
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
>
+ Create Incident
</button>
)}
<option value="recent">Most recent</option>
<option value="severity">Highest severity</option>
</select>
</label>
</div>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<SkeletonCard /><SkeletonCard />
</div>
) : (
<>
{hiddenCount > 0 && (
<p className="text-xs text-gray-600 font-mono">
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
</p>
)}
{active.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
@@ -291,8 +293,20 @@ export default function IncidentsPage() {
</section>
)}
{incidents.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
{filtered.length === 0 && (
<EmptyState
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
description={
incidents.length === 0
? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold."
}
action={
incidents.length > 0 && severityFilter !== "all" ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
) : undefined
}
/>
)}
</>
)}