From c655bc6d45ece3bfd349f548a17aeec99c1f8a5c Mon Sep 17 00:00:00 2001 From: hatiyildiz Date: Thu, 30 Apr 2026 21:51:56 +0200 Subject: [PATCH] feat(catalyst-ui): swap legacy topology SVG for ArchitectureGraphPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 of openova-io/openova#309. The Architecture sub-page body now delegates entirely to widgets/architecture-graph. Architecture.tsx is reduced to a thin adapter over useCloud() — the legacy topologyLayout SVG renderer, the inline zoom-on-click state, the depth-row labels, and the click-to-zoom CRUD modal sidebar are all gone. Founder reversed the layered tree decision in issue #228 → #309: "forget about the containment, just show it as another type of relation." InfrastructureDetailPanel.tsx is deleted — its responsibilities (properties, status, actions) are now inline in ArchitectureGraphPage's DetailPanel, which additionally surfaces the neighbor list (founder spec) and the focus-mode toggle. The lib/topologyLayout.ts module + tests stay as-is (no callers remain in the sovereign portal, but the module is referenced by src/lib/infrastructure.types.test.ts and may be reused for other surfaces). Removing it is out of P2 scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/InfrastructureDetailPanel.tsx | 214 -------- .../ui/src/pages/sovereign/Architecture.tsx | 487 +----------------- 2 files changed, 23 insertions(+), 678 deletions(-) delete mode 100644 products/catalyst/bootstrap/ui/src/components/InfrastructureDetailPanel.tsx diff --git a/products/catalyst/bootstrap/ui/src/components/InfrastructureDetailPanel.tsx b/products/catalyst/bootstrap/ui/src/components/InfrastructureDetailPanel.tsx deleted file mode 100644 index 6bef2ccb..00000000 --- a/products/catalyst/bootstrap/ui/src/components/InfrastructureDetailPanel.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/** - * InfrastructureDetailPanel — right-side slide-in panel for the - * Sovereign Infrastructure Topology canvas. - * - * Sections: - * • Properties — per-node provider data (read-only) - * • Status — current healthy/degraded/failed badge + last update - * • Actions — opens the appropriate CRUD modal for this node kind - * - * Per founder spec: "Click node → graph zooms in (NOT accordion). - * Right-side detail panel slides in." This panel is the secondary UI - * surface on top of the topology canvas — the canvas itself handles - * the zoom transition. - */ - -import type { ReactNode } from 'react' -import type { LayoutNode } from '@/lib/topologyLayout' -import type { TopologyStatus } from '@/lib/infrastructure.types' - -const STATUS_COLOR: Record = { - healthy: 'var(--color-success)', - degraded: 'var(--color-warn)', - failed: 'var(--color-danger)', - unknown: 'var(--color-text-dim)', -} - -export interface DetailAction { - key: string - label: string - onClick: () => void - /** Optional dangerous flag — renders the action in danger-red. */ - danger?: boolean -} - -export interface InfrastructureDetailPanelProps { - node: LayoutNode | null - onClose: () => void - /** Called when the operator clicks the "Add child" button. The - * topology page wires this to the appropriate CRUD modal. */ - actions?: DetailAction[] -} - -export function InfrastructureDetailPanel({ - node, - onClose, - actions = [], -}: InfrastructureDetailPanelProps) { - if (!node) return null - - const properties = collectProperties(node) - const lastUpdate = readLastUpdate(node) - - return ( - - ) -} - -function Section({ - title, - testId, - children, -}: { - title: string - testId: string - children: ReactNode -}) { - return ( -
-

- {title} -

