mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
c460bb87a8
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.
175 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|