import { useEffect, useState, useCallback } from 'react'; import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight, Plus } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog'; // Mirrors the backend networking payload shapes (the frontend never imports // backend). IntentEntry intentionally keeps only the fields this panel reads. type ExposureIntent = 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown'; const INTENTS: readonly ExposureIntent[] = ['internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown']; interface NetworkFactNetwork { key: string; name: string; external: boolean; internal: boolean; createdByStack: boolean } interface NetworkFactPort { hostIp: string; startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean } interface NetworkFactService { name: string; networks: { key: string; aliases: string[] }[]; publishedPorts: NetworkFactPort[]; networkMode?: string; extraHosts: string[]; } interface NetworkDriftFacts { runtimeOnlyAttachments: { container: string; service: string | null; network: string }[]; declaredButUnused: string[]; missingFromRuntime: string[]; foreignNetworkAttachments: { container: string; network: string }[]; } interface StackNetworkFacts { stack: string; renderable: boolean; renderError: string | null; runtime: 'available' | 'unavailable'; networks: NetworkFactNetwork[]; services: NetworkFactService[]; drift: NetworkDriftFacts; } interface IntentEntry { service: string; intent: ExposureIntent } const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; const ACTION_CLASS = 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40'; const CARD_CLASS = 'rounded-lg border px-3 py-2.5'; function portLabel(p: NetworkFactPort): string { const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`; return `${range}/${p.protocol}`; } /** Defensively read the intents array from an exposure response body. */ function asIntents(body: unknown): IntentEntry[] { const list = (body as { intents?: unknown })?.intents; return Array.isArray(list) ? (list as IntentEntry[]) : []; } /** A small chip that states the binding scope of a published port. */ function BindingBadge({ port }: { port: NetworkFactPort }) { if (port.allInterfaces) { return all interfaces; } if (port.loopbackOnly) { return loopback; } return {port.hostIp}; } /** * Exposure-intent picker: a row of pills plus a clear option. `value` null means * the scope is cleared. The clear option reads "unset" on the stack row and * "inherit" on a per-service row, where the service then falls back to the stack * intent. Disabled and read-only when the user cannot edit the stack. */ function IntentControl({ value, inherited, canEdit, onChange }: { value: ExposureIntent | null; inherited?: ExposureIntent | null; canEdit: boolean; onChange: (intent: ExposureIntent | null) => void; }) { const pill = (active: boolean) => cn( 'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide border transition-colors', active ? 'border-brand/50 bg-brand/15 text-brand' : 'border-muted bg-card/40 text-stat-subtitle', canEdit ? 'hover:border-brand/40' : 'cursor-default opacity-90', ); const clearLabel = inherited !== undefined ? 'inherit' : 'unset'; const cleared = value === null; return (
{INTENTS.map(opt => ( ))} {cleared && inherited && ( → {inherited} )}
); } export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled }: { stackName: string; canEdit: boolean; doctorEnabled: boolean; }) { const { activeNode } = useNodes(); const nodeId = activeNode?.id; const [facts, setFacts] = useState(null); const [intents, setIntents] = useState([]); const [loadError, setLoadError] = useState(false); const [refreshing, setRefreshing] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [showCreateNetwork, setShowCreateNetwork] = useState(false); useEffect(() => { let cancelled = false; const run = async () => { setLoadError(false); setRefreshing(true); try { const [factsRes, exposureRes] = await Promise.all([ apiFetch(`/stacks/${stackName}/networking`), apiFetch(`/stacks/${stackName}/exposure`), ]); if (cancelled) return; if (!factsRes.ok) { setLoadError(true); toast.error('Failed to load the networking view.'); return; } setFacts((await factsRes.json()) as StackNetworkFacts); // The exposure overlay is secondary: a bad exposure body must not tear // down a working facts view, so its parse is tolerated on its own. if (exposureRes.ok) { try { setIntents(asIntents(await exposureRes.json())); } catch { /* keep intents unset */ } } } catch { if (!cancelled) { setLoadError(true); toast.error('Failed to load the networking view.'); } } finally { if (!cancelled) setRefreshing(false); } }; void run(); return () => { cancelled = true; }; }, [stackName, nodeId, reloadKey]); const stackIntent = intents.find(i => i.service === '')?.intent ?? null; const intentFor = (service: string): ExposureIntent | null => intents.find(i => i.service === service)?.intent ?? null; const saveIntent = useCallback(async (service: string, intent: ExposureIntent | null) => { try { const res = await apiFetch(`/stacks/${stackName}/exposure`, { method: 'PUT', body: JSON.stringify({ service, intent }), }); if (!res.ok) { toast.error('Failed to save the exposure intent.'); return; } setIntents(asIntents(await res.json())); } catch { toast.error('Failed to save the exposure intent.'); } }, [stackName]); if (loadError) { return (
Could not load the networking view.
); } if (!facts) { return
Loading networking…
; } if (!facts.renderable) { return (
cannot render

{facts.renderError ?? 'Sencho could not render the effective Compose model.'}

); } const drift = facts.drift; const hasDrift = drift.runtimeOnlyAttachments.length > 0 || drift.foreignNetworkAttachments.length > 0 || drift.declaredButUnused.length > 0 || drift.missingFromRuntime.length > 0; return (
networking
{canEdit && ( )}
setReloadKey(k => k + 1)} /> {/* Exposure intent */}
exposure intent
stack saveIntent('', intent)} />
{facts.services.map(svc => (
{svc.name} saveIntent(svc.name, intent)} />
))}
{/* Networks */}
networks
{facts.networks.length === 0 &&
default network only
} {facts.networks.map(net => (
{net.name} {net.key !== net.name && ({net.key})} {net.external && external} {net.internal && internal} {net.createdByStack && created by stack}
))}
{/* Services */}
services
{facts.services.map(svc => (
{svc.name} {svc.networkMode && network_mode: {svc.networkMode}}
{svc.networks.length > 0 && (
{svc.networks.map(n => ( {n.key}{n.aliases.length > 0 && ({n.aliases.join(', ')})} ))}
)} {svc.networkMode === 'host' && ( // Host networking publishes every container port on the host, so // the service is host-exposed even with no published-port rows.
all container ports host-exposed
)} {svc.publishedPorts.length > 0 && (
{svc.publishedPorts.map((p, i) => (
{portLabel(p)}
))}
)} {svc.extraHosts.length > 0 && (
extra_hosts: {svc.extraHosts.join(', ')}
)}
))}
{/* Runtime drift */}
runtime drift
{facts.runtime === 'unavailable' ? (
runtime unavailable, showing the declared model only
) : !hasDrift ? (
runtime matches compose
) : (
{drift.runtimeOnlyAttachments.map((d, i) => (
{d.service ?? d.container} attached to undeclared network {d.network}
))} {drift.foreignNetworkAttachments.map((d, i) => (
{d.container} attached to a network owned by another stack: {d.network}
))} {drift.declaredButUnused.length > 0 && (
declared but unused by any running service: {drift.declaredButUnused.join(', ')}
)} {drift.missingFromRuntime.length > 0 && (
declared but missing from the runtime: {drift.missingFromRuntime.join(', ')}
)}
)}
{doctorEnabled && (
deploy and security findings are in the Doctor tab
)}
); }