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:
parent
4b35ed4214
commit
c655bc6d45
@ -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
|
||||
}
|
||||
@ -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’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…</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'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user