From 3b22f31f1469b53632fc03419e1ac485f3c0eccb Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 27 Jul 2026 15:53:05 -0400 Subject: [PATCH] fix(nodes): make per-node pages reactive to the live fleet The per-node pages (node detail, config, issue, profiles, rekey, root detail) read getNode() synchronously from the module store, which in live mode is only populated by the useNodes polling hook mounted on the list pages. On a direct load or refresh of /nodes/ the store is empty, so the page rendered "Node not found" and never recovered when the fetch arrived. Add a reactive useNode(name) hook (subscribes via useNodes) and use it on all per-node pages so they fetch and re-render. getNode stays for in-page selectors that read an already-loaded store. Refs #77 --- src/lib/nodes.ts | 9 +++++++++ src/pages/node-config.tsx | 4 ++-- src/pages/node-detail.tsx | 4 ++-- src/pages/node-issue.tsx | 4 ++-- src/pages/node-profiles.test.tsx | 2 +- src/pages/node-profiles.tsx | 4 ++-- src/pages/node-rekey.tsx | 4 ++-- src/pages/root-detail.tsx | 4 ++-- 8 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/lib/nodes.ts b/src/lib/nodes.ts index 55f4b14..d1d9aa1 100644 --- a/src/lib/nodes.ts +++ b/src/lib/nodes.ts @@ -125,6 +125,15 @@ export const useNodes = (): Node[] => { ); }; +// useNode is the reactive single-node lookup for per-node pages. It subscribes +// via useNodes so the live fleet is fetched and the page re-renders when it +// arrives — a bare getNode() reads the module store synchronously and, on a +// direct load or refresh in live mode, finds an empty store and never recovers. +export const useNode = (name: string | undefined): Node | undefined => { + const all = useNodes(); + return name ? all.find((n) => n.name === name) : undefined; +}; + // The trust chain from the root down to this node, following parentCn. Guards a // missing parent link and a cycle so a broken fixture can't loop forever. export const chainToRoot = (node: Node): Node[] => { diff --git a/src/pages/node-config.tsx b/src/pages/node-config.tsx index 65c7f5f..6790293 100644 --- a/src/pages/node-config.tsx +++ b/src/pages/node-config.tsx @@ -20,11 +20,11 @@ import { Link, Navigate, useParams } from "react-router-dom"; import { ConfigForm } from "@/components/config-form"; import { Button } from "@/components/ui/button"; -import { getNode } from "@/lib/nodes"; +import { useNode } from "@/lib/nodes"; export const NodeConfigPage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); if (!node || node.identityState === "REVOKED") { return ; diff --git a/src/pages/node-detail.tsx b/src/pages/node-detail.tsx index 461d435..e0887e8 100644 --- a/src/pages/node-detail.tsx +++ b/src/pages/node-detail.tsx @@ -22,7 +22,7 @@ import { CertInventory } from "@/components/cert-inventory"; import { NodeDetailPanel } from "@/components/node-detail-panel"; import { Button } from "@/components/ui/button"; import { type IdentityState, roleLabels } from "@/lib/mock"; -import { chainToRoot, getNode } from "@/lib/nodes"; +import { chainToRoot, useNode } from "@/lib/nodes"; import { cn } from "@/lib/utils"; const stateTone: Record = { @@ -33,7 +33,7 @@ const stateTone: Record = { export const NodeDetailPage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); if (!node) { return ( diff --git a/src/pages/node-issue.tsx b/src/pages/node-issue.tsx index 0691483..b6e4ca2 100644 --- a/src/pages/node-issue.tsx +++ b/src/pages/node-issue.tsx @@ -22,11 +22,11 @@ import { Link, Navigate, useParams } from "react-router-dom"; import { IssueForm } from "@/components/issue-form"; import { Button } from "@/components/ui/button"; import { canIssue, type Cert } from "@/lib/certs"; -import { getNode } from "@/lib/nodes"; +import { useNode } from "@/lib/nodes"; export const NodeIssuePage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); const [issued, setIssued] = useState(null); // Remount key: bumping it drops the form's issued/export state so "Issue // another" starts from a clean form (and a fresh, unexportable key). diff --git a/src/pages/node-profiles.test.tsx b/src/pages/node-profiles.test.tsx index 3933370..65b21f4 100644 --- a/src/pages/node-profiles.test.tsx +++ b/src/pages/node-profiles.test.tsx @@ -52,7 +52,7 @@ vi.mock("@/lib/config", () => ({ getNodeConfig: (...args: unknown[]) => getNodeConfig(...args), })); vi.mock("@/lib/nodes", () => ({ - getNode: (name: string) => ({ identityState: "ESTABLISHED", name }), + useNode: (name: string) => ({ identityState: "ESTABLISHED", name }), })); vi.mock("@/lib/profiles", async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/pages/node-profiles.tsx b/src/pages/node-profiles.tsx index 87700ac..78d4f29 100644 --- a/src/pages/node-profiles.tsx +++ b/src/pages/node-profiles.tsx @@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/context/auth"; import { getNodeConfig } from "@/lib/config"; import { fleetMode } from "@/lib/fleet/mode"; -import { getNode } from "@/lib/nodes"; +import { useNode } from "@/lib/nodes"; import { computeDrift, type DriftRow, type DriftStatus } from "@/lib/profile-drift"; import { applyProfileToNode, fromProtoProfile, useProfiles } from "@/lib/profiles"; import { cn } from "@/lib/utils"; @@ -151,7 +151,7 @@ const DriftView = ({ nodeName }: { nodeName: string }) => { export const NodeProfilesPage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); if (!node) { return ; diff --git a/src/pages/node-rekey.tsx b/src/pages/node-rekey.tsx index 253c24c..5cf5b96 100644 --- a/src/pages/node-rekey.tsx +++ b/src/pages/node-rekey.tsx @@ -20,11 +20,11 @@ import { Link, Navigate, useParams } from "react-router-dom"; import { RekeyWizard } from "@/components/rekey-wizard"; import { Button } from "@/components/ui/button"; -import { getNode } from "@/lib/nodes"; +import { useNode } from "@/lib/nodes"; export const NodeRekeyPage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); if (!node || node.identityState === "REVOKED") { return ; diff --git a/src/pages/root-detail.tsx b/src/pages/root-detail.tsx index ac04007..5baa69d 100644 --- a/src/pages/root-detail.tsx +++ b/src/pages/root-detail.tsx @@ -21,7 +21,7 @@ import { Link, Navigate, useParams } from "react-router-dom"; import { ConfigForm } from "@/components/config-form"; import { RekeyWizard } from "@/components/rekey-wizard"; import { Button } from "@/components/ui/button"; -import { getNode } from "@/lib/nodes"; +import { useNode } from "@/lib/nodes"; const Panel = ({ children, label }: { children: React.ReactNode; label: string }) => (
@@ -43,7 +43,7 @@ const Field = ({ children, label }: { children: React.ReactNode; label: string } export const RootDetailPage = () => { const { name } = useParams<{ name: string }>(); - const node = name ? getNode(name) : undefined; + const node = useNode(name); if (!node || node.role !== "root") { return ;