mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
feat: node-scoped Networking operator page (#1603)
* 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.
This commit is contained in:
@@ -21,6 +21,7 @@ export const SENCHO_OPEN_STACK_EVENT = 'sencho-open-stack';
|
||||
export interface SenchoOpenStackDetail {
|
||||
nodeId: number;
|
||||
stackName: string;
|
||||
destination?: 'stack' | 'editor' | 'anatomy-networking' | 'doctor' | 'dossier' | 'drift';
|
||||
}
|
||||
|
||||
/** Tabs of the top-level Security view. Used by the nav state and by
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
adaptNetworkingOverview, buildExternalNetworkSnippet, filterNetworkRows, getNetworkingPosture,
|
||||
} from './networking';
|
||||
import type { NetworkingFinding, NetworkingFindingKind, NetworkingNetworkRow, NodeNetworkingOverview } from '@/types/networking';
|
||||
|
||||
const rows: NetworkingNetworkRow[] = [
|
||||
{
|
||||
id: 'managed', name: 'app-net', driver: 'bridge', scope: 'local', isSystem: false, ingress: false,
|
||||
composeProject: 'app', stack: 'app', connectedCount: 2, isSencho: false, ownership: 'sencho-managed',
|
||||
declaredByStacks: ['app'], declaredExternalByStacks: ['peer'], isExternalDependency: true,
|
||||
sharedStackCount: 2, exposureSummary: null, findingIds: ['drift'], serviceNames: ['web'],
|
||||
},
|
||||
{
|
||||
id: 'system', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, ingress: false,
|
||||
composeProject: null, stack: null, connectedCount: 0, isSencho: false, ownership: 'system',
|
||||
declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false,
|
||||
sharedStackCount: 0, exposureSummary: null, findingIds: [], serviceNames: [],
|
||||
},
|
||||
];
|
||||
|
||||
const findingKindById = new Map<string, NetworkingFindingKind>([['drift', 'network-missing']]);
|
||||
|
||||
describe('networking view helpers', () => {
|
||||
it('filters ownership, dependencies, and findings independently', () => {
|
||||
expect(filterNetworkRows(rows, 'managed', '')).toHaveLength(1);
|
||||
expect(filterNetworkRows(rows, 'external', '')).toHaveLength(1);
|
||||
expect(filterNetworkRows(rows, 'system', '')).toHaveLength(1);
|
||||
expect(filterNetworkRows(rows, 'shared', '')).toHaveLength(1);
|
||||
expect(filterNetworkRows(rows, 'drift', '', findingKindById)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drift filter requires a drift-kind finding, not just any finding', () => {
|
||||
const nonDriftKindById = new Map<string, NetworkingFindingKind>([['drift', 'shared-network']]);
|
||||
expect(filterNetworkRows(rows, 'drift', '', nonDriftKindById)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not infer contained posture from a schema-less response', () => {
|
||||
expect(getNetworkingPosture([], true, true).label).toBe('Partial networking data');
|
||||
});
|
||||
|
||||
it('adapts schema-less responses without synthesizing findings', () => {
|
||||
const adapted = adaptNetworkingOverview({ networks: rows });
|
||||
expect(adapted.isLegacy).toBe(true);
|
||||
expect(adapted.findings).toEqual([]);
|
||||
expect(adapted.runtimeAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('adapts a schema-2 response: derives ownership counts and fills new-field defaults', () => {
|
||||
// A schema-2 remote sends partial rows/findings (no serviceNames, no
|
||||
// sources/doctorFindings); the envelope type models the schema-3 ideal, so
|
||||
// the partial wire shape is cast to mirror what actually arrives.
|
||||
const adapted = adaptNetworkingOverview({
|
||||
schemaVersion: 2,
|
||||
runtimeAvailable: true,
|
||||
overview: { networkCount: 2 } as NodeNetworkingOverview,
|
||||
networks: [
|
||||
{ id: 'a', name: 'a', ownership: 'sencho-managed', isExternalDependency: false },
|
||||
{ id: 'b', name: 'b', ownership: 'unmanaged', isExternalDependency: true },
|
||||
] as unknown as NetworkingNetworkRow[],
|
||||
findings: [{ id: 'f1', kind: 'network-missing', severity: 'high', title: 't', message: 'm' }] as unknown as NetworkingFinding[],
|
||||
});
|
||||
expect(adapted.isLegacy).toBe(false);
|
||||
expect(adapted.runtimeAvailable).toBe(true);
|
||||
// The schema-2 envelope omits ownership counts, so they are derived from the rows.
|
||||
expect(adapted.overview?.senchoManagedNetworkCount).toBe(1);
|
||||
expect(adapted.overview?.unmanagedNetworkCount).toBe(1);
|
||||
expect(adapted.overview?.externalDependencyNetworkCount).toBe(1);
|
||||
// Fields new in schema 3 get safe defaults rather than undefined.
|
||||
expect(adapted.networks[0].serviceNames).toEqual([]);
|
||||
expect(adapted.findings[0].sources).toEqual(['live']);
|
||||
expect(adapted.findings[0].doctorFindings).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds an external network snippet only for safe names', () => {
|
||||
expect(buildExternalNetworkSnippet('shared-net')).toBe(
|
||||
'networks:\n shared-net:\n external: true',
|
||||
);
|
||||
expect(buildExternalNetworkSnippet('../unsafe')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
isNetworkDriftFindingKind,
|
||||
type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOverviewEnvelope,
|
||||
type NetworkingRecommendedAction, type NodeNetworkingOverview,
|
||||
} from '@/types/networking';
|
||||
|
||||
export type NetworkFilter = 'all' | 'managed' | 'external' | 'system' | 'shared' | 'exposed' | 'drift';
|
||||
|
||||
export function isNetworkingActionVisible(
|
||||
action: NetworkingRecommendedAction,
|
||||
isAdmin: boolean,
|
||||
canEditStack: (stack: string) => boolean,
|
||||
): boolean {
|
||||
if (action.kind === 'create-network') return isAdmin;
|
||||
if (action.kind === 'set-exposure-intent') return canEditStack(action.stack);
|
||||
return true;
|
||||
}
|
||||
|
||||
// findingIds on a row are opaque hash IDs; drift classification needs each
|
||||
// finding's kind, so callers supply a lookup built from the full findings list.
|
||||
// Shared by the Networks-table drift filter and its count so they never diverge.
|
||||
export function rowHasDriftFinding(
|
||||
row: NetworkingNetworkRow,
|
||||
findingKindById: Map<string, NetworkingFindingKind>,
|
||||
): boolean {
|
||||
return row.findingIds.some((id) => {
|
||||
const kind = findingKindById.get(id);
|
||||
return kind !== undefined && isNetworkDriftFindingKind(kind);
|
||||
});
|
||||
}
|
||||
|
||||
function matchesNetworkFilter(
|
||||
row: NetworkingNetworkRow,
|
||||
filter: NetworkFilter,
|
||||
findingKindById: Map<string, NetworkingFindingKind>,
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'managed':
|
||||
return row.ownership === 'sencho-managed';
|
||||
case 'external':
|
||||
return row.isExternalDependency;
|
||||
case 'system':
|
||||
return row.ownership === 'system';
|
||||
case 'shared':
|
||||
return row.sharedStackCount >= 2;
|
||||
case 'exposed':
|
||||
return Boolean(row.exposureSummary?.broadExposureCount);
|
||||
case 'drift':
|
||||
return rowHasDriftFinding(row, findingKindById);
|
||||
case 'all':
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterNetworkRows(
|
||||
rows: NetworkingNetworkRow[],
|
||||
filter: NetworkFilter,
|
||||
search: string,
|
||||
findingKindById: Map<string, NetworkingFindingKind> = new Map(),
|
||||
): NetworkingNetworkRow[] {
|
||||
const query = search.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
if (!matchesNetworkFilter(row, filter, findingKindById)) return false;
|
||||
if (!query) return true;
|
||||
return [
|
||||
row.name,
|
||||
row.stack,
|
||||
row.composeProject,
|
||||
row.driver,
|
||||
...row.declaredByStacks,
|
||||
...row.declaredExternalByStacks,
|
||||
...row.serviceNames,
|
||||
].filter(Boolean).some((value) => value!.toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
export function getNetworkingPosture(
|
||||
findings: NetworkingFinding[],
|
||||
runtimeAvailable: boolean,
|
||||
isLegacy: boolean,
|
||||
): { label: string; tone: 'live' | 'warning' | 'critical' | 'neutral' } {
|
||||
if (isLegacy) return { label: 'Partial networking data', tone: 'neutral' };
|
||||
if (!runtimeAvailable) return { label: 'Runtime unavailable', tone: 'warning' };
|
||||
if (findings.some((finding) => finding.severity === 'critical' || finding.severity === 'high')) {
|
||||
return { label: 'Action needed', tone: 'critical' };
|
||||
}
|
||||
if (findings.some((finding) => finding.severity === 'medium')) {
|
||||
return { label: 'Review', tone: 'warning' };
|
||||
}
|
||||
return { label: 'Contained', tone: 'live' };
|
||||
}
|
||||
|
||||
export function canUseNetworkName(name: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
|
||||
}
|
||||
|
||||
export function buildExternalNetworkSnippet(name: string): string | null {
|
||||
if (!canUseNetworkName(name)) return null;
|
||||
return `networks:\n ${name}:\n external: true`;
|
||||
}
|
||||
|
||||
/** Normalizes a network row from a schema-2 remote (no serviceNames) so the UI
|
||||
* never crashes on a missing array. */
|
||||
function adaptNetworkRow(row: Partial<NetworkingNetworkRow>): NetworkingNetworkRow {
|
||||
return {
|
||||
id: row.id ?? '',
|
||||
name: row.name ?? '',
|
||||
driver: row.driver ?? 'bridge',
|
||||
scope: row.scope ?? 'local',
|
||||
isSystem: row.isSystem === true,
|
||||
ingress: row.ingress === true,
|
||||
enableIPv6: row.enableIPv6,
|
||||
composeProject: row.composeProject ?? null,
|
||||
stack: row.stack ?? null,
|
||||
connectedCount: row.connectedCount ?? 0,
|
||||
isSencho: row.isSencho === true,
|
||||
ownership: row.ownership ?? 'unmanaged',
|
||||
declaredByStacks: row.declaredByStacks ?? [],
|
||||
declaredExternalByStacks: row.declaredExternalByStacks ?? [],
|
||||
isExternalDependency: row.isExternalDependency === true,
|
||||
sharedStackCount: row.sharedStackCount ?? 0,
|
||||
exposureSummary: row.exposureSummary ?? null,
|
||||
findingIds: row.findingIds ?? [],
|
||||
serviceNames: row.serviceNames ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes a finding from a schema-2 remote (no sources/doctorFindings). */
|
||||
function adaptFinding(f: Partial<NetworkingFinding>): NetworkingFinding {
|
||||
return {
|
||||
id: f.id ?? '',
|
||||
kind: (f.kind ?? 'runtime-unavailable') as NetworkingFinding['kind'],
|
||||
severity: f.severity ?? 'info',
|
||||
title: f.title ?? '',
|
||||
message: f.message ?? '',
|
||||
stack: f.stack,
|
||||
network: f.network,
|
||||
service: f.service,
|
||||
evidence: f.evidence ?? [],
|
||||
recommendedActions: f.recommendedActions ?? [],
|
||||
sources: f.sources ?? ['live'],
|
||||
doctorFindings: f.doctorFindings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Derives ownership counts from network rows when the overview envelope itself
|
||||
* omits them (schema 2), instead of showing incorrect zeroes. */
|
||||
function deriveOwnershipCounts(networks: NetworkingNetworkRow[]): {
|
||||
senchoManagedNetworkCount: number;
|
||||
composeManagedNetworkCount: number;
|
||||
unmanagedNetworkCount: number;
|
||||
externalDependencyNetworkCount: number;
|
||||
} {
|
||||
return {
|
||||
senchoManagedNetworkCount: networks.filter((n) => n.ownership === 'sencho-managed').length,
|
||||
composeManagedNetworkCount: networks.filter((n) => n.ownership === 'compose-managed').length,
|
||||
unmanagedNetworkCount: networks.filter((n) => n.ownership === 'unmanaged').length,
|
||||
externalDependencyNetworkCount: networks.filter((n) => n.isExternalDependency).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptNetworkingOverview(body: Partial<NetworkingOverviewEnvelope>): {
|
||||
isLegacy: boolean;
|
||||
runtimeAvailable: boolean;
|
||||
overview: NodeNetworkingOverview | null;
|
||||
networks: NetworkingNetworkRow[];
|
||||
findings: NetworkingFinding[];
|
||||
recentActivity: NetworkingOverviewEnvelope['recentActivity'];
|
||||
} {
|
||||
const schemaVersion = body.schemaVersion;
|
||||
|
||||
// Schema 1 / absent: fully legacy, no usable shape at all.
|
||||
const isLegacy = schemaVersion === undefined || schemaVersion < 2;
|
||||
if (isLegacy) {
|
||||
return {
|
||||
isLegacy: true,
|
||||
runtimeAvailable: false,
|
||||
overview: null,
|
||||
networks: [],
|
||||
findings: [],
|
||||
recentActivity: [],
|
||||
};
|
||||
}
|
||||
|
||||
const networks = (body.networks ?? []).map(adaptNetworkRow);
|
||||
const findings = (body.findings ?? []).map(adaptFinding);
|
||||
const isSchema2 = schemaVersion === 2;
|
||||
const overview = body.overview
|
||||
? {
|
||||
...body.overview,
|
||||
...(isSchema2 ? deriveOwnershipCounts(networks) : {}),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
isLegacy: false,
|
||||
runtimeAvailable: body.runtimeAvailable === true,
|
||||
overview,
|
||||
networks,
|
||||
findings,
|
||||
recentActivity: body.recentActivity ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findingSourceLabel, groupFindings, rankFindings } from './networkingSeverity';
|
||||
import type { NetworkingFinding } from '@/types/networking';
|
||||
|
||||
function finding(overrides: Partial<NetworkingFinding> = {}): NetworkingFinding {
|
||||
return {
|
||||
id: overrides.id ?? Math.random().toString(36),
|
||||
kind: 'network-mode-host',
|
||||
severity: 'medium',
|
||||
title: 't',
|
||||
message: 'm',
|
||||
evidence: [],
|
||||
recommendedActions: [],
|
||||
sources: ['live'],
|
||||
doctorFindings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('groupFindings', () => {
|
||||
it('groups critical and high into needs-action, medium into review, info into informational', () => {
|
||||
const groups = groupFindings([
|
||||
finding({ id: 'a', severity: 'critical' }),
|
||||
finding({ id: 'b', severity: 'high' }),
|
||||
finding({ id: 'c', severity: 'medium' }),
|
||||
finding({ id: 'd', severity: 'info' }),
|
||||
]);
|
||||
expect(groups['needs-action'].map((f) => f.id)).toEqual(['a', 'b']);
|
||||
expect(groups['review-recommended'].map((f) => f.id)).toEqual(['c']);
|
||||
expect(groups.informational.map((f) => f.id)).toEqual(['d']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankFindings', () => {
|
||||
it('ranks live ahead of doctor-only at equal severity', () => {
|
||||
const live = finding({ id: 'live', severity: 'high', sources: ['live'] });
|
||||
const doctorOnly = finding({ id: 'doctor', severity: 'high', sources: ['doctor'] });
|
||||
const ranked = rankFindings([doctorOnly, live]);
|
||||
expect(ranked.map((f) => f.id)).toEqual(['live', 'doctor']);
|
||||
});
|
||||
|
||||
it('ranks strictly by severity first', () => {
|
||||
const low = finding({ id: 'low', severity: 'info' });
|
||||
const high = finding({ id: 'high', severity: 'critical' });
|
||||
expect(rankFindings([low, high]).map((f) => f.id)).toEqual(['high', 'low']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findingSourceLabel', () => {
|
||||
it('labels a merged card as Live plus Doctor', () => {
|
||||
const merged = finding({ sources: ['live', 'doctor'], doctorFindings: [{ ruleId: 'r', ranAt: new Date().toISOString(), title: 't', message: 'm', severity: 'high' }] });
|
||||
expect(findingSourceLabel(merged)).toBe('Live · also found by Doctor');
|
||||
});
|
||||
|
||||
it('labels a Doctor-only card with its last-run timestamp', () => {
|
||||
const ranAt = new Date('2026-01-01T00:00:00Z').toISOString();
|
||||
const doctorOnly = finding({ sources: ['doctor'], doctorFindings: [{ ruleId: 'r', ranAt, title: 't', message: 'm', severity: 'high' }] });
|
||||
expect(findingSourceLabel(doctorOnly)).toContain('Last Doctor run');
|
||||
});
|
||||
|
||||
it('returns null for a live-only finding', () => {
|
||||
expect(findingSourceLabel(finding({ sources: ['live'] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { NetworkingFinding, NetworkingFindingSeverity } from '@/types/networking';
|
||||
|
||||
export const NETWORKING_SEVERITY_RANK: Record<NetworkingFindingSeverity, number> = {
|
||||
info: 0,
|
||||
medium: 1,
|
||||
high: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
/** Text color per severity, shared by the Overview attention list and the Findings tab. */
|
||||
export const SEVERITY_TEXT_CLASS: Record<NetworkingFindingSeverity, string> = {
|
||||
info: 'text-stat-subtitle',
|
||||
medium: 'text-warning',
|
||||
high: 'text-destructive',
|
||||
critical: 'text-destructive',
|
||||
};
|
||||
|
||||
/** Severity descending; at equal severity a live-sourced finding ranks ahead of a
|
||||
* Doctor-only finding, mirroring the backend ranking helper. */
|
||||
export function compareFindingsForRanking(a: NetworkingFinding, b: NetworkingFinding): number {
|
||||
const rankDiff = NETWORKING_SEVERITY_RANK[b.severity] - NETWORKING_SEVERITY_RANK[a.severity];
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
const aIsLive = a.sources.includes('live');
|
||||
const bIsLive = b.sources.includes('live');
|
||||
if (aIsLive !== bIsLive) return aIsLive ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function rankFindings(findings: NetworkingFinding[]): NetworkingFinding[] {
|
||||
return [...findings].sort(compareFindingsForRanking);
|
||||
}
|
||||
|
||||
export type NetworkingFindingGroup = 'needs-action' | 'review-recommended' | 'informational';
|
||||
|
||||
export const FINDING_GROUP_LABELS: Record<NetworkingFindingGroup, string> = {
|
||||
'needs-action': 'Needs action',
|
||||
'review-recommended': 'Review recommended',
|
||||
informational: 'Informational',
|
||||
};
|
||||
|
||||
export function groupForSeverity(severity: NetworkingFindingSeverity): NetworkingFindingGroup {
|
||||
if (severity === 'critical' || severity === 'high') return 'needs-action';
|
||||
if (severity === 'medium') return 'review-recommended';
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
export function groupFindings(findings: NetworkingFinding[]): Record<NetworkingFindingGroup, NetworkingFinding[]> {
|
||||
const ranked = rankFindings(findings);
|
||||
const groups: Record<NetworkingFindingGroup, NetworkingFinding[]> = {
|
||||
'needs-action': [],
|
||||
'review-recommended': [],
|
||||
informational: [],
|
||||
};
|
||||
for (const f of ranked) groups[groupForSeverity(f.severity)].push(f);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Label for a finding's source provenance, so cached Doctor findings never read as current runtime facts. */
|
||||
export function findingSourceLabel(finding: Pick<NetworkingFinding, 'sources' | 'doctorFindings'>): string | null {
|
||||
const isLive = finding.sources.includes('live');
|
||||
const isDoctor = finding.sources.includes('doctor');
|
||||
if (isLive && isDoctor) return 'Live · also found by Doctor';
|
||||
if (isDoctor && !isLive) {
|
||||
const ranAt = finding.doctorFindings[0]?.ranAt;
|
||||
return ranAt ? `Last Doctor run · ${new Date(ranAt).toLocaleString()}` : 'Last Doctor run';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterTopologyNetworks, normalizeTopologyResponse } from './networkingTopology';
|
||||
import type { NetworkingTopologyNetwork } from '@/types/networking';
|
||||
|
||||
const missingNetwork: NetworkingTopologyNetwork = {
|
||||
id: 'missing:shared-net', name: 'shared-net', driver: 'unknown', scope: 'local', stack: null,
|
||||
isSystem: false, ingress: false, ownership: 'unmanaged', declaredByStacks: [],
|
||||
declaredExternalByStacks: ['app'], isExternalDependency: true, runtimeState: 'missing',
|
||||
findingIds: ['network-missing'], containers: [],
|
||||
};
|
||||
|
||||
describe('networking topology adapters', () => {
|
||||
it('unwraps unavailable envelopes without generating missing nodes', () => {
|
||||
expect(normalizeTopologyResponse({ schemaVersion: 2, runtimeAvailable: false, networks: [missingNetwork] })).toEqual({
|
||||
networks: [], runtimeAvailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters synthetic missing external nodes', () => {
|
||||
expect(filterTopologyNetworks([missingNetwork], {
|
||||
stack: '', network: '', ownership: 'all', exposedOnly: false, driftOnly: false, missingExternalOnly: true, sharedOnly: false,
|
||||
})).toEqual([missingNetwork]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { countTopologyGraphSize, TOPOLOGY_ANIMATION_EDGE_LIMIT, TOPOLOGY_RENDER_CAP } from './networkingTopology';
|
||||
import type { NetworkingTopologyContainer, NetworkingTopologyNetwork } from '@/types/networking';
|
||||
|
||||
function container(id: string): NetworkingTopologyContainer {
|
||||
return {
|
||||
id, name: id, ip: '10.0.0.1', state: 'running', image: 'nginx', stack: 'app', service: 'web',
|
||||
composeAliases: [], publishedPorts: [], exposureIntent: null, findingIds: [], driftFlags: [], hostMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
function network(id: string, containers: NetworkingTopologyContainer[]): NetworkingTopologyNetwork {
|
||||
return {
|
||||
id, name: id, driver: 'bridge', scope: 'local', stack: null, isSystem: false, ingress: false,
|
||||
ownership: 'sencho-managed', declaredByStacks: [], declaredExternalByStacks: [],
|
||||
isExternalDependency: false, findingIds: [], containers,
|
||||
};
|
||||
}
|
||||
|
||||
describe('countTopologyGraphSize', () => {
|
||||
it('counts a container attached to two networks as ONE node and TWO edges', () => {
|
||||
const shared = container('shared');
|
||||
const size = countTopologyGraphSize([network('net-a', [shared]), network('net-b', [shared])]);
|
||||
// 2 networks + 1 deduped container = 3 nodes; 2 edges (one per attachment).
|
||||
expect(size.nodeCount).toBe(3);
|
||||
expect(size.edgeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('lands just under the render cap at the boundary and just over one container later', () => {
|
||||
// One network with N containers is (1 + N) nodes and N edges: total 1 + 2N.
|
||||
const belowN = Math.floor((TOPOLOGY_RENDER_CAP - 1) / 2); // largest N with 1 + 2N <= cap
|
||||
const below = countTopologyGraphSize([network('g', Array.from({ length: belowN }, (_, i) => container(`c${i}`)))]);
|
||||
expect(below.nodeCount + below.edgeCount).toBeLessThanOrEqual(TOPOLOGY_RENDER_CAP);
|
||||
|
||||
const above = countTopologyGraphSize([network('g', Array.from({ length: belowN + 1 }, (_, i) => container(`c${i}`)))]);
|
||||
expect(above.nodeCount + above.edgeCount).toBeGreaterThan(TOPOLOGY_RENDER_CAP);
|
||||
});
|
||||
|
||||
it('crosses the animation edge limit exactly one edge past the threshold', () => {
|
||||
const atLimit = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT }, (_, i) => container(`c${i}`)))]);
|
||||
expect(atLimit.edgeCount).toBe(TOPOLOGY_ANIMATION_EDGE_LIMIT);
|
||||
const over = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT + 1 }, (_, i) => container(`c${i}`)))]);
|
||||
expect(over.edgeCount).toBeGreaterThan(TOPOLOGY_ANIMATION_EDGE_LIMIT);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export type ActiveView =
|
||||
| 'editor'
|
||||
| 'host-console'
|
||||
| 'resources'
|
||||
| 'networking'
|
||||
| 'templates'
|
||||
| 'global-observability'
|
||||
| 'fleet'
|
||||
|
||||
@@ -157,4 +157,12 @@ describe('senchoRoute', () => {
|
||||
expect(parsed.view).toBe('editor');
|
||||
expect(parsed.editorTab).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips the networking view', () => {
|
||||
const path = buildPath({ ...base, activeView: 'networking' });
|
||||
expect(path).toBe('/nodes/local/networking');
|
||||
const parsed = parsePath(path, '');
|
||||
expect(parsed.view).toBe('networking');
|
||||
expect(parsed.nodeSlug).toBe('local');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const VIEW_SEGMENTS = {
|
||||
editor: 'stacks',
|
||||
'host-console': 'host-console',
|
||||
resources: 'resources',
|
||||
networking: 'networking',
|
||||
templates: 'templates',
|
||||
'global-observability': 'logs',
|
||||
fleet: 'fleet',
|
||||
|
||||
Reference in New Issue
Block a user