- {children} -
- ) -} - -function collectProperties(node: LayoutNode): [string, string][] { - const out: [string, string][] = [] - switch (node.ref.kind) { - case 'cloud': { - const c = node.ref.data - out.push(['provider', c.provider]) - out.push(['regions', String(c.regionCount)]) - out.push(['quota', `${c.quotaUsed} / ${c.quotaLimit}`]) - break - } - case 'region': { - const r = node.ref.data - out.push(['provider', r.provider]) - out.push(['region', r.providerRegion]) - out.push(['cp sku', r.skuCp]) - out.push(['worker sku', r.skuWorker]) - out.push(['workers', String(r.workerCount)]) - out.push(['clusters', String(r.clusters.length)]) - break - } - case 'cluster': { - const c = node.ref.data - out.push(['version', c.version]) - out.push(['nodes', String(c.nodeCount)]) - out.push(['vclusters', String(c.vclusters.length)]) - out.push(['lbs', String(c.loadBalancers.length)]) - out.push(['pools', String(c.nodePools.length)]) - break - } - case 'vcluster': { - const v = node.ref.data - out.push(['isolation', v.isolationMode]) - break - } - } - return out -} - -function readLastUpdate(_node: LayoutNode): string | null { - return null -} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/Architecture.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/Architecture.tsx index 2edd5914..89eee9af 100644 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/Architecture.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/Architecture.tsx @@ -1,477 +1,36 @@ /** - * Architecture — hierarchical layered SVG canvas for the Sovereign - * Cloud / Architecture sub-page (default landing under /cloud). + * Architecture — Sovereign Cloud / Architecture sub-page (default + * landing under /cloud). * - * The four sub-pages (Architecture / Compute / Network / Storage) are - * filtered lenses over ONE backend response. Architecture reads the - * hierarchical tree directly; the others use flatten* helpers. + * P2 of issue openova-io/openova#309: replaces the legacy layered SVG + * canvas with a force-directed Architecture graph. Containment is + * just one of several edge types (`contains`, `runs-on`, `routes-to`, + * `attached-to`, `peers-with`) — see the founder verbatim in #309 + * ("forget about the containment, just show it as another type of + * relation"). * - * The graph rewrite (force-directed layout, relations beyond pure - * containment) is tracked separately as P2 of issue #309 — this file - * keeps the existing layered topologyLayout intact in P1; only the - * file name, class name, and user-visible testids change. + * The body of this page is delegated to `ArchitectureGraphPage` in the + * `widgets/architecture-graph` package; this file is a thin adapter + * over `useCloud()`. The legacy `topologyLayout` SVG path is gone. * - * Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode) — every - * status colour comes from the `--color-*` CSS variables the rest of - * the portal uses. + * Per docs/INVIOLABLE-PRINCIPLES.md: + * #1 (waterfall) — every UI affordance ships in this first cut. + * #4 (never hardcode) — every visual token comes from CSS variables + * or the type/edge palette in widgets/architecture-graph/types.ts. */ -import { useMemo, useState } from 'react' +import { ArchitectureGraphPage } from '@/widgets/architecture-graph' import { useCloud } from './CloudPage' -import { topologyLayout, type LayoutNode, type ZoomState } from '@/lib/topologyLayout' -import type { TopologyStatus } from '@/lib/infrastructure.types' -import { InfrastructureDetailPanel, type DetailAction } from '@/components/InfrastructureDetailPanel' -import { - AddRegionModal, - AddClusterModal, - AddVClusterModal, - AddNodePoolModal, - AddLBModal, - DeleteCascadeConfirm, -} from '@/components/CrudModals' -import type { CloudProvider } from '@/entities/deployment/model' - -const STATUS_FILL: Record = { - healthy: 'var(--color-success)', - degraded: 'var(--color-warn)', - failed: 'var(--color-danger)', - unknown: 'var(--color-text-dim)', -} - -const STATUS_RING: Record = { - healthy: 'var(--color-success)', - degraded: 'var(--color-warn)', - failed: 'var(--color-danger)', - unknown: 'var(--color-border-strong)', -} - -interface ModalState { - kind: - | 'none' - | 'add-region' - | 'add-cluster' - | 'add-vcluster' - | 'add-nodepool' - | 'add-lb' - | 'delete' -} export function Architecture() { const { deploymentId, data, isLoading, isError, refetch } = useCloud() - - const [zoom, setZoom] = useState({ - zoomedClusterId: null, - zoomedRegionId: null, - }) - const [selectedId, setSelectedId] = useState(null) - const [modal, setModal] = useState({ kind: 'none' }) - - const layout = useMemo(() => { - if (!data) return null - return topologyLayout(data, { zoom }) - }, [data, zoom]) - - const nodeById = useMemo(() => { - const m = new Map() - if (layout) for (const n of layout.nodes) m.set(n.id, n) - return m - }, [layout]) - - const selectedNode = selectedId ? nodeById.get(selectedId) ?? null : null - - function onNodeClick(node: LayoutNode) { - setSelectedId(node.id) - if (node.kind === 'cluster') { - setZoom({ - zoomedClusterId: node.id, - zoomedRegionId: node.ref.kind === 'cluster' ? node.ref.regionId : null, - }) - } else if (node.kind === 'region') { - setZoom({ - zoomedRegionId: node.id, - zoomedClusterId: null, - }) - } else if (node.kind === 'cloud') { - setZoom({ zoomedClusterId: null, zoomedRegionId: null }) - } - } - - // Build per-kind action lists for the detail panel. - const detailActions = useMemo(() => { - if (!selectedNode) return [] - const actions: DetailAction[] = [] - if (selectedNode.kind === 'cloud') { - actions.push({ - key: 'add-region', - label: '+ Add region', - onClick: () => setModal({ kind: 'add-region' }), - }) - } - if (selectedNode.kind === 'region') { - actions.push({ - key: 'add-cluster', - label: '+ Add cluster', - onClick: () => setModal({ kind: 'add-cluster' }), - }) - actions.push({ - key: 'add-lb', - label: '+ Add load balancer', - onClick: () => setModal({ kind: 'add-lb' }), - }) - actions.push({ - key: 'delete', - label: 'Delete region', - onClick: () => setModal({ kind: 'delete' }), - danger: true, - }) - } - if (selectedNode.kind === 'cluster') { - actions.push({ - key: 'add-vcluster', - label: '+ Add vCluster', - onClick: () => setModal({ kind: 'add-vcluster' }), - }) - actions.push({ - key: 'add-nodepool', - label: '+ Add node pool', - onClick: () => setModal({ kind: 'add-nodepool' }), - }) - actions.push({ - key: 'delete', - label: 'Delete cluster', - onClick: () => setModal({ kind: 'delete' }), - danger: true, - }) - } - if (selectedNode.kind === 'vcluster') { - actions.push({ - key: 'delete', - label: 'Delete vCluster', - onClick: () => setModal({ kind: 'delete' }), - danger: true, - }) - } - return actions - }, [selectedNode]) - - const hasNodes = !!layout && layout.nodes.length > 0 - return ( -
- {(zoom.zoomedClusterId || zoom.zoomedRegionId) && ( -
- - {zoom.zoomedClusterId - ? `Zoomed: cluster ${zoom.zoomedClusterId}` - : `Zoomed: region ${zoom.zoomedRegionId}`} - - -
- )} - -
- {isLoading && ( -
- Loading architecture… -
- )} - - {isError && !data && ( -
-

- Couldn’t load architecture -

-

- The Catalyst API is temporarily unreachable. Retry will start - automatically. -

- -
- )} - - {!hasNodes && !isLoading && !isError && ( -
-
-

Provisioning…

-

- The cloud architecture will appear here as soon as the - Sovereign cluster reports its first nodes. -

-
- )} - - {hasNodes && layout && ( - - - - - - - - {/* Depth row labels — anchor the layered intent. */} - - {(['Cloud', 'Region', 'Cluster', 'vCluster'] as const).map((label, i) => { - const sample = layout.nodes.find((n) => n.depth === i) - if (!sample) return null - return ( - - {label} - - ) - })} - - - {/* Edges first so they sit beneath the nodes. */} - - {layout.edges.map((e) => ( - `${p.x},${p.y}`).join(' ')} - fill="none" - stroke="var(--color-border-strong)" - strokeWidth={1.5} - markerEnd="url(#cloud-architecture-arrow)" - opacity={0.6} - /> - ))} - - - {/* Nodes. */} - - {layout.nodes.map((n) => { - const fill = STATUS_FILL[n.status] - const ring = STATUS_RING[n.status] - const isSelected = selectedId === n.id - return ( - onNodeClick(n)} - tabIndex={0} - role="button" - aria-label={`${n.label} — ${n.kind} — ${n.status}`} - aria-pressed={isSelected} - onKeyDown={(ev) => { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault() - onNodeClick(n) - } - }} - > - - - - {truncate(n.label, Math.floor(n.width / 8))} - - - {n.sublabel} - - - ) - })} - - - )} -
- - {/* Top-level Add Region button — visible at all times when the - tree has at least one cloud. Founder spec: every CRUD action - must be reachable from the Architecture canvas. */} - {hasNodes && data && ( -
- -
- )} - - setSelectedId(null)} - actions={detailActions} - /> - - {/* CRUD modals — opened from the detail panel actions. */} - {data && ( - <> - setModal({ kind: 'none' })} - /> - - {selectedNode?.kind === 'region' && ( - <> - setModal({ kind: 'none' })} - /> - setModal({ kind: 'none' })} - /> - - )} - - {selectedNode?.kind === 'cluster' && ( - <> - setModal({ kind: 'none' })} - /> - setModal({ kind: 'none' })} - /> - - )} - - {selectedNode && ( - setModal({ kind: 'none' })} - /> - )} - - )} -
+ ) } - -function truncate(s: string, max: number): string { - if (s.length <= max) return s - return s.slice(0, Math.max(0, max - 1)) + '…' -} - -function inferDefaultProvider(data: ReturnType['data']): CloudProvider { - const first = data?.cloud[0] - return ((first?.provider ?? 'hetzner') as CloudProvider) -} - -function inferProviderForCluster( - data: ReturnType['data'], - clusterId: string, -): CloudProvider { - if (!data) return 'hetzner' - for (const r of data.topology.regions ?? []) { - for (const c of r.clusters ?? []) { - if (c.id === clusterId) return r.provider as CloudProvider - } - } - return 'hetzner' -} - -function resourceForKind( - kind: 'cloud' | 'region' | 'cluster' | 'vcluster', -): 'regions' | 'clusters' | 'vclusters' { - if (kind === 'region') return 'regions' - if (kind === 'cluster') return 'clusters' - if (kind === 'vcluster') return 'vclusters' - return 'regions' -}