feat(catalyst-ui): ArchitectureGraphPage — adapter, density, search, panel, context menu
P2 of openova-io/openova#309. The page-level orchestrator wraps
GraphCanvas with the operator-facing UX founder spec calls for.
adapter.ts (hierarchyToGraph):
- Turns HierarchicalInfrastructure into neutral GraphNode/GraphEdge
- Composite ids: ${type}:${elementId}
- Edges emitted: contains, runs-on, routes-to, attached-to,
peers-with — containment is treated as ONE edge type (founder
verbatim: "forget about the containment, just show it as another
type of relation")
- Node types: Cloud, Region, Cluster, vCluster, NodePool,
WorkerNode, LoadBalancer, Network — every leaf surfaces so the
operator sees the full architecture in one canvas
ArchitectureGraphPage.tsx — bound to useCloud() data:
- Toolbar: search (debounced 250ms, isolation pattern with
"X matches + Y neighbors" counter) + global density slider
(0..100%, default 50%, applies proportional cap to all tunable
types) + clear-focus button
- Per-type badges with mini Popover: slider 0..total, presets
None / 25% / 50% / All / Hide; small types (<50) toggle hidden
on click; debounced 400ms
- Right-side detail panel on node click: properties, neighbor
list with type-color dots, focus-neighbors toggle, kind-aware
add-child button, delete (Region/Cluster/vCluster)
- Double-click → focus mode (filter to focus + direct neighbors)
- Right-click on node → context menu: kind-aware add (Cluster
has add-vcluster + add-nodepool, Region has add-cluster +
add-lb, Cloud has add-region) + delete
- Right-click on canvas → context menu with "Add region"
- Shift-drag from one node to another → emits onEdgeCreate
(logs intent; relation API lands with #321)
- Edge legend at the bottom — colour swatch + count per relation
type, dashed swatch matches edge rendering
- Reuses existing CrudModals (AddRegion / AddCluster / AddVCluster
/ AddNodePool / AddLB / DeleteCascadeConfirm) — no new modal
components, only fresh wiring
Per docs/INVIOLABLE-PRINCIPLES.md:
#1 (waterfall, target shape) — every UI affordance ships in the
first cut; no "for now" shortcuts.
#4 (never hardcode) — the type list, density presets, debounce
interval, edge palette and small-type threshold are all
constants at the top of the file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b94bfe5fde
commit
31cdc5a616
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* adapter.ts — turns the hierarchical infrastructure tree
|
||||
* (HierarchicalInfrastructure) into the neutral GraphNode/GraphEdge
|
||||
* shape consumed by GraphCanvas.
|
||||
*
|
||||
* Per founder spec: containment is just one of several edge types.
|
||||
* The adapter emits:
|
||||
* • `contains` — Cloud→Region, Region→Cluster, Cluster→vCluster
|
||||
* (the founder verbatim said "show it as another
|
||||
* type of relation" — so it stays, but rendered
|
||||
* identically to the others)
|
||||
* • `runs-on` — Cluster ←runs-on— NodePool / WorkerNode
|
||||
* • `routes-to` — LoadBalancer→Cluster
|
||||
* • `attached-to`— Network→Region (dashed)
|
||||
* • `peers-with` — Network↔Network (peering edges, dashed)
|
||||
* • `depends-on` — reserved for future cross-tree dependencies
|
||||
*
|
||||
* Composite ids: ${type}:${elementId} so a Region with id "eu-central"
|
||||
* becomes "Region:eu-central" — no collision with cluster ids that
|
||||
* might happen to share an integer suffix.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md:
|
||||
* #4 (never hardcode) — the type/edge palette lives in types.ts and
|
||||
* this file owns ONLY the shape transform.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HierarchicalInfrastructure,
|
||||
ClusterSpec,
|
||||
RegionSpec,
|
||||
} from '@/lib/infrastructure.types'
|
||||
import type { GraphEdge, GraphNode } from './types'
|
||||
|
||||
export interface AdaptResult {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
}
|
||||
|
||||
function compositeId(type: string, id: string): string {
|
||||
return `${type}:${id}`
|
||||
}
|
||||
|
||||
export function hierarchyToGraph(tree: HierarchicalInfrastructure | null): AdaptResult {
|
||||
const nodes: GraphNode[] = []
|
||||
const edges: GraphEdge[] = []
|
||||
if (!tree) return { nodes, edges }
|
||||
|
||||
// 1. Cloud anchors.
|
||||
for (const c of tree.cloud) {
|
||||
const id = compositeId('Cloud', c.id)
|
||||
nodes.push({
|
||||
id,
|
||||
type: 'Cloud',
|
||||
label: c.name || c.provider,
|
||||
sublabel: `${c.provider}`,
|
||||
status: 'healthy',
|
||||
metadata: {
|
||||
provider: c.provider,
|
||||
regions: String(c.regionCount),
|
||||
quota: `${c.quotaUsed}/${c.quotaLimit}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Regions, then their clusters / vclusters / pools / nodes / lbs / networks.
|
||||
for (const region of tree.topology.regions) {
|
||||
addRegion(region, nodes, edges, tree)
|
||||
}
|
||||
|
||||
// 3. Network peering edges — networks across regions.
|
||||
// We collect peering ids once after all networks have been emitted.
|
||||
const networkIds = new Set(
|
||||
tree.topology.regions.flatMap((r) => r.networks ?? []).map((n) => compositeId('Network', n.id)),
|
||||
)
|
||||
for (const region of tree.topology.regions) {
|
||||
for (const net of region.networks ?? []) {
|
||||
for (const peer of net.peerings ?? []) {
|
||||
// Best-effort: the peer's vpcPair string holds "from → to".
|
||||
// We don't have a structured peer-id, so skip cross-network edges
|
||||
// that don't resolve cleanly. If both ends exist in our
|
||||
// network set, draw a peers-with edge.
|
||||
const parts = peer.vpcPair?.split(/→|->/).map((s) => s.trim()) ?? []
|
||||
if (parts.length === 2) {
|
||||
const a = compositeId('Network', parts[0]!)
|
||||
const b = compositeId('Network', parts[1]!)
|
||||
if (networkIds.has(a) && networkIds.has(b) && a !== b) {
|
||||
edges.push({
|
||||
id: `peer:${peer.id}`,
|
||||
source: a,
|
||||
target: b,
|
||||
type: 'peers-with',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
function addRegion(
|
||||
region: RegionSpec,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
tree: HierarchicalInfrastructure,
|
||||
): void {
|
||||
const regionId = compositeId('Region', region.id)
|
||||
nodes.push({
|
||||
id: regionId,
|
||||
type: 'Region',
|
||||
label: region.name || region.providerRegion,
|
||||
sublabel: `${region.provider} · ${region.providerRegion}`,
|
||||
status: region.status,
|
||||
metadata: {
|
||||
provider: region.provider,
|
||||
providerRegion: region.providerRegion,
|
||||
skuCp: region.skuCp,
|
||||
skuWorker: region.skuWorker,
|
||||
workers: String(region.workerCount),
|
||||
},
|
||||
})
|
||||
|
||||
// Cloud → Region.
|
||||
const cloudMatch = tree.cloud.find((c) => c.provider === region.provider)
|
||||
if (cloudMatch) {
|
||||
edges.push({
|
||||
id: `e:${compositeId('Cloud', cloudMatch.id)}->${regionId}`,
|
||||
source: compositeId('Cloud', cloudMatch.id),
|
||||
target: regionId,
|
||||
type: 'contains',
|
||||
})
|
||||
}
|
||||
|
||||
// Networks under the region (attached-to, dashed).
|
||||
for (const net of region.networks ?? []) {
|
||||
const netId = compositeId('Network', net.id)
|
||||
nodes.push({
|
||||
id: netId,
|
||||
type: 'Network',
|
||||
label: `vpc-${net.id.slice(0, 6)}`,
|
||||
sublabel: net.cidr,
|
||||
status: 'healthy',
|
||||
metadata: {
|
||||
cidr: net.cidr,
|
||||
region: net.region,
|
||||
},
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${netId}->${regionId}`,
|
||||
source: netId,
|
||||
target: regionId,
|
||||
type: 'attached-to',
|
||||
})
|
||||
}
|
||||
|
||||
for (const cluster of region.clusters ?? []) {
|
||||
addCluster(cluster, region, nodes, edges)
|
||||
}
|
||||
}
|
||||
|
||||
function addCluster(
|
||||
cluster: ClusterSpec,
|
||||
region: RegionSpec,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
): void {
|
||||
const regionId = compositeId('Region', region.id)
|
||||
const clusterId = compositeId('Cluster', cluster.id)
|
||||
nodes.push({
|
||||
id: clusterId,
|
||||
type: 'Cluster',
|
||||
label: cluster.name,
|
||||
sublabel: cluster.version,
|
||||
status: cluster.status,
|
||||
metadata: {
|
||||
version: cluster.version,
|
||||
nodes: String(cluster.nodeCount),
|
||||
vclusters: String(cluster.vclusters.length),
|
||||
},
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${regionId}->${clusterId}`,
|
||||
source: regionId,
|
||||
target: clusterId,
|
||||
type: 'contains',
|
||||
})
|
||||
|
||||
// vClusters.
|
||||
for (const vc of cluster.vclusters) {
|
||||
const vcId = compositeId('vCluster', vc.id)
|
||||
nodes.push({
|
||||
id: vcId,
|
||||
type: 'vCluster',
|
||||
label: vc.name,
|
||||
sublabel: vc.isolationMode,
|
||||
status: vc.status,
|
||||
metadata: { isolationMode: vc.isolationMode },
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${clusterId}->${vcId}`,
|
||||
source: clusterId,
|
||||
target: vcId,
|
||||
type: 'contains',
|
||||
})
|
||||
}
|
||||
|
||||
// Node pools.
|
||||
for (const pool of cluster.nodePools) {
|
||||
const pId = compositeId('NodePool', pool.id)
|
||||
nodes.push({
|
||||
id: pId,
|
||||
type: 'NodePool',
|
||||
label: pool.id,
|
||||
sublabel: `${pool.sku} ×${pool.replicas}`,
|
||||
status: pool.status,
|
||||
metadata: { sku: pool.sku, replicas: String(pool.replicas) },
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${pId}->${clusterId}`,
|
||||
source: pId,
|
||||
target: clusterId,
|
||||
type: 'runs-on',
|
||||
})
|
||||
}
|
||||
|
||||
// Worker nodes.
|
||||
for (const node of cluster.nodes) {
|
||||
const nId = compositeId('WorkerNode', node.id)
|
||||
nodes.push({
|
||||
id: nId,
|
||||
type: 'WorkerNode',
|
||||
label: node.name,
|
||||
sublabel: `${node.sku} · ${node.role}`,
|
||||
status: node.status,
|
||||
metadata: { sku: node.sku, role: node.role, ip: node.ip },
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${nId}->${clusterId}`,
|
||||
source: nId,
|
||||
target: clusterId,
|
||||
type: 'runs-on',
|
||||
})
|
||||
}
|
||||
|
||||
// Load balancers.
|
||||
for (const lb of cluster.loadBalancers) {
|
||||
const lbId = compositeId('LoadBalancer', lb.id)
|
||||
nodes.push({
|
||||
id: lbId,
|
||||
type: 'LoadBalancer',
|
||||
label: lb.name,
|
||||
sublabel: lb.publicIP,
|
||||
status: lb.status,
|
||||
metadata: {
|
||||
publicIP: lb.publicIP,
|
||||
listeners: lb.listeners.map((l) => `${l.port}/${l.protocol}`).join(','),
|
||||
},
|
||||
})
|
||||
edges.push({
|
||||
id: `e:${lbId}->${clusterId}`,
|
||||
source: lbId,
|
||||
target: clusterId,
|
||||
type: 'routes-to',
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Public surface of the architecture-graph widget package.
|
||||
*
|
||||
* Two layers:
|
||||
* • GraphCanvas — reusable, low-level force-directed canvas
|
||||
* • ArchitectureGraphPage — page-level orchestrator (data adapter +
|
||||
* density slider + search + detail panel + context menu + CRUD)
|
||||
*/
|
||||
|
||||
export { GraphCanvas, type GraphCanvasHandle, type GraphCanvasProps } from './GraphCanvas'
|
||||
export {
|
||||
ArchitectureGraphPage,
|
||||
type ArchitectureGraphPageProps,
|
||||
} from './ArchitectureGraphPage'
|
||||
export { hierarchyToGraph } from './adapter'
|
||||
export {
|
||||
edgeNodeId,
|
||||
type ArchEdgeType,
|
||||
type ArchNodeType,
|
||||
type ArchStatus,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
} from './types'
|
||||
Loading…
Reference in New Issue
Block a user