feat(catalyst-ui): swap legacy topology SVG for ArchitectureGraphPage

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) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-04-30 21:51:56 +02:00 committed by Emrah Baysal
parent 4b35ed4214
commit c655bc6d45
2 changed files with 23 additions and 678 deletions

View File

@ -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<TopologyStatus, string> = {
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 (
<aside
role="dialog"
aria-label={`${node.label} details`}
data-testid="infrastructure-detail-panel"
className="fixed right-0 top-14 z-30 flex h-[calc(100vh-3.5rem)] w-96 flex-col gap-3 border-l border-[var(--color-border)] bg-[var(--color-bg-2)] p-4 shadow-xl"
>
<header className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p
className="truncate text-base font-semibold text-[var(--color-text-strong)]"
data-testid="infrastructure-detail-panel-name"
>
{node.label}
</p>
<p className="text-xs uppercase tracking-wide text-[var(--color-text-dim)]">
{node.kind} · depth {node.depth}
</p>
</div>
<button
type="button"
onClick={onClose}
data-testid="infrastructure-detail-panel-close"
className="rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
aria-label="Close detail panel"
>
×
</button>
</header>
<Section title="Status" testId="infrastructure-detail-panel-status">
<div className="flex items-center justify-between rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-xs">
<span
data-testid="infrastructure-detail-panel-status-pill"
style={{
color: STATUS_COLOR[node.status],
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{node.status}
</span>
{lastUpdate && (
<span className="text-[var(--color-text-dim)]">{lastUpdate}</span>
)}
</div>
</Section>
<Section title="Properties" testId="infrastructure-detail-panel-properties">
{properties.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No additional properties for this node.
</p>
) : (
<dl className="grid grid-cols-3 gap-x-2 gap-y-1.5 text-xs">
{properties.map(([k, v]) => (
<div key={k} className="contents">
<dt className="col-span-1 truncate text-[var(--color-text-dim)]">
{k}
</dt>
<dd
className="col-span-2 truncate font-mono text-[var(--color-text)]"
data-testid={`infrastructure-detail-panel-prop-${k}`}
>
{v}
</dd>
</div>
))}
</dl>
)}
</Section>
<Section title="Actions" testId="infrastructure-detail-panel-actions">
{actions.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No actions available for this node yet.
</p>
) : (
<div className="flex flex-col gap-1.5">
{actions.map((a) => (
<button
key={a.key}
type="button"
onClick={a.onClick}
data-testid={`infrastructure-detail-panel-action-${a.key}`}
className={`rounded-md border px-3 py-1.5 text-left text-xs font-medium transition-colors ${
a.danger
? 'border-[color-mix(in_srgb,var(--color-danger)_50%,var(--color-border))] text-[var(--color-danger)] hover:bg-[color-mix(in_srgb,var(--color-danger)_8%,transparent)]'
: 'border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-bg)]'
}`}
>
{a.label}
</button>
))}
</div>
)}
</Section>
</aside>
)
}
function Section({
title,
testId,
children,
}: {
title: string
testId: string
children: ReactNode
}) {
return (
<section data-testid={testId} className="flex flex-col gap-1.5">
<h3 className="text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-dim)]">
{title}
</h3>
{children}
</section>
)
}
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
}

View File

