mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 20:59:09 +00:00
feat(routing): redesign node cards to the System Sheet recipe (#1133)
Lift <RoutingNodeCard> primitive from audit §21 / DESIGN §9.13. Migrate the fleet adapter to consume it: collapse five inline state pills into a single nodeState prop, replace the k/v stat list with a mono meta line, drop the per-row HEALTHY chip in favor of a state dot, and add a real empty-state block with a primary CTA for every state. Compact density body swap stays inside the component, no -compact fork.
This commit is contained in:
@@ -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<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
|
||||
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<string | null>(null);
|
||||
const [lastTestedByHost, setLastTestedByHost] = useState<Map<string, { at: number; ok: boolean }>>(() => new Map());
|
||||
const lastSeenRef = useRef<number>(Date.now());
|
||||
const lastStatusSignatureRef = useRef<string>('');
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{status.reachableMode === 'local' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-brand/40 bg-brand/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-brand">
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
{status.reachableMode === 'pilot' && !status.pilotConnected && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-destructive/40 bg-destructive/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-destructive">
|
||||
pilot offline
|
||||
</span>
|
||||
)}
|
||||
{status.reachableMode === 'unreachable' && (
|
||||
<span
|
||||
title={status.reachableReason ?? undefined}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-destructive/40 bg-destructive/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-destructive"
|
||||
>
|
||||
unreachable
|
||||
</span>
|
||||
)}
|
||||
{status.reverseCallbackStatus === 'connecting' && (
|
||||
<span
|
||||
title="Central is dialing the reverse bridge to this peer."
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-card-border bg-card text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle"
|
||||
>
|
||||
reconnecting
|
||||
</span>
|
||||
)}
|
||||
{status.reverseCallbackStatus === 'unavailable' && (
|
||||
<span
|
||||
title="Peer→central tunnel is between dials. Central will redial on its next reconcile tick."
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-sm border border-amber-500/40 bg-amber-500/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
reverse unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill
|
||||
checked={status.enabled}
|
||||
disabled={toggling || status.reachableMode === 'unreachable'}
|
||||
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.reachableMode === 'unreachable' && status.reachableReason && (
|
||||
<div className="text-[11px] text-destructive">
|
||||
{status.reachableReason}
|
||||
</div>
|
||||
)}
|
||||
{status.reachableMode === 'pilot' && !status.pilotConnected && (
|
||||
<div className="text-[11px] text-stat-subtitle">
|
||||
Pilot tunnel is not connected. Mesh traffic resumes when the agent reconnects.
|
||||
</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">
|
||||
{!hasOptIns && 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-[10px] leading-3 font-mono uppercase tracking-[0.18em] ${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>
|
||||
);
|
||||
})}
|
||||
{suspendedOptIns.map((s) => (
|
||||
<div
|
||||
key={`suspended-${s.stackName}`}
|
||||
className="flex items-center justify-between gap-2 rounded border border-card-border bg-card px-2 py-1.5"
|
||||
title="Stack is opted into the mesh but has no running services. Aliases will publish when the stack starts."
|
||||
>
|
||||
<span className="text-[11px] font-mono text-left truncate text-stat-subtitle">
|
||||
{s.stackName}
|
||||
</span>
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded-sm border border-amber-500/40 bg-amber-500/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400">
|
||||
suspended
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{suspendedOptIns.length > 0 && (
|
||||
<div className="text-[11px] text-stat-subtitle pt-1">
|
||||
Stack stopped, alias resumes when services start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm" className="w-full"
|
||||
onClick={onAddStack}
|
||||
disabled={status.reachableMode === 'unreachable'}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> Add stack to mesh
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RoutingNodeCardPrimitive
|
||||
crumb={['Routing', 'Node', status.nodeName]}
|
||||
name={status.nodeName}
|
||||
isLocal={status.reachableMode === 'local'}
|
||||
nodeState={nodeState}
|
||||
meta={meta}
|
||||
aliases={rows}
|
||||
onToggleEnabled={(next) => { void toggleEnabled(next); }}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={(host) => { void handleTestAlias(host); }}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onChanged}
|
||||
footerContext={footerContext}
|
||||
offlineReason={status.reachableReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 w-full">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-[18px] grid-auto-rows-min-content items-start w-full">
|
||||
{status.map((s) => (
|
||||
<RoutingNodeCard
|
||||
key={s.nodeId}
|
||||
@@ -223,7 +223,7 @@ export function RoutingTab() {
|
||||
)}
|
||||
</div>
|
||||
{viewMode === 'table' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-[18px] grid-auto-rows-min-content items-start">
|
||||
{status.map((s) => (
|
||||
<RoutingNodeCard
|
||||
key={s.nodeId}
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2, Play, Plus, RotateCw, ServerCog } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import { formatAgeShort } from '@/lib/relativeTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type RoutingNodeState = 'meshed' | 'idle' | 'degraded' | 'offline';
|
||||
|
||||
export interface RoutingAliasRow {
|
||||
host: string;
|
||||
port: number;
|
||||
kind: 'alias' | 'suspended';
|
||||
lastTested?: { at: number; ok: boolean };
|
||||
testing?: boolean;
|
||||
}
|
||||
|
||||
export interface RoutingNodeCardMeta {
|
||||
pilotConnected: boolean;
|
||||
reverseBridge: 'up' | 'unavailable' | 'na';
|
||||
stacks: number;
|
||||
aliases: number;
|
||||
}
|
||||
|
||||
export interface RoutingNodeCardProps {
|
||||
crumb: string[];
|
||||
name: string;
|
||||
isLocal?: boolean;
|
||||
nodeState: RoutingNodeState;
|
||||
meta: RoutingNodeCardMeta;
|
||||
aliases: RoutingAliasRow[];
|
||||
onToggleEnabled: (next: boolean) => 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<RoutingNodeState, string> = {
|
||||
meshed: 'bg-brand',
|
||||
idle: '',
|
||||
degraded: 'bg-warning',
|
||||
offline: 'bg-destructive',
|
||||
};
|
||||
|
||||
const RAIL_INLINE_STYLE: Record<RoutingNodeState, React.CSSProperties | undefined> = {
|
||||
meshed: undefined,
|
||||
idle: { background: 'oklch(0.28 0 0)' },
|
||||
degraded: undefined,
|
||||
offline: undefined,
|
||||
};
|
||||
|
||||
const STATE_CHIP: Record<RoutingNodeState, { label: string; tone: string }> = {
|
||||
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<RoutingNodeCardMeta['reverseBridge'], string> = {
|
||||
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 (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('absolute inset-y-0 left-0 w-[3px]', railClass)}
|
||||
style={railStyle}
|
||||
/>
|
||||
{compact
|
||||
? <CompactBody
|
||||
name={name}
|
||||
isLocal={isLocal}
|
||||
chip={chip}
|
||||
meta={meta}
|
||||
nodeState={nodeState}
|
||||
isEnabled={isEnabled}
|
||||
toggleDisabled={toggleDisabled}
|
||||
diagnosticsDisabled={diagnosticsDisabled}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
footerContext={footerContext}
|
||||
aliasesEmpty={aliases.length === 0}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
: <ComfortableBody
|
||||
crumb={crumb}
|
||||
name={name}
|
||||
isLocal={isLocal}
|
||||
chip={chip}
|
||||
meta={meta}
|
||||
nodeState={nodeState}
|
||||
isEnabled={isEnabled}
|
||||
toggleDisabled={toggleDisabled}
|
||||
diagnosticsDisabled={diagnosticsDisabled}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
aliases={aliases}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
footerContext={footerContext}
|
||||
offlineReason={offlineReason}
|
||||
/>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<header className="px-4 pt-4 pb-3 pl-5">
|
||||
<div className={cn(KICKER, 'text-stat-subtitle leading-none')}>
|
||||
{crumb.join(' › ')}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<h3 className="font-display italic text-[22px] leading-[28px] text-stat-value">
|
||||
{name}
|
||||
</h3>
|
||||
{isLocal && (
|
||||
<span className={cn(KICKER, 'inline-flex items-center px-1.5 py-0.5 rounded-sm border border-brand/40 bg-brand/10 text-brand')}>
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(KICKER, 'mt-1.5 text-stat-subtitle leading-none tracking-[0.14em]')}>
|
||||
pilot {meta.pilotConnected ? 'connected' : 'offline'}
|
||||
{' · '}reverse {REVERSE_LABEL[meta.reverseBridge]}
|
||||
{' · '}{meta.stacks} stacks
|
||||
{' · '}{meta.aliases} aliases
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Toolbar
|
||||
chip={chip}
|
||||
isEnabled={isEnabled}
|
||||
toggleDisabled={toggleDisabled}
|
||||
diagnosticsDisabled={diagnosticsDisabled}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onShowDiagnostics={onShowDiagnostics}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-3 pl-5">
|
||||
{showAliases
|
||||
? <AliasList
|
||||
title={sectionTitle}
|
||||
aliases={aliases}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
/>
|
||||
: <EmptyState
|
||||
nodeState={nodeState}
|
||||
name={name}
|
||||
offlineReason={offlineReason}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Footer context={footerContext} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactBody(props: CompactProps) {
|
||||
const {
|
||||
name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
footerContext, aliasesEmpty, onAddStack, onRetry,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-2 px-4 py-2.5 pl-5">
|
||||
<h3 className="font-display italic text-[18px] leading-[22px] text-stat-value mr-1">
|
||||
{name}
|
||||
</h3>
|
||||
{isLocal && (
|
||||
<span className={cn(KICKER, 'inline-flex items-center px-1 py-0.5 rounded-sm border border-brand/40 bg-brand/10 text-brand')}>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(KICKER, 'inline-flex items-center px-1.5 py-0.5 rounded-sm border', chip.tone)}>
|
||||
{chip.label}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<TogglePill
|
||||
checked={isEnabled}
|
||||
disabled={toggleDisabled}
|
||||
onChange={onToggleEnabled}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowDiagnostics}
|
||||
disabled={diagnosticsDisabled}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Diagnostics"
|
||||
>
|
||||
<ServerCog className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-4 divide-x divide-card-border/40 border-y border-card-border/40 bg-card/60 pl-[3px]">
|
||||
<CompactCell
|
||||
dot={meta.stacks > 0 ? 'success' : 'muted'}
|
||||
value={String(meta.stacks)}
|
||||
sub="opted in"
|
||||
/>
|
||||
<CompactCell
|
||||
dot={meta.aliases > 0 ? 'success' : 'muted'}
|
||||
value={String(meta.aliases)}
|
||||
sub="published"
|
||||
/>
|
||||
<CompactCell
|
||||
dot={meta.pilotConnected ? 'success' : 'warning'}
|
||||
value={meta.pilotConnected ? 'on' : 'off'}
|
||||
sub="agent"
|
||||
/>
|
||||
<CompactCell
|
||||
dot={reverseDot(meta.reverseBridge)}
|
||||
value={REVERSE_LABEL[meta.reverseBridge]}
|
||||
sub="bridge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CompactFooter
|
||||
context={footerContext}
|
||||
nodeState={nodeState}
|
||||
name={name}
|
||||
aliasesEmpty={aliasesEmpty}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function reverseDot(reverse: RoutingNodeCardMeta['reverseBridge']): DotTone {
|
||||
if (reverse === 'up') return 'success';
|
||||
if (reverse === 'unavailable') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
type DotTone = 'success' | 'warning' | 'destructive' | 'muted';
|
||||
|
||||
function CompactCell({ dot, value, sub }: { dot: DotTone; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="px-3 py-2.5 flex items-center gap-2">
|
||||
<Dot tone={dot} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[12px] leading-none text-stat-value tabular-nums">{value}</div>
|
||||
<div className={cn(KICKER, 'text-stat-subtitle leading-none mt-1')}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
chip: { label: string; tone: string };
|
||||
isEnabled: boolean;
|
||||
toggleDisabled: boolean;
|
||||
diagnosticsDisabled: boolean;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
onShowDiagnostics: () => void;
|
||||
}
|
||||
|
||||
function Toolbar({ chip, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics }: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 pl-5 border-y border-card-border/40 bg-card/60">
|
||||
<span className={cn(KICKER, 'inline-flex items-center px-1.5 py-0.5 rounded-sm border', chip.tone)}>
|
||||
{chip.label}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<TogglePill
|
||||
checked={isEnabled}
|
||||
disabled={toggleDisabled}
|
||||
onChange={onToggleEnabled}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowDiagnostics}
|
||||
disabled={diagnosticsDisabled}
|
||||
>
|
||||
<ServerCog className="w-3 h-3 mr-1" /> Diagnostics
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AliasListProps {
|
||||
title: string;
|
||||
aliases: RoutingAliasRow[];
|
||||
onShowAlias?: (alias: string) => void;
|
||||
onTestAlias?: (alias: string) => void;
|
||||
}
|
||||
|
||||
function AliasList({ title, aliases, onShowAlias, onTestAlias }: AliasListProps) {
|
||||
return (
|
||||
<section>
|
||||
<h4 className={cn(KICKER, 'text-stat-subtitle leading-none mb-2 tracking-[0.22em]')}>
|
||||
{title}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{aliases.map((row) => (
|
||||
<AliasRow
|
||||
key={`${row.kind}:${row.host}`}
|
||||
row={row}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface AliasRowProps {
|
||||
row: RoutingAliasRow;
|
||||
onShowAlias?: (alias: string) => void;
|
||||
onTestAlias?: (alias: string) => void;
|
||||
}
|
||||
|
||||
function AliasRow({ row, onShowAlias, onTestAlias }: AliasRowProps) {
|
||||
const isSuspended = row.kind === 'suspended';
|
||||
const dot: DotTone = isSuspended
|
||||
? 'warning'
|
||||
: row.lastTested
|
||||
? (row.lastTested.ok ? 'success' : 'destructive')
|
||||
: 'muted';
|
||||
const ageLabel = row.lastTested
|
||||
? formatAgeShort(Date.now() - row.lastTested.at)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded border border-card-border/40 bg-card/40 px-2 py-1.5 shadow-[inset_0_1px_0_oklch(0_0_0_/_0.03)]">
|
||||
<Dot tone={dot} />
|
||||
{isSuspended
|
||||
? <span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-subtitle">
|
||||
{row.host} <span className="opacity-70">(suspended)</span>
|
||||
</span>
|
||||
: onShowAlias
|
||||
? <button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 truncate text-left font-mono text-[11px] text-stat-value hover:text-brand transition-colors"
|
||||
onClick={() => onShowAlias(row.host)}
|
||||
>
|
||||
{row.host}<span className="text-brand">:{row.port}</span>
|
||||
</button>
|
||||
: <span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-value">
|
||||
{row.host}<span className="text-brand">:{row.port}</span>
|
||||
</span>}
|
||||
{!isSuspended && ageLabel && (
|
||||
<span className={cn(KICKER, 'shrink-0 text-stat-subtitle tabular-nums')}>{ageLabel}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onTestAlias?.(row.host)}
|
||||
disabled={isSuspended || row.testing || !onTestAlias}
|
||||
className="h-[22px] w-[22px] p-0 shrink-0"
|
||||
aria-label={`Test ${row.host}`}
|
||||
>
|
||||
{row.testing
|
||||
? <Loader2 className="w-3 h-3 animate-spin" />
|
||||
: <Play className="w-3 h-3" strokeWidth={1.75} />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
nodeState: RoutingNodeState;
|
||||
name: string;
|
||||
offlineReason?: string | null;
|
||||
onAddStack?: () => void;
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
}
|
||||
|
||||
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled }: EmptyStateProps) {
|
||||
const { headline, sub, cta } = emptyStateCopy(nodeState, name, offlineReason);
|
||||
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
else if (nodeState === 'meshed') onAddStack?.();
|
||||
else onRetry?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 py-3">
|
||||
<div className="font-display italic text-[18px] leading-[24px] text-stat-value">
|
||||
{headline}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
{sub}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn('mt-1', ctaToneFor(nodeState))}
|
||||
>
|
||||
{ctaIconFor(nodeState)}{cta}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ctaIconFor(state: RoutingNodeState): React.ReactNode {
|
||||
if (state === 'meshed') return <Plus className="w-3 h-3 mr-1" />;
|
||||
if (state === 'degraded' || state === 'offline') return <RotateCw className="w-3 h-3 mr-1" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
const CTA_TONE: Record<RoutingNodeState, string> = {
|
||||
meshed: '',
|
||||
idle: '',
|
||||
degraded: 'border-warning/40 text-warning hover:bg-warning/10',
|
||||
offline: 'border-destructive/40 text-destructive hover:bg-destructive/10',
|
||||
};
|
||||
|
||||
function ctaToneFor(state: RoutingNodeState): string {
|
||||
return CTA_TONE[state];
|
||||
}
|
||||
|
||||
function emptyStateCopy(state: RoutingNodeState, name: string, offlineReason?: string | null): {
|
||||
headline: string; sub: string; cta: string;
|
||||
} {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return {
|
||||
headline: 'Not in the mesh.',
|
||||
sub: 'Toggle on to start publishing aliases from this node.',
|
||||
cta: `Enable mesh on ${name}`,
|
||||
};
|
||||
case 'meshed':
|
||||
return {
|
||||
headline: 'No services routed yet.',
|
||||
sub: 'Mesh is on. Opt a stack in to start publishing aliases.',
|
||||
cta: '+ Add stack to mesh',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
headline: 'Pilot tunnel disconnected.',
|
||||
sub: 'Mesh traffic resumes when the agent reconnects.',
|
||||
cta: 'Retry now',
|
||||
};
|
||||
case 'offline':
|
||||
return {
|
||||
headline: 'Node unreachable.',
|
||||
sub: offlineReason || 'Connection refused.',
|
||||
cta: 'Retry now',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function Footer({ context }: { context: string }) {
|
||||
return (
|
||||
<div className="px-4 py-2 pl-5 border-t border-card-border/40 bg-card/60">
|
||||
<div className={cn(KICKER, 'text-stat-subtitle leading-none')}>
|
||||
{context}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompactFooterProps {
|
||||
context: string;
|
||||
nodeState: RoutingNodeState;
|
||||
name: string;
|
||||
aliasesEmpty: boolean;
|
||||
onAddStack?: () => void;
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
}
|
||||
|
||||
function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled }: CompactFooterProps) {
|
||||
const showCta = nodeState !== 'meshed' || aliasesEmpty;
|
||||
const { cta } = emptyStateCopy(nodeState, name);
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
else if (nodeState === 'meshed') onAddStack?.();
|
||||
else onRetry?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 pl-5 border-t border-card-border/40 bg-card/60">
|
||||
<div className={cn(KICKER, 'text-stat-subtitle leading-none truncate')}>
|
||||
{context}
|
||||
</div>
|
||||
{showCta && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn('ml-auto h-7', ctaToneFor(nodeState))}
|
||||
>
|
||||
{ctaIconFor(nodeState)}{cta}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DOT_CLASS: Record<DotTone, string> = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
muted: 'bg-stat-subtitle/50',
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: DotTone }) {
|
||||
return <span aria-hidden="true" className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', DOT_CLASS[tone])} />;
|
||||
}
|
||||
@@ -18,3 +18,18 @@ export function formatTimeAgo(timestamp: number): string {
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short age formatter for cockpit-style "12s / 4m / 3h / 2d" badges. Always
|
||||
* returns under five characters. Negative deltas (clock skew) read as `0s`.
|
||||
*/
|
||||
export function formatAgeShort(ms: number): string {
|
||||
if (ms < 1000) return '0s';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user