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
+78 -28
View File
@@ -1,21 +1,52 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
import { useCalls, useActiveCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { useActiveIncidents } from "@/lib/useIncidents";
import { NodeCard } from "@/components/NodeCard";
import { CallRow } from "@/components/CallRow";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { TypeBadge } from "@/components/IncidentBadges";
import { severityBadge, severityRank } from "@/lib/severity";
import { useState } from "react";
import type { NodeRecord } from "@/lib/types";
import type { NodeRecord, IncidentRecord } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<Card>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
</div>
</Card>
);
}
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
const router = useRouter();
return (
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<TypeBadge type={incident.type} />
{severityBadge(incident.severity)}
</div>
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
<p className="text-gray-500 text-xs font-mono mt-2">
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</Card>
);
}
@@ -25,6 +56,7 @@ export default function DashboardPage() {
const { calls, error: callsError } = useCalls(20);
const activeCalls = useActiveCalls();
const { systems, error: systemsError } = useSystems();
const activeIncidents = useActiveIncidents();
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const { isAdmin } = useAuth();
@@ -33,44 +65,66 @@ export default function DashboardPage() {
const fsError = nodesError ?? callsError ?? systemsError;
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
// Worst-first: the incident that most needs a human's attention leads the panel.
const sortedIncidents = [...activeIncidents].sort(
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
);
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
{fsError && (
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
</div>
)}
return (
<div className="space-y-8">
<PageHeader
title="Dashboard"
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
/>
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
{/* Pending config banner */}
{pending.length > 0 && (
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
<p className="text-indigo-300 text-sm font-mono">
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
</p>
<button
onClick={() => setConfigNode(pending[0])}
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
>
Configure now
</button>
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Total Nodes" value={nodes.length} />
<StatCard label="Systems" value={systems.length} />
</div>
{/* Active incidents — the primary "what's happening" view */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
View all →
</Link>
</div>
{sortedIncidents.length === 0 ? (
<EmptyState
title="No active incidents"
description="Incidents appear here automatically as calls correlate into events."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedIncidents.slice(0, 6).map((inc) => (
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
))}
</div>
)}
</section>
{/* Nodes */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
{nodes.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => (
@@ -84,9 +138,9 @@ export default function DashboardPage() {
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
{calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
<EmptyState title="No calls recorded yet" />
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<Card padding="none" className="overflow-hidden overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
@@ -104,16 +158,12 @@ export default function DashboardPage() {
))}
</tbody>
</table>
</div>
</Card>
)}
</section>
{configNode && (
<NodeConfigModal
node={configNode}
systems={systems}
onClose={() => setConfigNode(null)}
/>
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
)}
</div>
);