mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
fix(mesh): auto-fallback through candidate subnets when default overlaps (#1156)
The default mesh subnet 172.30.0.0/24 is fully contained in linuxserver/* default networks (sonarr_default 172.30.0.0/16, etc.), so libnetwork rejects the IPAM allocation with "Pool overlaps with other one on this address space" on a typical homelab Docker host. The single hard-coded default left first-run operators with a silently broken mesh. MeshService.setupMeshNetwork now resolves the subnet via three paths: 1. Operator-explicit (SENCHO_MESH_SUBNET set): use that subnet, strict. Pre-existing sencho_mesh with a different subnet still raises subnet_mismatch. 2. Adopt-existing (env unset, sencho_mesh already on the daemon): adopt the existing subnet. Docker is the source of truth across restarts. 3. Candidate iteration (env unset, no existing network): walk 172.30.0.0/24, 172.31.0.0/24, 10.42.0.0/24, 10.43.0.0/24 in order. First subnet Docker accepts wins. If every candidate overlaps, record subnet_overlap with a message naming every attempt. The dashboard's Fleet Heartbeat card now surfaces the down state via a compact banner above the per-node rows, plus a "mesh down" counter suffix on the right of the title. The existing Routing-tab banner is extracted into a shared MeshDataPlaneBanner component with tab and card variants. Dashboard polling is gated on Admiral tier so non-paid users do not fire the Admiral-only /mesh/status endpoint. Six new tests in mesh-setup-error-classification cover: iterates past first overlap, all candidates overlap, adopts existing network, inspectNetwork non-404 failure classified as attach_failed, env-matches- existing skip-create, and operator-explicit strict (no fallback). Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that left the mesh down on the most common homelab Docker layout.
This commit is contained in:
@@ -2,7 +2,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Radio, CheckCircle2 } from 'lucide-react';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { useFleetHeartbeat } from './useFleetHeartbeat';
|
||||
import { useMeshDataPlane } from './useMeshDataPlane';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { MeshDataPlaneBanner } from '@/components/fleet/MeshDataPlaneBanner';
|
||||
import type { FleetNodeOverview } from './useFleetHeartbeat';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
@@ -54,7 +56,11 @@ function SkeletonRow() {
|
||||
|
||||
export function FleetHeartbeat() {
|
||||
const { nodes: overviewNodes, loading, error } = useFleetHeartbeat();
|
||||
const { status: meshDataPlane } = useMeshDataPlane();
|
||||
const { nodes: contextNodes } = useNodes();
|
||||
const meshDown = meshDataPlane?.ok === false
|
||||
&& meshDataPlane.reason !== 'not_in_docker'
|
||||
&& meshDataPlane.reason !== 'not_started';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -102,11 +108,15 @@ export function FleetHeartbeat() {
|
||||
{unreachableCount > 0 && (
|
||||
<span className="text-destructive"> · {unreachableCount} unreachable</span>
|
||||
)}
|
||||
{meshDown && (
|
||||
<span className="text-destructive"> · mesh down</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{meshDown && <MeshDataPlaneBanner status={meshDataPlane} variant="card" />}
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-stat-subtitle">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" strokeWidth={1.5} />
|
||||
|
||||
@@ -9,6 +9,7 @@ export { StackRestartMap } from './StackRestartMap';
|
||||
export { useDashboardData } from './useDashboardData';
|
||||
export { useConfigurationStatus } from './useConfigurationStatus';
|
||||
export { useFleetHeartbeat } from './useFleetHeartbeat';
|
||||
export { useMeshDataPlane } from './useMeshDataPlane';
|
||||
export { useStackRestartMap } from './useStackRestartMap';
|
||||
export type * from './types';
|
||||
export type { ConfigurationStatus as ConfigurationStatusPayload } from './useConfigurationStatus';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
import type { MeshDataPlaneStatus } from '@/types/mesh';
|
||||
|
||||
export interface MeshDataPlaneResult {
|
||||
status: MeshDataPlaneStatus | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `/mesh/status` for the local data-plane health so dashboard surfaces
|
||||
* can flag a down mesh without opening the Routing tab. The endpoint is
|
||||
* Admiral-gated, so the hook short-circuits on non-Admiral tiers (no
|
||||
* request fired, no banner rendered). On the rare 403 from an Admiral
|
||||
* tier (token race during downgrade) we leave `status` at null. 30 s
|
||||
* cadence matches `useFleetHeartbeat` so the dashboard refresh feel is
|
||||
* consistent.
|
||||
*/
|
||||
export function useMeshDataPlane(): MeshDataPlaneResult {
|
||||
const { permissions } = useAuth();
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const [status, setStatus] = useState<MeshDataPlaneStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/mesh/status', { localOnly: true });
|
||||
if (res.status === 403) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) return;
|
||||
const body = await res.json() as { localDataPlane?: MeshDataPlaneStatus };
|
||||
if (body.localDataPlane) setStatus(body.localDataPlane);
|
||||
} catch {
|
||||
// Background poll; transient network errors stay silent so the
|
||||
// card does not flicker on every refresh failure.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmiral) {
|
||||
setStatus(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void fetchStatus();
|
||||
return visibilityInterval(() => { void fetchStatus(); }, 30_000);
|
||||
}, [isAdmiral, fetchStatus]);
|
||||
|
||||
return { status, loading };
|
||||
}
|
||||
Reference in New Issue
Block a user