diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx index 551b30a6..59785636 100644 --- a/frontend/src/components/fleet/RoutingNodeCard.tsx +++ b/frontend/src/components/fleet/RoutingNodeCard.tsx @@ -1,12 +1,14 @@ -import { useState } from 'react'; +import { useMemo, useRef, 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 { formatAgeShort } from '@/lib/relativeTime'; import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; -import { meshRouteStateFor, meshRouteStateTokens } from './meshRouteState'; +import { + RoutingNodeCard as RoutingNodeCardPrimitive, + type RoutingAliasRow, + type RoutingNodeCardMeta, + type RoutingNodeState, +} from '@/components/ui/routing-node-card'; interface Props { status: MeshNodeStatus; @@ -18,25 +20,108 @@ interface Props { onChanged: () => void; } +const REVERSE_BRIDGE: Record = { + connected: 'up', + connecting: 'unavailable', + unavailable: 'unavailable', + not_applicable: 'na', +}; + +function deriveNodeState(status: MeshNodeStatus): RoutingNodeState { + if (status.reachableMode === 'unreachable') return 'offline'; + if (!status.enabled) return 'idle'; + if (status.reachableMode === 'pilot' && !status.pilotConnected) return 'degraded'; + if (status.reverseCallbackStatus === 'unavailable' || status.reverseCallbackStatus === 'connecting') return 'degraded'; + return 'meshed'; +} + +function buildFooterContext( + nodeState: RoutingNodeState, + status: MeshNodeStatus, + seenAgeMs: number, +): string { + const seen = formatAgeShort(seenAgeMs); + switch (nodeState) { + case 'meshed': { + const reverse = status.reverseCallbackStatus === 'connected' + ? 'up' + : status.reverseCallbackStatus === 'not_applicable' + ? 'n/a' + : 'unavail'; + return `Reverse bridge ${reverse} · reconcile ${seen}`; + } + case 'idle': + return `Mesh off · seen ${seen}`; + case 'degraded': + return status.reverseCallbackStatus === 'connecting' + ? `Redialing · last update ${seen}` + : `Last seen ${seen}`; + case 'offline': + return `Last seen ${seen}`; + } +} + export function RoutingNodeCard({ status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, }: Props) { const [toggling, setToggling] = useState(false); const [testingAlias, setTestingAlias] = useState(null); + const [lastTestedByHost, setLastTestedByHost] = useState>(() => new Map()); + const lastSeenRef = useRef(Date.now()); + const lastStatusSignatureRef = useRef(''); - const nodeAliases = aliases.filter((a) => a.nodeId === status.nodeId); - const hasOptIns = status.optedInStacks.length > 0; - // Defensive de-dup: `/mesh/status` and `/mesh/aliases` are fetched - // separately, so a transient inconsistency between the two snapshots - // could otherwise render both a live alias row and a suspended row for - // the same stack. Drop suspended entries that the alias snapshot still - // covers; the alias row already represents the live state. - const stackNamesWithAliases = new Set(nodeAliases.map((a) => a.stackName)); - const suspendedOptIns = status.optedInStacks.filter( - (s) => !s.currentlyResolvable && !stackNamesWithAliases.has(s.stackName), + // Track only health-bearing fields; `activeStreamCount` and stack-count + // churn would otherwise reset the "seen" clock on every reconcile tick. + const signature = `${status.enabled}|${status.reachableMode}|${status.pilotConnected}|${status.reverseCallbackStatus}`; + if (signature !== lastStatusSignatureRef.current) { + lastStatusSignatureRef.current = signature; + lastSeenRef.current = Date.now(); + } + + const nodeAliases = useMemo(() => aliases.filter((a) => a.nodeId === status.nodeId), [aliases, status.nodeId]); + // Defensive de-dup: `/mesh/status` and `/mesh/aliases` are fetched separately, + // so a transient inconsistency could otherwise render both a live row and a + // suspended row for the same stack. Drop suspended entries the alias snapshot + // still covers; the alias row already represents the live state. + const stackNamesWithAliases = useMemo( + () => new Set(nodeAliases.map((a) => a.stackName)), + [nodeAliases], + ); + const suspended = useMemo( + () => status.optedInStacks.filter( + (s) => !s.currentlyResolvable && !stackNamesWithAliases.has(s.stackName), + ), + [status.optedInStacks, stackNamesWithAliases], ); + const rows: RoutingAliasRow[] = useMemo(() => { + const live: RoutingAliasRow[] = nodeAliases.map((a) => ({ + host: a.host, + port: a.port, + kind: 'alias', + lastTested: lastTestedByHost.get(a.host), + testing: testingAlias === a.host, + })); + const sus: RoutingAliasRow[] = suspended.map((s) => ({ + host: s.stackName, + port: 0, + kind: 'suspended', + })); + return [...live, ...sus]; + }, [nodeAliases, suspended, lastTestedByHost, testingAlias]); + + const nodeState = deriveNodeState(status); + const meta: RoutingNodeCardMeta = { + pilotConnected: status.pilotConnected, + reverseBridge: REVERSE_BRIDGE[status.reverseCallbackStatus], + stacks: status.optedInStacks.length, + aliases: nodeAliases.length, + }; + const seenAgeMs = Date.now() - lastSeenRef.current; + const footerContext = buildFooterContext(nodeState, status, seenAgeMs); + const toggleEnabled = async (next: boolean) => { + if (toggling) return; setToggling(true); try { const action = next ? 'enable' : 'disable'; @@ -53,150 +138,43 @@ export function RoutingNodeCard({ } }; - const runTest = async (alias: string) => { - setTestingAlias(alias); - try { await onTestUpstream(alias); } finally { setTestingAlias(null); } + const handleTestAlias = async (host: string) => { + if (testingAlias) return; + setTestingAlias(host); + try { + await onTestUpstream(host); + setLastTestedByHost((prev) => { + const next = new Map(prev); + next.set(host, { at: Date.now(), ok: true }); + return next; + }); + } catch { + setLastTestedByHost((prev) => { + const next = new Map(prev); + next.set(host, { at: Date.now(), ok: false }); + return next; + }); + } finally { + setTestingAlias(null); + } }; return ( - - -
-
- {status.nodeName} - {status.reachableMode === 'local' && ( - - ★ Local - - )} - {status.reachableMode === 'pilot' && !status.pilotConnected && ( - - pilot offline - - )} - {status.reachableMode === 'unreachable' && ( - - unreachable - - )} - {status.reverseCallbackStatus === 'connecting' && ( - - reconnecting - - )} - {status.reverseCallbackStatus === 'unavailable' && ( - - reverse unavailable - - )} -
-
- { void toggleEnabled(next); }} - /> - -
-
- - {status.reachableMode === 'unreachable' && status.reachableReason && ( -
- {status.reachableReason} -
- )} - {status.reachableMode === 'pilot' && !status.pilotConnected && ( -
- Pilot tunnel is not connected. Mesh traffic resumes when the agent reconnects. -
- )} - -
-
Mesh stacks
-
{status.optedInStacks.length}
-
Aliases
-
{nodeAliases.length}
-
- - {status.enabled && ( - <> -
- {!hasOptIns && nodeAliases.length === 0 && ( -
No mesh services on this node yet.
- )} - {nodeAliases.map((a) => { - const pillState = meshRouteStateFor({ - optedIn: true, - pilotConnected: status.pilotConnected, - }); - const pill = meshRouteStateTokens(pillState); - return ( -
- - - {pill.label} - - -
- ); - })} - {suspendedOptIns.map((s) => ( -
- - {s.stackName} - - - suspended - -
- ))} - {suspendedOptIns.length > 0 && ( -
- Stack stopped, alias resumes when services start. -
- )} -
- - - )} -
-
+ { void toggleEnabled(next); }} + onShowDiagnostics={onShowDiagnostics} + onShowAlias={onShowAlias} + onTestAlias={(host) => { void handleTestAlias(host); }} + onAddStack={onAddStack} + onRetry={onChanged} + footerContext={footerContext} + offlineReason={status.reachableReason} + /> ); } - diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx index 1f904559..895ddd87 100644 --- a/frontend/src/components/fleet/RoutingTab.tsx +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -166,7 +166,7 @@ export function RoutingTab() { Add a stack to the mesh and its services become reachable from any other meshed stack by hostname. No VPN, no firewall changes. -
+
{status.map((s) => ( {viewMode === 'table' ? ( -
+
{status.map((s) => ( void; + onShowDiagnostics: () => void; + onShowAlias?: (alias: string) => void; + onTestAlias?: (alias: string) => void; + onAddStack?: () => void; + onRetry?: () => void; + footerContext: string; + /** Offline-state reason copy; falls back to a generic line. */ + offlineReason?: string | null; +} + +const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; + +const RAIL_CLASS: Record = { + meshed: 'bg-brand', + idle: '', + degraded: 'bg-warning', + offline: 'bg-destructive', +}; + +const RAIL_INLINE_STYLE: Record = { + meshed: undefined, + idle: { background: 'oklch(0.28 0 0)' }, + degraded: undefined, + offline: undefined, +}; + +const STATE_CHIP: Record = { + meshed: { label: 'Meshed', tone: 'border-brand/40 bg-brand/10 text-brand' }, + idle: { label: 'Idle', tone: 'border-card-border bg-card text-stat-subtitle' }, + degraded: { label: 'Degraded', tone: 'border-warning/40 bg-warning/10 text-warning' }, + offline: { label: 'Offline', tone: 'border-destructive/40 bg-destructive/10 text-destructive' }, +}; + +const REVERSE_LABEL: Record = { + up: 'up', + unavailable: 'unavailable', + na: 'n/a', +}; + +export function RoutingNodeCard(props: RoutingNodeCardProps) { + const { + crumb, name, isLocal, nodeState, meta, aliases, + onToggleEnabled, onShowDiagnostics, onShowAlias, onTestAlias, + onAddStack, onRetry, footerContext, offlineReason, + } = props; + const [density] = useDensity(); + const compact = density === 'compact'; + + const toggleDisabled = nodeState === 'offline'; + const diagnosticsDisabled = nodeState === 'offline'; + const isEnabled = nodeState === 'meshed' || nodeState === 'degraded'; + const chip = STATE_CHIP[nodeState]; + + const railClass = RAIL_CLASS[nodeState]; + const railStyle = RAIL_INLINE_STYLE[nodeState]; + + return ( + + + ); +} + +interface BodyChrome { + name: string; + isLocal?: boolean; + chip: { label: string; tone: string }; + meta: RoutingNodeCardMeta; + nodeState: RoutingNodeState; + isEnabled: boolean; + toggleDisabled: boolean; + diagnosticsDisabled: boolean; + onToggleEnabled: (next: boolean) => void; + onShowDiagnostics: () => void; + footerContext: string; + onAddStack?: () => void; + onRetry?: () => void; +} + +interface CompactProps extends BodyChrome { + aliasesEmpty: boolean; +} + +interface ComfortableProps extends BodyChrome { + crumb: string[]; + aliases: RoutingAliasRow[]; + onShowAlias?: (alias: string) => void; + onTestAlias?: (alias: string) => void; + offlineReason?: string | null; +} + +function ComfortableBody(props: ComfortableProps) { + const { + crumb, name, isLocal, chip, meta, nodeState, isEnabled, + toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics, + aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason, + } = props; + const published = aliases.filter((a) => a.kind === 'alias').length; + const showAliases = aliases.length > 0; + const sectionTitle = published < aliases.length + ? `Aliases · ${published} of ${aliases.length}` + : `Aliases · ${aliases.length}`; + + return ( + <> +
+
+ {crumb.join(' › ')} +
+
+

+ {name} +

+ {isLocal && ( + + ★ Local + + )} +
+
+ pilot {meta.pilotConnected ? 'connected' : 'offline'} + {' · '}reverse {REVERSE_LABEL[meta.reverseBridge]} + {' · '}{meta.stacks} stacks + {' · '}{meta.aliases} aliases +
+
+ + + +
+ {showAliases + ? + : } +
+ +