fix(mesh): surface data-plane failures in health, meta, and Routing tab (#1088)

The three previously-silent console.warn paths in MeshService.setupMeshNetwork
now route through a typed recordSetupFailure helper that classifies the
failure (subnet_invalid, subnet_overlap, subnet_mismatch, ip_in_use,
attach_failed, not_in_docker), emits a mesh.disable activity entry at the
matching level (error for real failures, warn for the expected dev-mode
not_in_docker case), and strips mesh_proxy_callback_bootstrap from
advertised capabilities via CapabilityRegistry.

/api/health gains a mesh.dataPlane block carrying the typed status.
/api/mesh/status carries localDataPlane at the top level so the Routing tab
renders a red banner with an operator-actionable recovery hint (set
SENCHO_MESH_SUBNET to a free /24 and recreate the container) when the data
plane is down. The success path re-enables the capability and flips the
status to ok.

Generalizes the previously-documented F-0 failure mode (IP-in-subnet
collision) to also cover the subnet-pool-overlap case where another Docker
bridge on the host already owns the requested CIDR.
This commit is contained in:
Anso
2026-05-17 17:31:57 -04:00
committed by GitHub
parent f11e5ef58c
commit 578cac89da
9 changed files with 497 additions and 36 deletions
+44 -3
View File
@@ -3,7 +3,7 @@ import { apiFetch } from '@/lib/api';
import { visibilityInterval } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { Button } from '@/components/ui/button';
import { ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react';
import { AlertTriangle, ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react';
import { RoutingNodeCard } from './RoutingNodeCard';
import { MeshOptInSheet } from './MeshOptInSheet';
import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
@@ -12,7 +12,7 @@ import { MeshActivitySheet } from './MeshActivitySheet';
import { MeshTopologyGraph, type MeshGraphEdgeMode } from './MeshTopologyGraph';
import { MeshStackTopologySheet } from './MeshStackTopologySheet';
import { SegmentedControl } from '@/components/ui/segmented-control';
import type { MeshAlias, MeshNodeStatus, MeshProbeResult } from '@/types/mesh';
import type { MeshAlias, MeshDataPlaneStatus, MeshNodeStatus, MeshProbeResult } from '@/types/mesh';
type RoutingViewMode = 'table' | 'graph';
@@ -46,6 +46,7 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
export function RoutingTab() {
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
const [localDataPlane, setLocalDataPlane] = useState<MeshDataPlaneStatus | null>(null);
const [aliases, setAliases] = useState<MeshAlias[]>([]);
const [loading, setLoading] = useState(true);
const [optInNode, setOptInNode] = useState<{ id: number; name: string } | null>(null);
@@ -63,8 +64,9 @@ export function RoutingTab() {
apiFetch('/mesh/aliases', { localOnly: true }),
]);
if (statusRes.ok) {
const body = await statusRes.json() as { nodes: MeshNodeStatus[] };
const body = await statusRes.json() as { nodes: MeshNodeStatus[]; localDataPlane?: MeshDataPlaneStatus };
setStatus(body.nodes);
if (body.localDataPlane) setLocalDataPlane(body.localDataPlane);
}
if (aliasesRes.ok) {
const body = await aliasesRes.json() as { aliases: MeshAlias[] };
@@ -156,6 +158,7 @@ export function RoutingTab() {
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} reachableNodes={reachableNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<DataPlaneBanner status={localDataPlane} />
<div className="flex flex-col items-center justify-center py-12 rounded border border-dashed border-card-border bg-card/50">
<ArrowLeftRight className="w-12 h-12 text-stat-subtitle mb-4" />
<div className="text-lg font-display italic mb-2">Mesh containers across nodes</div>
@@ -196,6 +199,7 @@ export function RoutingTab() {
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} reachableNodes={reachableNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<DataPlaneBanner status={localDataPlane} />
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl<RoutingViewMode>
value={viewMode}
@@ -256,6 +260,43 @@ export function RoutingTab() {
);
}
/**
* Visible when the local Sencho's mesh data plane is down. Distinguishes the
* common reasons (subnet conflict, IP-in-use, invalid CIDR) so the operator
* does not have to dig through the activity log to know how to recover. The
* `not_in_docker` warn-level case is intentionally suppressed: it is the
* expected condition for dev-mode startup.
*/
function DataPlaneBanner({ status }: { status: MeshDataPlaneStatus | null }) {
if (!status || status.ok || status.reason === 'not_in_docker' || status.reason === 'not_started') {
return null;
}
const headlines: Record<Exclude<MeshDataPlaneStatus['reason'], 'ok' | 'not_started' | 'not_in_docker'>, string> = {
subnet_invalid: 'SENCHO_MESH_SUBNET is not a valid CIDR.',
subnet_overlap: `Mesh subnet ${status.subnet} overlaps another Docker network on this host.`,
subnet_mismatch: `sencho_mesh already exists with a different subnet than ${status.subnet}.`,
ip_in_use: `Another container is using Sencho's address on ${status.subnet}.`,
attach_failed: 'Sencho could not attach to its own mesh network.',
};
const reason = status.reason as keyof typeof headlines;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive flex items-start gap-3 shadow-card-bevel">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" strokeWidth={1.75} />
<div className="min-w-0 space-y-1">
<div className="font-medium">Mesh data plane is down</div>
<div className="text-xs leading-relaxed text-destructive/90">
{headlines[reason] ?? 'Mesh setup did not complete.'}
{' '}
Set <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">SENCHO_MESH_SUBNET</code> to a free <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">/24</code> (for example <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">10.42.0.0/24</code>) and restart the Sencho container.
</div>
{status.message ? (
<div className="text-[11px] font-mono text-destructive/80 truncate">{status.message}</div>
) : null}
</div>
</div>
);
}
function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActivity }: {
meshedNodes: number; reachableNodes: number; totalAliases: number; onShowActivity: () => void;
}) {
+17
View File
@@ -1,5 +1,22 @@
export type MeshRoutePillState = 'healthy' | 'degraded' | 'unreachable' | 'tunnel-down' | 'not-authorized';
export type MeshDataPlaneReason =
| 'ok'
| 'not_started'
| 'subnet_invalid'
| 'subnet_overlap'
| 'subnet_mismatch'
| 'ip_in_use'
| 'attach_failed'
| 'not_in_docker';
export interface MeshDataPlaneStatus {
ok: boolean;
reason: MeshDataPlaneReason;
message: string | null;
subnet: string;
}
export interface MeshAlias {
host: string;
nodeId: number;