Files
Logan CusanoandClaude Opus 5 be79499635
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
Give the nav's dead links somewhere to land
Three of the app's routes were referenced but never existed, so the redesign's
navigation pointed at 404s from several directions.

/dashboard was the post-login and fallback redirect target in nine places --
login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards,
and the marketing header -- but app/dashboard/ was never created. Signing in
normally dropped the user on a 404. The real signed-in home is "/", which
app/page.tsx already renders as LiveView for an authed user with an org, and
which the nav labels "Live"; all nine now point there.

Nav also linked /watch and /network, neither of which existed. /watch is the
alerts screen under its redesign name, so it re-exports app/alerts/page.tsx
and /alerts stays reachable for old links. /network is new: the "my equipment"
hub the redesign moved /nodes, /systems and /tokens behind and then never
built, which had left /systems and /tokens with no entry point in the UI at
all. Its hooks all run before the admin/operator guard, per d041c86.

Separately, the admin page's guard read isAdmin without authLoading, so every
cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away
while the Firebase claims were still resolving. Admin was only reachable by
clicking through from an already-mounted page. Now it waits, like every other
guarded route does.

And /incidents no longer lies about an empty list: a failed Firestore query
leaves `incidents` empty just as a quiet night does, and the page was printing
"No incidents recorded yet" over the top of a missing-composite-index error.
useIncidents already returned `error`; the page just ignored it. It now renders
an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a
failure rather than as silence on the radio.

Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and
indexes still have to be pushed to the live project by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:28:02 -04:00

105 lines
3.5 KiB
TypeScript

"use client";
// The nav's "Network" destination (components/Nav.tsx) — "my equipment".
// The redesign added the link but never the route, so it 404'd and the three
// screens behind it (/nodes, /systems, /tokens) had no entry point in the nav
// at all. This is the hub: it counts what's there, surfaces nodes that still
// need configuring, and hands off to the existing pages.
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
function HubCard({
href,
title,
description,
count,
countLabel,
badge,
}: {
href: string;
title: string;
description: string;
count: number | null;
countLabel: string;
badge?: React.ReactNode;
}) {
return (
<Link href={href} className="block">
<Card hover className="h-full">
<div className="flex items-start justify-between gap-3">
<h2 className="text-ink font-semibold text-sm">{title}</h2>
{badge}
</div>
<p className="text-ink-muted text-xs mt-1.5 leading-snug">{description}</p>
<p className="text-ink text-2xl font-mono mt-4">
{count === null ? "—" : count}
<span className="text-ink-muted text-xs font-sans ml-2">{countLabel}</span>
</p>
</Card>
</Link>
);
}
export default function NetworkPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { nodes, loading: nodesLoading } = useNodes();
const { systems, loading: systemsLoading } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
}, [authLoading, isAdmin, isOperator, router]);
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
if (authLoading || (!isAdmin && !isOperator)) return null;
const pending = nodes.filter((n) => !n.configured);
const online = nodes.filter((n) => n.status === "online" || n.status === "recording");
return (
<div className="space-y-6">
<PageHeader
title="Network"
description="The equipment on your account — field nodes, the radio systems they decode, and the Discord bot tokens they use."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<HubCard
href="/nodes"
title="Nodes"
description="Field SDR nodes: status, location, and per-node configuration."
count={nodesLoading ? null : nodes.length}
countLabel={nodesLoading ? "" : `total · ${online.length} up`}
badge={
pending.length > 0 ? (
<Badge tone="warning">{pending.length} need setup</Badge>
) : undefined
}
/>
<HubCard
href="/systems"
title="Systems"
description="Radio system definitions — control channels, talkgroups, and per-system AI flags."
count={systemsLoading ? null : systems.length}
countLabel={systemsLoading ? "" : "configured"}
/>
<HubCard
href="/tokens"
title="Bot Tokens"
description="Discord bot tokens available for nodes to claim when relaying live audio."
count={null}
countLabel="manage"
/>
</div>
</div>
);
}