mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
f62716f557
* 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
219 lines
10 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|