Files
sencho/frontend/src/components/fleet/RoutingTab.tsx
T
Anso f62716f557 refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2

* refactor(design): canonicalize tracked-mono kickers and display rungs

* refactor(design): collapse to five-slot palette and align card surfaces
2026-05-01 03:01:00 -04:00

219 lines
10 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { Button } from '@/components/ui/button';
import { ArrowLeftRight, Loader2, ScrollText } from 'lucide-react';
import { RoutingNodeCard } from './RoutingNodeCard';
import { MeshOptInSheet } from './MeshOptInSheet';
import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet';
import { MeshActivitySheet } from './MeshActivitySheet';
import type { MeshAlias, MeshNodeStatus, MeshProbeResult } from '@/types/mesh';
export function RoutingTab() {
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
const [aliases, setAliases] = useState<MeshAlias[]>([]);
const [loading, setLoading] = useState(true);
const [optInNode, setOptInNode] = useState<{ id: number; name: string } | null>(null);
const [diagnosticsNode, setDiagnosticsNode] = useState<{ id: number; name: string } | null>(null);
const [routeDetailAlias, setRouteDetailAlias] = useState<string | null>(null);
const [activityOpen, setActivityOpen] = useState(false);
const refresh = useCallback(async () => {
try {
const [statusRes, aliasesRes] = await Promise.all([
apiFetch('/mesh/status', { localOnly: true }),
apiFetch('/mesh/aliases', { localOnly: true }),
]);
if (statusRes.ok) {
const body = await statusRes.json() as { nodes: MeshNodeStatus[] };
setStatus(body.nodes);
}
if (aliasesRes.ok) {
const body = await aliasesRes.json() as { aliases: MeshAlias[] };
setAliases(body.aliases);
}
} catch (err) {
toast.error(`Failed to load mesh state: ${(err as Error).message}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const testUpstream = useCallback(async (alias: string): Promise<void> => {
try {
const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, {
method: 'POST', localOnly: true,
});
const body = await res.json() as MeshProbeResult;
if (body.ok) {
toast.success(`${alias} ok (${body.latencyMs}ms)`);
} else {
toast.error(`${alias} ${body.where ?? 'fail'}: ${body.code ?? 'error'}`);
}
} catch (err) {
toast.error(`Probe failed: ${(err as Error).message}`);
}
}, []);
const totalAliases = aliases.length;
const meshedNodes = status.filter((s) => s.enabled).length;
const onlineNodes = status.filter((s) => s.pilotConnected).length;
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-stat-subtitle">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading mesh state
</div>
);
}
if (status.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<ArrowLeftRight className="w-12 h-12 text-stat-subtitle mb-4" />
<div className="text-lg font-display italic mb-2">No nodes available</div>
<div className="text-sm text-stat-subtitle text-center max-w-md">
Add a node to the fleet to start routing traffic between containers across nodes.
</div>
</div>
);
}
if (meshedNodes === 0) {
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} onlineNodes={onlineNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<div className="flex flex-col items-center justify-center py-12 rounded border border-dashed border-card-border bg-card/50">
<ArrowLeftRight className="w-12 h-12 text-stat-subtitle mb-4" />
<div className="text-lg font-display italic mb-2">Mesh containers across nodes</div>
<div className="text-sm text-stat-subtitle text-center max-w-md mb-6">
Add a stack to the mesh and its services become reachable from any other meshed
stack by hostname. No VPN, no firewall changes.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 w-full">
{status.map((s) => (
<RoutingNodeCard
key={s.nodeId}
status={s}
aliases={aliases}
isLocal={s.nodeId === status[0]?.nodeId && s.pilotConnected}
onAddStack={() => setOptInNode({ id: s.nodeId, name: s.nodeName })}
onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })}
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
/>
))}
</div>
</div>
<SheetsRoot
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
onChanged={() => { void refresh(); }}
/>
</div>
);
}
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} onlineNodes={onlineNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{status.map((s) => (
<RoutingNodeCard
key={s.nodeId}
status={s}
aliases={aliases}
isLocal={s.nodeId === status[0]?.nodeId && s.pilotConnected}
onAddStack={() => setOptInNode({ id: s.nodeId, name: s.nodeName })}
onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })}
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
/>
))}
</div>
<SheetsRoot
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
onChanged={() => { void refresh(); }}
/>
</div>
);
}
function RoutingMasthead({ meshedNodes, onlineNodes, totalAliases, onShowActivity }: {
meshedNodes: number; onlineNodes: number; totalAliases: number; onShowActivity: () => void;
}) {
const stateWord = meshedNodes === 0 ? 'unmeshed' : meshedNodes < onlineNodes ? 'partial' : 'meshed';
return (
<div className="flex items-center justify-between rounded-lg border border-card-border bg-card p-4 shadow-card-bevel">
<div className="flex items-center gap-4">
<div className="font-display italic text-2xl">{stateWord}</div>
<div className="grid grid-cols-3 gap-4 text-xs">
<div>
<div className="text-[10px] leading-3 tracking-[0.18em] uppercase text-stat-subtitle font-mono">meshed</div>
<div className="font-mono text-stat-value">{meshedNodes}/{onlineNodes}</div>
</div>
<div>
<div className="text-[10px] leading-3 tracking-[0.18em] uppercase text-stat-subtitle font-mono">aliases</div>
<div className="font-mono text-stat-value">{totalAliases}</div>
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={onShowActivity}>
<ScrollText className="w-3 h-3 mr-1" /> Mesh activity
</Button>
</div>
);
}
function SheetsRoot(props: {
optInNode: { id: number; name: string } | null;
setOptInNode: (v: { id: number; name: string } | null) => void;
diagnosticsNode: { id: number; name: string } | null;
setDiagnosticsNode: (v: { id: number; name: string } | null) => void;
routeDetailAlias: string | null;
setRouteDetailAlias: (v: string | null) => void;
activityOpen: boolean;
setActivityOpen: (v: boolean) => void;
onChanged: () => void;
}) {
return (
<>
{props.optInNode && (
<MeshOptInSheet
open={!!props.optInNode}
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
nodeId={props.optInNode.id}
nodeName={props.optInNode.name}
onChanged={props.onChanged}
/>
)}
<MeshDiagnosticsSheet
open={!!props.diagnosticsNode}
onOpenChange={(open) => { if (!open) props.setDiagnosticsNode(null); }}
nodeId={props.diagnosticsNode?.id ?? null}
nodeName={props.diagnosticsNode?.name ?? null}
/>
<MeshRouteDetailSheet
open={!!props.routeDetailAlias}
onOpenChange={(open) => { if (!open) props.setRouteDetailAlias(null); }}
alias={props.routeDetailAlias}
/>
<MeshActivitySheet
open={props.activityOpen}
onOpenChange={props.setActivityOpen}
/>
</>
);
}