mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
feat(fleet): sencho mesh in traffic and routing tab (#858)
* feat(fleet): sencho mesh in traffic and routing tab Lights up Sencho Mesh: cross-node container forwarding rendered as if the container next to you were on localhost. Builds on the dormant TCP frame plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar package) and exposes the Admiral-only orchestrator surface. Backend - New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column via DatabaseService.migrateMeshTables. - MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with cascading override regeneration, request-based resolver from sidecar control WS, cross-node TCP forwarding via PilotTunnelManager (same-node fast path included), in-memory 1000-event activity ring buffer with durable mirror to audit_log for state-change events, per-node and per-route diagnostics, and the Test upstream probe. - MeshComposeOverride: pure YAML generator that injects extra_hosts using host-gateway. The user's docker-compose.yml is never mutated; overrides live under DATA_DIR/mesh/overrides. - ComposeService deploy/update splice the override file when the stack is opted in; non-mesh stacks behave identically to today. - Pilot agent resolveMeshTarget consults the local mesh_stacks table (defense in depth) and resolves Compose containers via Dockerode. - /api/mesh router with 13 Admiral-gated endpoints covering status, enable/disable, stack opt-in/out, alias listing, per-route diagnostic, Test upstream probe, per-node diagnostic, sidecar restart, activity log paginated and SSE. - meshControl WS slot at /api/mesh/control validates the mesh_sidecar JWT minted by MeshService; dispatched as upgrade slot 2 (canonical order preserved). Frontend - New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state typography from the audit. - RoutingTab masthead with mesh activity drawer, per-node card grid with TogglePill, alias rows with five-state pill taxonomy (healthy / degraded / unreachable / tunnel-down / not-authorized), inline Test buttons. - Four sheets: opt-in picker with port-collision inline error, per-route detail with diagnostic + filtered activity, per-node diagnostics with active streams + resolver cache + restart action, fleet-wide activity log with filters. - meshRouteState helper centralizes pill-state mapping; pure-function tests cover all five states. Docs - User docs at /docs/features/sencho-mesh.mdx covering opt-in, troubleshooting, security model (4 guarantees + 4 explicit non-guarantees), and V1 limitations. - Internal architecture and runbook pages. - websocket-dispatch internal doc updated with the new slot. * fix(mesh): validate stack name before path use; fix test DB lifecycle Two surgical fixes against the prior PR. Path-injection (CodeQL js/path-injection): MeshService.optInStack, optOutStack, ensureStackOverride, and removeStackOverride now validate stackName via isValidStackName from utils/validation, reject malicious names at the API boundary, and additionally check isPathWithinBase on the resolved override file path for defense in depth. The dataflow from req.params.stackName to fs.writeFile no longer reaches an unsanitized path expression. Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb / cleanupTestDb, which deletes the temp dir while DatabaseService still holds an open SQLite handle. On Linux CI this raises SQLITE_READONLY_DBMOVED on the next prepare() because the inode has been unlinked. Switched to file-scoped beforeAll/afterAll matching agents-routes.test.ts, with a per-test beforeEach that truncates mesh_stacks plus non-default nodes and resets the MeshService singleton in-memory state. Adds a new test case asserting the path-traversal rejection. * fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho writes its canonical compose file as `compose.yaml`, so any stack created via the UI failed to deploy with `open ...docker-compose.yml: no such file or directory`. When no mesh override applies, drop the explicit `-f` so docker compose's built-in discovery resolves the actual filename. When an override exists, look up the real base filename via FileSystemService.getComposeFilename() and pass both files explicitly. Also hoist the MeshService import to module top now that the dependency is known to be acyclic, and revert the matching unit-test assertion.
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle,
|
||||
Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check,
|
||||
CircleCheck, CircleAlert, Globe, Monitor, X, LayoutGrid, Network, SlidersHorizontal,
|
||||
Send, KeyRound,
|
||||
Send, KeyRound, ArrowLeftRight,
|
||||
} from 'lucide-react';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { FleetTopology } from './fleet/FleetTopology';
|
||||
@@ -27,9 +27,11 @@ import { springs } from '@/lib/motion';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
@@ -673,7 +675,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
|
||||
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
|
||||
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
|
||||
const { isPaid } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
||||
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
@@ -1044,6 +1047,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="configuration">
|
||||
<TabsTrigger value="configuration">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
@@ -1364,6 +1374,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate featureName="Sencho Mesh">
|
||||
<RoutingTab />
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { MeshActivityEvent } from '@/types/mesh';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MeshActivitySheet({ open, onOpenChange }: Props) {
|
||||
const [events, setEvents] = useState<MeshActivityEvent[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/mesh/activity?limit=200', { localOnly: true });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json() as { events: MeshActivityEvent[] };
|
||||
if (!cancelled) setEvents(body.events);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
const visible = events.filter((e) => {
|
||||
if (!filter) return true;
|
||||
const f = filter.toLowerCase();
|
||||
return (
|
||||
e.message.toLowerCase().includes(f) ||
|
||||
(e.alias?.toLowerCase().includes(f) ?? false) ||
|
||||
e.type.toLowerCase().includes(f)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[600px] sm:max-w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mesh activity</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Filter by alias, type, or message"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
|
||||
<div className="max-h-[70vh] overflow-auto space-y-1">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events.</div>
|
||||
)}
|
||||
{visible.slice().reverse().map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[80px_70px_120px_1fr] gap-2 text-[11px] font-mono py-1 border-b border-card-border/50">
|
||||
<span className="text-stat-subtitle tabular-nums">{new Date(e.ts).toLocaleTimeString()}</span>
|
||||
<span className={
|
||||
e.level === 'error' ? 'text-destructive uppercase tracking-wide' :
|
||||
e.level === 'warn' ? 'text-warning uppercase tracking-wide' :
|
||||
'text-stat-subtitle uppercase tracking-wide'
|
||||
}>{e.source}</span>
|
||||
<span className="text-stat-value">{e.type}</span>
|
||||
<span className="text-stat-value truncate" title={e.message}>{e.alias ? `[${e.alias}] ` : ''}{e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, RefreshCw, ServerCog } from 'lucide-react';
|
||||
import type { MeshNodeDiagnostic } from '@/types/mesh';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
nodeId: number | null;
|
||||
nodeName: string | null;
|
||||
}
|
||||
|
||||
function bytesFmt(n: number): string {
|
||||
if (n < 1024) return `${n}B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function ageFmt(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m`;
|
||||
}
|
||||
|
||||
export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: Props) {
|
||||
const [diag, setDiag] = useState<MeshNodeDiagnostic | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
const refresh = async () => {
|
||||
if (nodeId == null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/mesh/nodes/${nodeId}/diagnostic`, { localOnly: true });
|
||||
if (res.ok) setDiag(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, nodeId]);
|
||||
|
||||
const restart = async () => {
|
||||
if (nodeId == null) return;
|
||||
setRestarting(true);
|
||||
try {
|
||||
const res = await apiFetch(`/mesh/nodes/${nodeId}/sidecar/restart`, {
|
||||
method: 'POST', localOnly: true,
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Sidecar restart requested');
|
||||
await refresh();
|
||||
} else {
|
||||
toast.error('Sidecar restart failed');
|
||||
}
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[520px] sm:max-w-[520px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ServerCog className="w-4 h-4" /> Diagnostics{nodeName ? ` · ${nodeName}` : ''}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { void refresh(); }} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />}
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { void restart(); }} disabled={restarting}>
|
||||
{restarting ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : null}
|
||||
Restart sidecar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 rounded border border-card-border bg-card p-3 text-xs">
|
||||
<div className="text-stat-subtitle">Sidecar</div>
|
||||
<div className="font-mono text-stat-value">{diag?.sidecar.running ? 'running' : 'off'}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
<div className="text-stat-subtitle">Buffered</div>
|
||||
<div className="font-mono text-stat-value">{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}</div>
|
||||
<div className="text-stat-subtitle">Last seen</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] tracking-wide uppercase text-stat-subtitle font-mono mb-2">Active streams</div>
|
||||
{(!diag || diag.activeStreams.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No active streams.</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{diag?.activeStreams.map((s) => (
|
||||
<div key={s.streamId} className="flex justify-between rounded border border-card-border bg-card px-2 py-1 text-[11px] font-mono">
|
||||
<span>#{s.streamId} {s.alias ?? '<no-alias>'}</span>
|
||||
<span className="text-stat-subtitle">in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] tracking-wide uppercase text-stat-subtitle font-mono mb-2">Resolver cache</div>
|
||||
{(!diag || diag.aliasCache.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No aliases registered.</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{diag?.aliasCache.map((a) => (
|
||||
<div key={a.host} className="flex justify-between rounded border border-card-border bg-card px-2 py-1 text-[11px] font-mono">
|
||||
<span>{a.host}</span>
|
||||
<span className="text-stat-subtitle">node #{a.targetNodeId}:{a.port}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { MeshStackEntry } from '@/types/mesh';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged }: Props) {
|
||||
const [stacks, setStacks] = useState<MeshStackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingStack, setPendingStack] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch(`/mesh/nodes/${nodeId}/stacks`, { localOnly: true });
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json() as { stacks: MeshStackEntry[] };
|
||||
if (!cancelled) setStacks(data.stacks);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, nodeId]);
|
||||
|
||||
const toggle = async (stack: MeshStackEntry) => {
|
||||
setPendingStack(stack.name);
|
||||
setError(null);
|
||||
try {
|
||||
const action = stack.optedIn ? 'opt-out' : 'opt-in';
|
||||
const res = await apiFetch(
|
||||
`/mesh/nodes/${nodeId}/stacks/${encodeURIComponent(stack.name)}/${action}`,
|
||||
{ method: 'POST', localOnly: true },
|
||||
);
|
||||
if (res.status === 409) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
setError(body.error || 'Port already claimed by another mesh stack');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
setStacks((prev) => prev.map((s) => s.name === stack.name ? { ...s, optedIn: !stack.optedIn } : s));
|
||||
onChanged();
|
||||
toast.success(stack.optedIn ? 'Stack removed from mesh' : 'Stack added to mesh');
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
toast.error('Mesh update failed');
|
||||
} finally {
|
||||
setPendingStack(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mesh stacks on {nodeName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Adding a stack lets its services be reached from other meshed stacks by hostname.
|
||||
Toggling a stack redeploys it to refresh hostnames.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && stacks.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
|
||||
)}
|
||||
{stacks.map((stack) => (
|
||||
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`mesh-stack-${stack.name}`}
|
||||
checked={stack.optedIn}
|
||||
disabled={pendingStack === stack.name}
|
||||
onCheckedChange={() => { void toggle(stack); }}
|
||||
/>
|
||||
<label htmlFor={`mesh-stack-${stack.name}`} className="text-sm font-mono">{stack.name}</label>
|
||||
</div>
|
||||
{pendingStack === stack.name && <Loader2 className="w-3 h-3 animate-spin text-stat-subtitle" />}
|
||||
{stack.optedIn && pendingStack !== stack.name && (
|
||||
<span className="text-[10px] tracking-wide uppercase text-success/80 font-mono">in mesh</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Activity, ServerCog, Hash } from 'lucide-react';
|
||||
import type { MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh';
|
||||
import { meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
const [diag, setDiag] = useState<MeshRouteDiagnostic | null>(null);
|
||||
const [events, setEvents] = useState<MeshActivityEvent[]>([]);
|
||||
const [probe, setProbe] = useState<MeshProbeResult | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !alias) return;
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [diagRes, evRes] = await Promise.all([
|
||||
apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/diagnostic`, { localOnly: true }),
|
||||
apiFetch(`/mesh/activity?alias=${encodeURIComponent(alias)}&limit=20`, { localOnly: true }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (diagRes.ok) setDiag(await diagRes.json());
|
||||
if (evRes.ok) {
|
||||
const body = await evRes.json() as { events: MeshActivityEvent[] };
|
||||
setEvents(body.events);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, alias]);
|
||||
|
||||
const runProbe = async () => {
|
||||
if (!alias) return;
|
||||
setProbing(true);
|
||||
setProbe(null);
|
||||
try {
|
||||
const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, {
|
||||
method: 'POST', localOnly: true,
|
||||
});
|
||||
const body = await res.json() as MeshProbeResult;
|
||||
setProbe(body);
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!alias) return null;
|
||||
const pillState = diag ? meshRouteStateFromBackend(diag.state) : 'not-authorized';
|
||||
const pill = meshRouteStateTokens(pillState);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[480px] sm:max-w-[480px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="font-mono text-sm">{alias}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-sm border text-[10px] font-mono uppercase tracking-wide ${pill.toneClass}`}>
|
||||
{pill.label}
|
||||
</span>
|
||||
{diag?.lastProbeMs != null && (
|
||||
<span className="text-[11px] font-mono text-stat-subtitle">{diag.lastProbeMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diag?.target && (
|
||||
<div className="grid grid-cols-2 gap-2 rounded border border-card-border bg-card p-3 text-xs">
|
||||
<div className="text-stat-subtitle">Target node</div>
|
||||
<div className="font-mono text-stat-value">#{diag.target.nodeId}</div>
|
||||
<div className="text-stat-subtitle">Stack / service</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.stack}/{diag.target.service}</div>
|
||||
<div className="text-stat-subtitle">Port</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.port}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diag?.lastError && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-3 text-xs">
|
||||
<div className="text-destructive font-mono uppercase tracking-wide text-[10px] mb-1">last error</div>
|
||||
<div className="text-stat-value">{diag.lastError.message}</div>
|
||||
<div className="text-[10px] text-stat-subtitle mt-1">{new Date(diag.lastError.ts).toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => { void runProbe(); }} disabled={probing}>
|
||||
{probing ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Activity className="w-3 h-3 mr-1" />}
|
||||
Test upstream
|
||||
</Button>
|
||||
{probe && (
|
||||
<Badge variant={probe.ok ? 'default' : 'destructive'} className="text-[10px] font-mono">
|
||||
{probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] tracking-wide uppercase text-stat-subtitle font-mono mb-2">Recent activity</div>
|
||||
<div className="space-y-1 max-h-72 overflow-auto">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />}
|
||||
{!loading && events.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events yet for this alias.</div>
|
||||
)}
|
||||
{events.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-[11px] font-mono">
|
||||
{e.source === 'sidecar' && <ServerCog className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'pilot' && <Hash className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'mesh' && <Activity className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
<span className={`tabular-nums ${e.level === 'error' ? 'text-destructive' : e.level === 'warn' ? 'text-warning' : 'text-stat-value'}`}>
|
||||
{new Date(e.ts).toLocaleTimeString()} {e.type} {e.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Plus, ServerCog, Activity, Loader2 } from 'lucide-react';
|
||||
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
|
||||
import { meshRouteStateFor, meshRouteStateTokens } from './meshRouteState';
|
||||
|
||||
interface Props {
|
||||
status: MeshNodeStatus;
|
||||
aliases: MeshAlias[];
|
||||
isLocal: boolean;
|
||||
onAddStack: () => void;
|
||||
onShowDiagnostics: () => void;
|
||||
onShowAlias: (alias: string) => void;
|
||||
onTestUpstream: (alias: string) => Promise<void>;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function RoutingNodeCard({
|
||||
status, aliases, isLocal, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged,
|
||||
}: Props) {
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [testingAlias, setTestingAlias] = useState<string | null>(null);
|
||||
|
||||
const nodeAliases = aliases.filter((a) => a.nodeId === status.nodeId);
|
||||
|
||||
const toggleEnabled = async (next: boolean) => {
|
||||
setToggling(true);
|
||||
try {
|
||||
const action = next ? 'enable' : 'disable';
|
||||
const res = await apiFetch(`/mesh/nodes/${status.nodeId}/${action}`, {
|
||||
method: 'POST', localOnly: true,
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
toast.success(next ? 'Mesh enabled on node' : 'Mesh disabled on node');
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(`Failed to ${next ? 'enable' : 'disable'} mesh: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async (alias: string) => {
|
||||
setTestingAlias(alias);
|
||||
try { await onTestUpstream(alias); } finally { setTestingAlias(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{status.nodeName}</span>
|
||||
{isLocal && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-brand/40 bg-brand/10 text-[9px] font-mono uppercase tracking-wide text-brand">
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
{!status.pilotConnected && status.nodeId !== -1 && !isLocal && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-destructive/40 bg-destructive/10 text-[9px] font-mono uppercase tracking-wide text-destructive">
|
||||
pilot offline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill
|
||||
checked={status.enabled}
|
||||
disabled={toggling || (!status.pilotConnected && !isLocal)}
|
||||
onChange={(next) => { void toggleEnabled(next); }}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onShowDiagnostics}>
|
||||
<ServerCog className="w-3 h-3 mr-1" /> Diagnostics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.pilotConnected && !isLocal && (
|
||||
<div className="text-[11px] text-stat-subtitle">
|
||||
Sencho Mesh requires the pilot agent on this node.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-stat-subtitle">Mesh stacks</div>
|
||||
<div className="font-mono text-stat-value">{status.optedInStacks.length}</div>
|
||||
<div className="text-stat-subtitle">Aliases</div>
|
||||
<div className="font-mono text-stat-value">{nodeAliases.length}</div>
|
||||
</div>
|
||||
|
||||
{status.enabled && (
|
||||
<>
|
||||
<div className="border-t border-card-border pt-2 space-y-1">
|
||||
{nodeAliases.length === 0 && (
|
||||
<div className="text-[11px] text-stat-subtitle">No mesh services on this node yet.</div>
|
||||
)}
|
||||
{nodeAliases.map((a) => {
|
||||
const pillState = meshRouteStateFor({
|
||||
optedIn: true,
|
||||
pilotConnected: status.pilotConnected,
|
||||
});
|
||||
const pill = meshRouteStateTokens(pillState);
|
||||
return (
|
||||
<div key={a.host} className="flex items-center justify-between gap-2 rounded border border-card-border bg-card px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] font-mono text-left truncate hover:text-brand transition-colors"
|
||||
onClick={() => onShowAlias(a.host)}
|
||||
>
|
||||
{a.host}:{a.port}
|
||||
</button>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded-sm border text-[9px] font-mono uppercase tracking-wide ${pill.toneClass}`}>
|
||||
{pill.label}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
onClick={() => { void runTest(a.host); }}
|
||||
disabled={testingAlias === a.host}
|
||||
className="h-6 px-1.5"
|
||||
>
|
||||
{testingAlias === a.host
|
||||
? <Loader2 className="w-3 h-3 animate-spin" />
|
||||
: <Activity className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={onAddStack}>
|
||||
<Plus className="w-3 h-3 mr-1" /> Add stack to mesh
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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] tracking-wide uppercase text-stat-subtitle font-mono">meshed</div>
|
||||
<div className="font-mono text-stat-value">{meshedNodes}/{onlineNodes}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] tracking-wide 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { meshRouteStateFor, meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState';
|
||||
|
||||
describe('meshRouteStateFor', () => {
|
||||
const FROZEN_NOW = 1_700_000_000_000;
|
||||
|
||||
it('returns not-authorized when the route is not opted in', () => {
|
||||
expect(meshRouteStateFor({ optedIn: false, pilotConnected: true, now: FROZEN_NOW })).toBe('not-authorized');
|
||||
});
|
||||
|
||||
it('returns tunnel-down when opted in but pilot is offline', () => {
|
||||
expect(meshRouteStateFor({ optedIn: true, pilotConnected: false, now: FROZEN_NOW })).toBe('tunnel-down');
|
||||
});
|
||||
|
||||
it('returns unreachable when a recent error is recorded', () => {
|
||||
const lastErrorAt = FROZEN_NOW - 5_000;
|
||||
expect(meshRouteStateFor({
|
||||
optedIn: true, pilotConnected: true, lastErrorAt, now: FROZEN_NOW,
|
||||
})).toBe('unreachable');
|
||||
});
|
||||
|
||||
it('does NOT return unreachable when the error is older than the window', () => {
|
||||
const lastErrorAt = FROZEN_NOW - 90_000;
|
||||
expect(meshRouteStateFor({
|
||||
optedIn: true, pilotConnected: true, lastErrorAt, now: FROZEN_NOW,
|
||||
})).toBe('healthy');
|
||||
});
|
||||
|
||||
it('returns degraded when the last probe was slow', () => {
|
||||
expect(meshRouteStateFor({
|
||||
optedIn: true, pilotConnected: true, lastProbeMs: 750, now: FROZEN_NOW,
|
||||
})).toBe('degraded');
|
||||
});
|
||||
|
||||
it('returns healthy in the happy path', () => {
|
||||
expect(meshRouteStateFor({
|
||||
optedIn: true, pilotConnected: true, lastProbeMs: 12, now: FROZEN_NOW,
|
||||
})).toBe('healthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('meshRouteStateFromBackend', () => {
|
||||
it('maps each backend label to its pill key', () => {
|
||||
expect(meshRouteStateFromBackend('healthy')).toBe('healthy');
|
||||
expect(meshRouteStateFromBackend('degraded')).toBe('degraded');
|
||||
expect(meshRouteStateFromBackend('unreachable')).toBe('unreachable');
|
||||
expect(meshRouteStateFromBackend('tunnel down')).toBe('tunnel-down');
|
||||
expect(meshRouteStateFromBackend('not authorized')).toBe('not-authorized');
|
||||
expect(meshRouteStateFromBackend('anything-else')).toBe('not-authorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('meshRouteStateTokens', () => {
|
||||
it('produces a token + label + toneClass for every pill state', () => {
|
||||
for (const s of ['healthy', 'degraded', 'unreachable', 'tunnel-down', 'not-authorized'] as const) {
|
||||
const tk = meshRouteStateTokens(s);
|
||||
expect(tk.label.length).toBeGreaterThan(0);
|
||||
expect(tk.toneClass.length).toBeGreaterThan(0);
|
||||
expect(tk.token.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MeshRoutePillState } from '@/types/mesh';
|
||||
|
||||
const SLOW_PROBE_THRESHOLD_MS = 500;
|
||||
const RECENT_ERROR_WINDOW_MS = 60_000;
|
||||
|
||||
export interface MeshRouteSignals {
|
||||
optedIn: boolean;
|
||||
pilotConnected: boolean;
|
||||
lastErrorAt?: number | null;
|
||||
lastProbeMs?: number | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function mapping the four observable signals to a single pill state.
|
||||
* Centralizes the priority order so every UI surface (route row, node card,
|
||||
* detail sheet, activity log) renders the same word for the same situation.
|
||||
*/
|
||||
export function meshRouteStateFor(signals: MeshRouteSignals): MeshRoutePillState {
|
||||
if (!signals.optedIn) return 'not-authorized';
|
||||
if (!signals.pilotConnected) return 'tunnel-down';
|
||||
const now = signals.now ?? Date.now();
|
||||
if (signals.lastErrorAt && now - signals.lastErrorAt < RECENT_ERROR_WINDOW_MS) return 'unreachable';
|
||||
if (signals.lastProbeMs != null && signals.lastProbeMs > SLOW_PROBE_THRESHOLD_MS) return 'degraded';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
const TONE_TOKENS: Record<MeshRoutePillState, { token: string; label: string; toneClass: string }> = {
|
||||
'healthy': { token: 'success', label: 'healthy', toneClass: 'text-success bg-success/10 border-success/30' },
|
||||
'degraded': { token: 'warning', label: 'degraded', toneClass: 'text-warning bg-warning/10 border-warning/30' },
|
||||
'unreachable': { token: 'destructive', label: 'unreachable', toneClass: 'text-destructive bg-destructive/10 border-destructive/30' },
|
||||
'tunnel-down': { token: 'destructive', label: 'tunnel down', toneClass: 'text-destructive bg-destructive/10 border-destructive/30' },
|
||||
'not-authorized': { token: 'muted', label: 'not authorized', toneClass: 'text-ink-3 bg-ink-3/10 border-ink-3/30' },
|
||||
};
|
||||
|
||||
export function meshRouteStateTokens(state: MeshRoutePillState) {
|
||||
return TONE_TOKENS[state];
|
||||
}
|
||||
|
||||
/** Convert the backend's diagnostic state string to the pill key. */
|
||||
export function meshRouteStateFromBackend(state: string): MeshRoutePillState {
|
||||
if (state === 'healthy') return 'healthy';
|
||||
if (state === 'degraded') return 'degraded';
|
||||
if (state === 'unreachable') return 'unreachable';
|
||||
if (state === 'tunnel down') return 'tunnel-down';
|
||||
return 'not-authorized';
|
||||
}
|
||||
Reference in New Issue
Block a user