mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +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.
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
import type { NetworkingTopologyContainer, NetworkingTopologyEnvelope, NetworkingTopologyNetwork } from '@/types/networking';
|
|
|
|
export type TopologyOwnershipFilter = 'all' | 'managed' | 'external' | 'system';
|
|
|
|
export interface NetworkingTopologyFilters {
|
|
stack: string;
|
|
network: string;
|
|
ownership: TopologyOwnershipFilter;
|
|
exposedOnly: boolean;
|
|
driftOnly: boolean;
|
|
missingExternalOnly: boolean;
|
|
sharedOnly: boolean;
|
|
}
|
|
|
|
export const DEFAULT_TOPOLOGY_FILTERS: NetworkingTopologyFilters = {
|
|
stack: '',
|
|
network: '',
|
|
ownership: 'all',
|
|
exposedOnly: false,
|
|
driftOnly: false,
|
|
missingExternalOnly: false,
|
|
sharedOnly: false,
|
|
};
|
|
|
|
/** Large-topology strategy: edges stop animating above this count,
|
|
* and the graph refuses to render (falling back to the Networks table) above
|
|
* the combined node+edge cap. The cap check runs on FILTERED, already-deduped
|
|
* counts so narrowing a filter can bring an over-cap graph back under it. */
|
|
export const TOPOLOGY_ANIMATION_EDGE_LIMIT = 80;
|
|
export const TOPOLOGY_RENDER_CAP = 350;
|
|
|
|
/** Cheap pre-layout size count. Mirrors the container cross-network dedupe that
|
|
* `layoutGraph`/`aggregateContainers` perform, so a container attached to two
|
|
* networks counts as one node (not two) and contributes one edge per network. */
|
|
export function countTopologyGraphSize(networks: NetworkingTopologyNetwork[]): {
|
|
nodeCount: number;
|
|
edgeCount: number;
|
|
} {
|
|
const containerIds = new Set<string>();
|
|
let edgeCount = 0;
|
|
for (const network of networks) {
|
|
for (const container of network.containers) {
|
|
containerIds.add(container.id);
|
|
edgeCount += 1;
|
|
}
|
|
}
|
|
return { nodeCount: networks.length + containerIds.size, edgeCount };
|
|
}
|
|
|
|
export function normalizeTopologyResponse(payload: unknown): {
|
|
networks: NetworkingTopologyNetwork[];
|
|
runtimeAvailable: boolean;
|
|
} {
|
|
if (Array.isArray(payload)) {
|
|
return { networks: payload as NetworkingTopologyNetwork[], runtimeAvailable: true };
|
|
}
|
|
if (!payload || typeof payload !== 'object') {
|
|
throw new Error('Invalid topology response');
|
|
}
|
|
const envelope = payload as Partial<NetworkingTopologyEnvelope>;
|
|
if (envelope.runtimeAvailable === false) {
|
|
return { networks: [], runtimeAvailable: false };
|
|
}
|
|
if (!Array.isArray(envelope.networks)) {
|
|
throw new Error('Invalid topology response');
|
|
}
|
|
// A schema-2 remote's containers lack `hostMode`; default it so exposedOnly
|
|
// filtering degrades to published-ports-only instead of crashing.
|
|
const networks = envelope.networks.map((network) => ({
|
|
...network,
|
|
containers: network.containers.map((container) => ({
|
|
...container,
|
|
hostMode: container.hostMode === true,
|
|
})),
|
|
}));
|
|
return { networks, runtimeAvailable: true };
|
|
}
|
|
|
|
function containerMatchesStackQuery(
|
|
container: NetworkingTopologyContainer,
|
|
stackQuery: string,
|
|
): boolean {
|
|
return Boolean(
|
|
container.stack?.toLowerCase().includes(stackQuery)
|
|
|| container.service?.toLowerCase().includes(stackQuery),
|
|
);
|
|
}
|
|
|
|
function matchesOwnership(network: NetworkingTopologyNetwork, ownership: TopologyOwnershipFilter): boolean {
|
|
switch (ownership) {
|
|
case 'managed': return network.ownership === 'sencho-managed';
|
|
case 'external': return network.isExternalDependency;
|
|
case 'system': return network.ownership === 'system';
|
|
case 'all':
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
export function filterTopologyNetworks(
|
|
networks: NetworkingTopologyNetwork[],
|
|
filters: NetworkingTopologyFilters,
|
|
): NetworkingTopologyNetwork[] {
|
|
const networkQuery = filters.network.trim().toLowerCase();
|
|
const stackQuery = filters.stack.trim().toLowerCase();
|
|
|
|
return networks.flatMap((network) => {
|
|
const isMissing = network.runtimeState === 'missing' || network.id.startsWith('missing:');
|
|
const containers = stackQuery
|
|
? network.containers.filter((container) => containerMatchesStackQuery(container, stackQuery))
|
|
: network.containers;
|
|
|
|
if (networkQuery && !network.name.toLowerCase().includes(networkQuery)) return [];
|
|
if (
|
|
stackQuery
|
|
&& !network.declaredByStacks.some((stack) => stack.toLowerCase().includes(stackQuery))
|
|
&& containers.length === 0
|
|
) {
|
|
return [];
|
|
}
|
|
if (!matchesOwnership(network, filters.ownership)) return [];
|
|
if (
|
|
filters.exposedOnly
|
|
&& !network.containers.some((container) => container.publishedPorts.length > 0 || container.hostMode)
|
|
) {
|
|
return [];
|
|
}
|
|
if (
|
|
filters.driftOnly
|
|
&& !network.findingIds.length
|
|
&& !network.containers.some((container) => container.findingIds.length || container.driftFlags.length)
|
|
) {
|
|
return [];
|
|
}
|
|
if (filters.missingExternalOnly && !isMissing) return [];
|
|
if (filters.sharedOnly) {
|
|
// declaredExternalByStacks is a SUBSET of declaredByStacks (an external
|
|
// declaration is still a declaration), so summing both double-counts.
|
|
// Shared means 2+ distinct declaring stacks.
|
|
const uniqueStacks = new Set(network.declaredByStacks);
|
|
if (uniqueStacks.size < 2) return [];
|
|
}
|
|
|
|
return [{ ...network, containers }];
|
|
});
|
|
}
|
|
|
|
export function isMissingTopologyNetwork(network: NetworkingTopologyNetwork): boolean {
|
|
return network.runtimeState === 'missing' || network.id.startsWith('missing:');
|
|
}
|
|
|
|
export function formatTopologyPort(port: NetworkingTopologyContainer['publishedPorts'][number]): string {
|
|
return `${port.hostIp ?? '0.0.0.0'}:${port.published ?? port.target}/${port.protocol}`;
|
|
}
|