mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
8980910153
* feat: add node-scoped Networking operator page Adds a Networking view with overview, topology, inventory, and findings. Shared aggregate reads back the page; Resources keeps prune and redirects here. Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing. * fix: rename unused variable n to _n to satisfy no-unused-vars lint * fix: keep top bar search clickable when nav grows * feat: complete Compose-first Networking Phase 2 operator assistant * fix: move networking action visibility helper out of component module * feat(networking): complete Compose-first Networking operator page Finish the node-scoped Networking page (Overview, Networks, Topology, Findings) with design-system parity and correct finding semantics. - Rebuild detail sheets on SystemSheet/SheetSection; align the tab band, masthead, and mobile tone with Fleet and Security. - Encode the host-mode and exposure severity matrix; fix collision counts so intentional shared externals are not flagged; add one typed drift predicate shared by inventory, topology, badges, and overview counts. - Preserve per-container attachments and IPs on topology node clicks; drawer-only click with an explicit logs action; ownership and boolean filters; bound large graphs before layout. - Aggregate cached Compose Doctor findings into the Findings tab with honest source labels, structural merge and dedupe, staleness reconciliation, and a shared exposure-context helper both engines use. - Networks tab: privacy-safe service search, precise ownership counts, schema v3 with version-2 adapters on every endpoint, pre-confirm delete reasons, and the shared sortable table with an internal scroll region. - Interop: Fleet node-card networking signal with pending-intent navigation, stack-to-node backlink, and Dossier/Drift deep links. - Enrich sanitized inspect with an allowlisted connected-container list; fetch topology once and filter client-side. - Docs and tests across every new finding kind, adapter, and flow. * fix(networking): correct drift count, exposure fail-soft, and inspect crash paths Address code-review findings on the Networking page implementation: - Fix the Overview drift count to use the shared drift-kind predicate instead of a hardcoded list that omitted external-network-missing. - Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented rules on exposure-context availability, so a DB read failure no longer fabricates findings (mirrors the live engine's existing fail-soft behavior). - Guard the per-stack exposure-intent read in topology aggregation so a transient DB failure degrades to unknown intent instead of failing the whole response. - Harden the network detail drawer against a partial inspect payload from an older remote node, and log the real error instead of a bare catch. - Remove now-duplicated severity-rank and drift-kind helpers in favor of the shared modules; drop dead backend-only exports; widen the frontend schema version type to a plain number instead of casting past a literal type. - Add coverage for the delete-guard precedence, the full host-mode severity matrix, the schema-2 compatibility adapter, and the sanitized connected- container allowlist; tighten two tests that were not exercising the behavior they claimed to. * fix: add missing onOpenNodeNetworking prop to FleetView experimental test The added required prop on FleetViewProps broke the merge-build when the test file (on main but not on this branch) was compiled against the updated FleetView interface.
163 lines
6.0 KiB
TypeScript
163 lines
6.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
|
|
const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const;
|
|
type NetworkDriver = (typeof NETWORK_DRIVERS)[number];
|
|
|
|
interface CreateNetworkForm {
|
|
name: string;
|
|
driver: NetworkDriver;
|
|
subnet: string;
|
|
gateway: string;
|
|
internal: boolean;
|
|
attachable: boolean;
|
|
}
|
|
|
|
const EMPTY_FORM: CreateNetworkForm = {
|
|
name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false,
|
|
};
|
|
|
|
interface CreateNetworkDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
initialName?: string;
|
|
onCreated?: (network: { name: string }) => void | Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Self-contained "Create network" modal. Owns its form state and posts to
|
|
* `/system/networks`, so it can be reused from the Resources Networks tab and
|
|
* the stack-detail Networking tab without sharing parent state.
|
|
*/
|
|
export function CreateNetworkDialog({ open, onOpenChange, initialName, onCreated }: CreateNetworkDialogProps) {
|
|
const [form, setForm] = useState<CreateNetworkForm>(EMPTY_FORM);
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open) setForm((current) => ({ ...current, name: initialName ?? current.name }));
|
|
}, [initialName, open]);
|
|
|
|
const handleCreate = async () => {
|
|
setCreating(true);
|
|
try {
|
|
const res = await apiFetch('/system/networks', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: form.name,
|
|
driver: form.driver,
|
|
subnet: form.subnet || undefined,
|
|
gateway: form.gateway || undefined,
|
|
internal: form.internal,
|
|
attachable: form.attachable,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
throw new Error(data?.error || `Failed to create network (${res.status})`);
|
|
}
|
|
toast.success(`Network "${form.name}" created`);
|
|
onOpenChange(false);
|
|
setForm(EMPTY_FORM);
|
|
await onCreated?.({ name: form.name });
|
|
} catch (error) {
|
|
const err = error as Record<string, unknown>;
|
|
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal open={open} onOpenChange={onOpenChange} size="md">
|
|
<ModalHeader
|
|
kicker="NETWORKS · NEW"
|
|
title="Create network"
|
|
description="Create a new Docker network for inter-container communication."
|
|
/>
|
|
<ModalBody>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="net-name" className="text-xs font-medium">Name</Label>
|
|
<Input
|
|
id="net-name"
|
|
placeholder="my-network"
|
|
className="font-mono text-sm"
|
|
value={form.name}
|
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="net-driver" className="text-xs font-medium">Driver</Label>
|
|
<Combobox
|
|
options={NETWORK_DRIVERS.map(d => ({ value: d, label: d }))}
|
|
value={form.driver}
|
|
onValueChange={v => setForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))}
|
|
placeholder="Select driver..."
|
|
searchPlaceholder="Search drivers..."
|
|
emptyText="No matching driver."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="net-subnet" className="text-xs font-medium">Subnet <span className="text-muted-foreground">(optional)</span></Label>
|
|
<Input
|
|
id="net-subnet"
|
|
placeholder="172.20.0.0/16"
|
|
className="font-mono text-sm"
|
|
value={form.subnet}
|
|
onChange={e => setForm(f => ({ ...f, subnet: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="net-gateway" className="text-xs font-medium">Gateway <span className="text-muted-foreground">(optional)</span></Label>
|
|
<Input
|
|
id="net-gateway"
|
|
placeholder="172.20.0.1"
|
|
className="font-mono text-sm"
|
|
value={form.gateway}
|
|
onChange={e => setForm(f => ({ ...f, gateway: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-6 pt-1">
|
|
<div className="flex items-center gap-2">
|
|
<TogglePill
|
|
id="net-internal"
|
|
checked={form.internal}
|
|
onChange={v => setForm(f => ({ ...f, internal: v }))}
|
|
/>
|
|
<Label htmlFor="net-internal" className="text-xs cursor-pointer">Internal <span className="text-muted-foreground">(no external access)</span></Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<TogglePill
|
|
id="net-attachable"
|
|
checked={form.attachable}
|
|
onChange={v => setForm(f => ({ ...f, attachable: v }))}
|
|
/>
|
|
<Label htmlFor="net-attachable" className="text-xs cursor-pointer">Attachable</Label>
|
|
</div>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter
|
|
hint={`DRIVER ${form.driver}`}
|
|
secondary={
|
|
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={creating}>
|
|
Cancel
|
|
</Button>
|
|
}
|
|
primary={
|
|
<Button size="sm" onClick={handleCreate} disabled={!form.name.trim() || creating}>
|
|
{creating ? 'Creating...' : 'Create network'}
|
|
</Button>
|
|
}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|