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 => (
canEdit && onChange(opt)}>
{opt}
))}
canEdit && onChange(null)}>
{clearLabel}
{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.
setReloadKey(k => k + 1)} className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline">retry
);
}
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 && (
setShowCreateNetwork(true)} className={ACTION_CLASS}>
create network
)}
setReloadKey(k => k + 1)} disabled={refreshing} className={ACTION_CLASS}>
refresh
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
)}
);
}