mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
f599110386
The separate saelix/sencho-mesh sidecar container is gone. The
forwarder logic that previously lived in mesh-sidecar/src/forwarder.ts
moves into the Sencho process as backend/src/services/MeshForwarder.ts,
a thin per-port net.Server lifecycle wrapper. MeshService implements
the host interface and owns resolve plus splice; MeshForwarder owns
listener boilerplate. One container per node, no separate image to
publish, no control WebSocket.
Operator-facing change: the Sencho container now runs in
network_mode: host so the forwarder can bind alias ports on the host
network where meshed containers' extra_hosts host-gateway entries
point. Without host network mode the listeners would land in the
container's namespace and inbound traffic from peers would never
reach them. The 1852:1852 port publish becomes a no-op under host
mode and is commented out in the operator template.
Same-node forward path now dials the target container's bridge IP
via Dockerode (preferring the compose default network for
deterministic selection across daemon versions) instead of 127.0.0.1.
The legacy 127.0.0.1 path only worked when the target service
published its port to the host; the IP path works regardless.
Cross-node mesh routing in this phase is central -> pilot direction
only via PilotTunnelManager.openTcpStream. Pilot -> central and
pilot <-> pilot via central relay land in Phase B with the
tcp_open_reverse frame.
Deletions:
- mesh-sidecar/ package entirely (Dockerfile, package, sources, tests)
- backend/src/websocket/meshControl.ts
- MeshService sidecar lifecycle: spawnSidecar, stopSidecar,
isSidecarRunning, mintSidecarToken, verifySidecarToken,
attachSidecarSocket, handleSidecarResolve, sendSidecar
- POST /api/mesh/nodes/:id/sidecar/restart route
- /api/mesh/control WS dispatch in upgradeHandler
Type cleanup: 'sidecar' literal removed from MeshActivitySource and
MeshProbeResult.where (also the frontend mirror). MeshNodeStatus
sidecarRunning becomes localForwarderListening (boolean | null) so
non-local nodes get a null instead of an unconditional false; the
honest semantic is "this view only knows the local forwarder state;
remote forwarder status lands in Phase B." MeshNodeDiagnostic
sidecar object becomes forwarder { listening, listenerCount }.
Frontend MeshDiagnosticsSheet drops the restart-sidecar action and
sidecar liveness card; surfaces forwarder state plus a "runs
in-process; no separate container" caption.
Resolves audit findings C-1 (data plane non-functional), C-2 (sidecar
control WS not loopback-enforced), C-4 (sidecar lifecycle Dockerode-
on-remote, PR #999 closed), and C-5 (saelix/sencho-mesh:latest
unreachable). C-3 (PR #992) is unchanged. M-12 (PR #994) is
unchanged.
125 lines
5.5 KiB
TypeScript
125 lines
5.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
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 [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
|
|
|
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());
|
|
setUpdatedAt(Date.now());
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open) void refresh();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, nodeId]);
|
|
|
|
const forwarderLabel = diag
|
|
? (diag.forwarder.listening ? `forwarder listening (${diag.forwarder.listenerCount})` : 'forwarder idle')
|
|
: 'forwarder ?';
|
|
const pilotLabel = diag ? (diag.pilot.connected ? 'pilot connected' : 'pilot disconnected') : 'pilot ?';
|
|
const streamsLabel = `${diag?.activeStreams.length ?? 0} streams`;
|
|
const aliasesLabel = `${diag?.aliasCache.length ?? 0} aliases`;
|
|
const meta = `${forwarderLabel} · ${pilotLabel} · ${streamsLabel} · ${aliasesLabel}`;
|
|
|
|
const footerContext = updatedAt ? `Updated ${formatTimeAgo(updatedAt)}` : (loading ? 'Loading…' : 'Never updated');
|
|
|
|
return (
|
|
<SystemSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
crumb={['Fleet', 'Mesh', 'Diagnostics']}
|
|
name={nodeName ?? 'Diagnostics'}
|
|
meta={meta}
|
|
primaryAction={{
|
|
label: 'Refresh',
|
|
icon: RefreshCw,
|
|
onClick: () => { void refresh(); },
|
|
disabled: loading,
|
|
}}
|
|
footerContext={footerContext}
|
|
size="md"
|
|
>
|
|
<SheetSection title="Forwarder · pilot · transport">
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
|
<div className="text-stat-subtitle">Forwarder</div>
|
|
<div className="font-mono text-stat-value">
|
|
{diag?.forwarder.listening ? `listening on ${diag.forwarder.listenerCount} port${diag.forwarder.listenerCount === 1 ? '' : 's'}` : 'idle'}
|
|
</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>
|
|
<p className="text-[11px] text-stat-subtitle leading-snug mt-2">
|
|
Mesh runs in-process on each node; no separate container.
|
|
</p>
|
|
</SheetSection>
|
|
|
|
<SheetSection title="Active streams">
|
|
{(!diag || diag.activeStreams.length === 0) && (
|
|
<div className="text-xs text-stat-subtitle">No active streams.</div>
|
|
)}
|
|
<div className="divide-y divide-card-border/40">
|
|
{diag?.activeStreams.map((s) => (
|
|
<div key={s.streamId} className="flex justify-between py-1.5 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>
|
|
</SheetSection>
|
|
|
|
<SheetSection title="Resolver cache">
|
|
{(!diag || diag.aliasCache.length === 0) && (
|
|
<div className="text-xs text-stat-subtitle">No aliases registered.</div>
|
|
)}
|
|
<div className="divide-y divide-card-border/40">
|
|
{diag?.aliasCache.map((a) => (
|
|
<div key={a.host} className="flex justify-between py-1.5 text-[11px] font-mono">
|
|
<span>{a.host}</span>
|
|
<span className="text-stat-subtitle">node #{a.targetNodeId}:{a.port}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SheetSection>
|
|
</SystemSheet>
|
|
);
|
|
}
|