Files
sencho/frontend/src/components/fleet/MeshRouteDetailSheet.tsx
T
Anso c460bb87a8 fix(mesh): probe upstream synchronously in route diagnostic (F-11) (#1100)
GET /api/mesh/aliases/:alias/diagnostic returned a cached state derived
from the last latency/error maps. Those maps only mutated when someone
called POST .../test or when a cross-node connect logged an event, so
once an upstream stopped the diagnostic kept reporting "healthy" until
the 60 s alias-cache refresh pruned the alias entirely.

The GET now calls testUpstream synchronously after the alias-resolved,
opted-in, tunnel-up short-circuits. Probe failures land in routeErrorMap
via logActivity (cross-node already did this; same-node timeout/error
paths now log probe.fail in the same shape), so the state computation
flips to "unreachable" on the same request that exposed the stopped
upstream. A new routeProbeAtMap stamps freshness and surfaces in the
response as lastProbeAt; the route detail sheet renders "Last probe
<age> · <ms>" using the existing formatTimeAgo helper.
2026-05-18 16:48:09 -04:00

175 lines
8.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Loader2, Activity, 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;
}
type RouteTab = 'overview' | 'events' | 'raw';
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);
const [activeTab, setActiveTab] = useState<RouteTab>('overview');
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);
const meta = diag?.target
? `${diag.target.stack}/${diag.target.service}:${diag.target.port} · node #${diag.target.nodeId}`
: (loading ? 'Loading…' : 'No target resolved');
const footerContext = probe
? (probe.ok ? `Last probe ok · ${probe.latencyMs}ms` : `Last probe failed · ${probe.where ?? 'unknown'}`)
: diag?.lastProbeMs != null
? (diag.lastProbeAt != null
? `Last probe ${formatTimeAgo(diag.lastProbeAt)} · ${diag.lastProbeMs}ms`
: `Last probe ${diag.lastProbeMs}ms`)
: 'No probe run yet';
return (
<SystemSheet
open={open}
onOpenChange={onOpenChange}
crumb={['Fleet', 'Mesh', 'Routes', alias]}
name={alias}
meta={meta}
primaryAction={{
label: probing ? 'Probing…' : 'Test probe',
icon: probing ? Loader2 : Activity,
onClick: () => { void runProbe(); },
disabled: probing,
}}
tabs={[
{ id: 'overview', label: 'Overview' },
{ id: 'events', label: 'Events', count: events.length },
{ id: 'raw', label: 'Raw' },
]}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as RouteTab)}
footerContext={footerContext}
size="md"
>
{activeTab === 'overview' && (
<>
<SheetSection title="State" hideHeader>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-sm border text-[10px] leading-3 font-mono uppercase tracking-[0.18em] ${pill.toneClass}`}>
{pill.label}
</span>
{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>
</SheetSection>
{diag?.target && (
<SheetSection title="Target">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 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>
</SheetSection>
)}
{diag?.lastError && (
<SheetSection title="Last error">
<div className="rounded border border-destructive/30 bg-destructive/10 p-3 text-xs">
<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>
</SheetSection>
)}
</>
)}
{activeTab === 'events' && (
<SheetSection title="Recent activity" hideHeader>
<div className="space-y-1">
{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 === '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>
</SheetSection>
)}
{activeTab === 'raw' && (
<SheetSection title="Diagnostic JSON" hideHeader>
<pre className="text-[11px] font-mono text-stat-value bg-card border border-card-border rounded p-3 overflow-x-auto whitespace-pre-wrap break-all">
{diag ? JSON.stringify(diag, null, 2) : 'No diagnostic data loaded.'}
</pre>
</SheetSection>
)}
</SystemSheet>
);
}