@ -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<TopologyStatus, string> = {
healthy: 'var(--color-success)',
degraded: 'var(--color-warn)',
failed: 'var(--color-danger)',
unknown: 'var(--color-text-dim)',
}
const STATUS_RING: Record<TopologyStatus, string> = {
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<ZoomState>({
zoomedClusterId: null,
zoomedRegionId: null,
})
const [selectedId, setSelectedId] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>({ kind: 'none' })
const layout = useMemo(() => {
if (!data) return null
return topologyLayout(data, { zoom })
}, [data, zoom])
const nodeById = useMemo(() => {
const m = new Map<string, LayoutNode>()
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<DetailAction[]>(() => {
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 (
<div data-testid="cloud-architecture" className="relative">
{(zoom.zoomedClusterId || zoom.zoomedRegionId) && (
<div
data-testid="cloud-architecture-zoom-status"
className="mb-2 flex items-center justify-between rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-1.5 text-xs"
>
<span className="text-[var(--color-text-dim)]">
{zoom.zoomedClusterId
? `Zoomed: cluster ${zoom.zoomedClusterId}`
: `Zoomed: region ${zoom.zoomedRegionId}`}
</span>
<button
type="button"
data-testid="cloud-architecture-zoom-reset"
onClick={() => setZoom({ zoomedClusterId: null, zoomedRegionId: null })}
className="rounded-md border border-[var(--color-border)] bg-transparent px-2 py-0.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-bg)]"
>
Reset zoom
</button>
</div>
)}
<div
className="relative w-full overflow-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)]"
data-testid="cloud-architecture-canvas"
style={{ minHeight: 480 }}
>
{isLoading && (
<div
className="flex h-[480px] items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="cloud-architecture-loading"
>
Loading architecture
</div>
)}
{isError && !data && (
<div
className="flex h-[480px] flex-col items-center justify-center gap-2 px-6 text-center text-sm"
data-testid="cloud-architecture-error"
>
<p className="font-medium text-[var(--color-danger)]">
Couldn&rsquo;t load architecture
</p>
<p className="text-[var(--color-text-dim)]">
The Catalyst API is temporarily unreachable. Retry will start
automatically.
</p>
<button
type="button"
onClick={refetch}
className="mt-2 rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs hover:bg-[var(--color-bg)]"
>
Retry
</button>
</div>
)}
{!hasNodes && !isLoading && !isError && (
<div
className="flex h-[480px] flex-col items-center justify-center gap-2 px-6 text-center text-sm"
data-testid="cloud-architecture-empty"
>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
<p className="font-medium text-[var(--color-text)]">Provisioning&hellip;</p>
<p className="text-[var(--color-text-dim)]">
The cloud architecture will appear here as soon as the
Sovereign cluster reports its first nodes.
</p>
</div>
)}
{hasNodes && layout && (
<svg
data-testid="cloud-architecture-svg"
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
aria-label="Sovereign cloud architecture"
style={{ display: 'block', minWidth: '100%' }}
>
<defs>
<marker
id="cloud-architecture-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 Z" fill="var(--color-border-strong)" />
</marker>
</defs>
{/* Depth row labels — anchor the layered intent. */}
<g data-testid="cloud-architecture-depth-labels">
{(['Cloud', 'Region', 'Cluster', 'vCluster'] as const).map((label, i) => {
const sample = layout.nodes.find((n) => n.depth === i)
if (!sample) return null
return (
<text
key={label}
x={8}
y={sample.y - 6}
fontSize={10}
fontWeight={600}
fill="var(--color-text-dim)"
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
{label}
</text>
)
})}
</g>
{/* Edges first so they sit beneath the nodes. */}
<g data-testid="cloud-architecture-edges">
{layout.edges.map((e) => (
<polyline
key={e.id}
data-testid={`cloud-edge-${e.fromId}-${e.toId}`}
points={e.points.map((p) => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke="var(--color-border-strong)"
strokeWidth={1.5}
markerEnd="url(#cloud-architecture-arrow)"
opacity={0.6}
/>
))}
</g>
{/* Nodes. */}
<g data-testid="cloud-architecture-nodes">
{layout.nodes.map((n) => {
const fill = STATUS_FILL[n.status]
const ring = STATUS_RING[n.status]
const isSelected = selectedId === n.id
return (
<g
key={n.id}
data-testid={`cloud-node-${n.id}`}
data-kind={n.kind}
data-depth={n.depth}
data-status={n.status}
data-dim={n.dim ? 'true' : 'false'}
transform={`translate(${n.x}, ${n.y})`}
style={{ cursor: 'pointer', opacity: n.dim ? 0.35 : 1 }}
onClick={() => 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)
}
}}
>
<rect
width={n.width}
height={n.height}
rx={10}
ry={10}
fill="var(--color-bg)"
stroke={ring}
strokeWidth={isSelected ? 2.5 : 1.5}
/>
<circle cx={14} cy={n.height / 2} r={6} fill={fill} />
<text
x={28}
y={n.height / 2 - 6}
fill="var(--color-text-strong)"
fontSize={12}
fontWeight={600}
dominantBaseline="middle"
>
{truncate(n.label, Math.floor(n.width / 8))}
</text>
<text
x={28}
y={n.height / 2 + 12}
fill="var(--color-text-dim)"
fontSize={10}
fontWeight={500}
dominantBaseline="middle"
>
{n.sublabel}
</text>
</g>
)
})}
</g>
</svg>
)}
</div>
{/* 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 && (
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
data-testid="cloud-architecture-add-region"
onClick={() => setModal({ kind: 'add-region' })}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-bg)]"
>
+ Add region
</button>
</div>
)}
<InfrastructureDetailPanel
node={selectedNode}
onClose={() => setSelectedId(null)}
actions={detailActions}
/>
{/* CRUD modals — opened from the detail panel actions. */}
{data && (
<>
<AddRegionModal
open={modal.kind === 'add-region'}
deploymentId={deploymentId}
defaultProvider={inferDefaultProvider(data)}
onClose={() => setModal({ kind: 'none' })}
/>
{selectedNode?.kind === 'region' && (
<>
<AddClusterModal
open={modal.kind === 'add-cluster'}
deploymentId={deploymentId}
regionId={selectedNode.id}
regionProvider={
selectedNode.ref.kind === 'region'
? (selectedNode.ref.data.provider as CloudProvider)
: 'hetzner'
}
onClose={() => setModal({ kind: 'none' })}
/>
<AddLBModal
open={modal.kind === 'add-lb'}
deploymentId={deploymentId}
regionId={selectedNode.id}
onClose={() => setModal({ kind: 'none' })}
/>
</>
)}
{selectedNode?.kind === 'cluster' && (
<>
<AddVClusterModal
open={modal.kind === 'add-vcluster'}
deploymentId={deploymentId}
clusterId={selectedNode.id}
onClose={() => setModal({ kind: 'none' })}
/>
<AddNodePoolModal
open={modal.kind === 'add-nodepool'}
deploymentId={deploymentId}
clusterId={selectedNode.id}
regionProvider={inferProviderForCluster(data, selectedNode.id)}
onClose={() => setModal({ kind: 'none' })}
/>
</>
)}
{selectedNode && (
<DeleteCascadeConfirm
open={modal.kind === 'delete'}
deploymentId={deploymentId}
resource={resourceForKind(selectedNode.kind)}
resourceId={selectedNode.id}
resourceLabel={selectedNode.label}
onClose={() => setModal({ kind: 'none' })}
/>
)}
</>
)}
</div>
<ArchitectureGraphPage
deploymentId={deploymentId}
data={data}
isLoading={isLoading}
isError={isError}
onRefetch={refetch}
/>
)
}
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<typeof useCloud>['data']): CloudProvider {
const first = data?.cloud[0]
return ((first?.provider ?? 'hetzner') as CloudProvider)
}
function inferProviderForCluster(
data: ReturnType<typeof useCloud>['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'
}