feat(catalyst-ui): rename InfrastructureTopology/Compute/Network/Storage files + testids
Renames the four Sovereign-Cloud sub-page files + classes + testids (issue #309). The component contents stay otherwise unchanged in P1 — the force-graph rewrite (P2) and per-resource list pages (P3) are separate phases. Renames: InfrastructureTopology.tsx → Architecture.tsx InfrastructureTopology → Architecture InfrastructureCompute.tsx → CloudCompute.tsx InfrastructureCompute → CloudCompute InfrastructureNetwork.tsx → CloudNetwork.tsx InfrastructureNetwork → CloudNetwork InfrastructureStorage.tsx → CloudStorage.tsx InfrastructureStorage → CloudStorage Testid prefix renames (data-testid + FlatTable testId props): infrastructure-topology-* → cloud-architecture-* infrastructure-compute-* → cloud-compute-* infrastructure-network-* → cloud-network-* infrastructure-storage-* → cloud-storage-* infrastructure-pools-* → cloud-pools-* infrastructure-pool-row-* → cloud-pool-row-* infrastructure-nodes-* → cloud-nodes-* infrastructure-node-row-* → cloud-node-row-* infrastructure-pvcs-* → cloud-pvcs-* infrastructure-pvc-row-* → cloud-pvc-row-* infrastructure-buckets-* → cloud-buckets-* infrastructure-bucket-row-* → cloud-bucket-row-* infrastructure-volumes-* → cloud-volumes-* infrastructure-volume-row-* → cloud-volume-row-* infrastructure-lbs-* → cloud-lbs-* infrastructure-lb-row-* → cloud-lb-row-* infrastructure-peerings-* → cloud-peerings-* infrastructure-peering-row-* → cloud-peering-row-* infrastructure-firewalls-* → cloud-firewalls-* infrastructure-firewall-row-* → cloud-firewall-row-* infra-edge-* → cloud-edge-* infra-node-* → cloud-node-* infra-topology-arrow → cloud-architecture-arrow Modal testids (`infrastructure-modal-*`) are out of scope for P1 and keep their current shape — those modal components are reused beyond the Cloud surface. Architecture sub-page user-visible strings updated: "Loading topology…" → "Loading architecture…" "Couldn't load topology" → "Couldn't load architecture" "Topology will appear here..." → "The cloud architecture will appear here..." aria-label: "Sovereign infrastructure topology" → "Sovereign cloud architecture" Router imports + component references switched to the renamed exports. Test files updated alongside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
344a8009df
commit
4ba99525f1
@ -25,10 +25,10 @@ import { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
|
||||
import { Dashboard } from '@/pages/sovereign/Dashboard'
|
||||
import { BatchDetail } from '@/pages/sovereign/BatchDetail'
|
||||
import { CloudPage } from '@/pages/sovereign/CloudPage'
|
||||
import { InfrastructureTopology } from '@/pages/sovereign/InfrastructureTopology'
|
||||
import { InfrastructureCompute } from '@/pages/sovereign/InfrastructureCompute'
|
||||
import { InfrastructureStorage } from '@/pages/sovereign/InfrastructureStorage'
|
||||
import { InfrastructureNetwork } from '@/pages/sovereign/InfrastructureNetwork'
|
||||
import { Architecture } from '@/pages/sovereign/Architecture'
|
||||
import { CloudCompute } from '@/pages/sovereign/CloudCompute'
|
||||
import { CloudStorage } from '@/pages/sovereign/CloudStorage'
|
||||
import { CloudNetwork } from '@/pages/sovereign/CloudNetwork'
|
||||
|
||||
// Root
|
||||
const rootRoute = createRootRoute({ component: RootLayout })
|
||||
@ -140,10 +140,9 @@ const provisionDashboardRoute = createRoute({
|
||||
// /cloud redirects to the architecture sub-route so the URL shape is
|
||||
// always explicit.
|
||||
//
|
||||
// The legacy /infrastructure/* routes below are preserved for now and
|
||||
// render the same components — a follow-up commit converts them to
|
||||
// 301-style redirects to the /cloud/* equivalents. Keeping both
|
||||
// resolvable in this initial commit keeps the diff additive.
|
||||
// The legacy /infrastructure/* paths below are preserved as
|
||||
// redirect-only routes so deep links and bookmarks land on the
|
||||
// renamed surface without 404'ing.
|
||||
const provisionCloudRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/cloud',
|
||||
@ -164,25 +163,25 @@ const provisionCloudIndexRoute = createRoute({
|
||||
const provisionCloudArchitectureRoute = createRoute({
|
||||
getParentRoute: () => provisionCloudRoute,
|
||||
path: '/architecture',
|
||||
component: InfrastructureTopology,
|
||||
component: Architecture,
|
||||
})
|
||||
|
||||
const provisionCloudComputeRoute = createRoute({
|
||||
getParentRoute: () => provisionCloudRoute,
|
||||
path: '/compute',
|
||||
component: InfrastructureCompute,
|
||||
component: CloudCompute,
|
||||
})
|
||||
|
||||
const provisionCloudStorageRoute = createRoute({
|
||||
getParentRoute: () => provisionCloudRoute,
|
||||
path: '/storage',
|
||||
component: InfrastructureStorage,
|
||||
component: CloudStorage,
|
||||
})
|
||||
|
||||
const provisionCloudNetworkRoute = createRoute({
|
||||
getParentRoute: () => provisionCloudRoute,
|
||||
path: '/network',
|
||||
component: InfrastructureNetwork,
|
||||
component: CloudNetwork,
|
||||
})
|
||||
|
||||
// Legacy /infrastructure/* — every legacy path now redirects to its
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* _shared.tsx — modal shell + form atoms used by every CRUD modal in
|
||||
* the Sovereign Infrastructure surface (issue #228).
|
||||
* the Sovereign Cloud surface (issue #309 supersedes #228).
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md:
|
||||
* #2 (no compromise) — every CRUD modal speaks the same vocabulary
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* InfrastructureTopology.test.tsx — render lock-in for the hierarchical
|
||||
* Topology canvas (issue #228).
|
||||
* Architecture.test.tsx — render lock-in for the Sovereign Cloud /
|
||||
* Architecture sub-page hierarchical canvas (issue #309 supersedes
|
||||
* #228).
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Empty state shows when the tree has no nodes.
|
||||
@ -25,7 +26,7 @@ import {
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { CloudPage } from './CloudPage'
|
||||
import { InfrastructureTopology } from './InfrastructureTopology'
|
||||
import { Architecture } from './Architecture'
|
||||
import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture'
|
||||
import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
@ -48,7 +49,7 @@ function renderTopologyPage(data: HierarchicalInfrastructure) {
|
||||
const architectureRoute = createRoute({
|
||||
getParentRoute: () => cloudRoute,
|
||||
path: '/architecture',
|
||||
component: InfrastructureTopology,
|
||||
component: Architecture,
|
||||
})
|
||||
const tree = rootRoute.addChildren([cloudRoute.addChildren([architectureRoute])])
|
||||
const router = createRouter({
|
||||
@ -69,7 +70,7 @@ function renderTopologyPage(data: HierarchicalInfrastructure) {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('InfrastructureTopology — empty', () => {
|
||||
describe('Architecture — empty', () => {
|
||||
it('renders the Provisioning… overlay when the tree is empty', async () => {
|
||||
const empty: HierarchicalInfrastructure = {
|
||||
cloud: [],
|
||||
@ -77,35 +78,35 @@ describe('InfrastructureTopology — empty', () => {
|
||||
storage: { pvcs: [], buckets: [], volumes: [] },
|
||||
}
|
||||
renderTopologyPage(empty)
|
||||
expect(await screen.findByTestId('infrastructure-topology-empty')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-architecture-empty')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InfrastructureTopology — hierarchical render', () => {
|
||||
describe('Architecture — hierarchical render', () => {
|
||||
it('renders the canvas with all 4 depths', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-topology-svg')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-architecture-svg')).toBeTruthy()
|
||||
|
||||
// Depth 0 — cloud
|
||||
expect(screen.getByTestId('infra-node-cloud-hetzner')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-node-cloud-hetzner')).toBeTruthy()
|
||||
// Depth 1 — region
|
||||
expect(screen.getByTestId('infra-node-region-eu-central')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-node-region-eu-central')).toBeTruthy()
|
||||
// Depth 2 — cluster
|
||||
expect(screen.getByTestId('infra-node-cluster-eu-central-primary')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-node-cluster-eu-central-primary')).toBeTruthy()
|
||||
// Depth 3 — vcluster
|
||||
expect(screen.getByTestId('infra-node-vc-eu-central-dmz')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-node-vc-eu-central-dmz')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders edges between parent and child', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
await screen.findByTestId('infrastructure-topology-svg')
|
||||
expect(screen.getByTestId('infra-edge-cloud-hetzner-region-eu-central')).toBeTruthy()
|
||||
expect(screen.getByTestId('infra-edge-region-eu-central-cluster-eu-central-primary')).toBeTruthy()
|
||||
await screen.findByTestId('cloud-architecture-svg')
|
||||
expect(screen.getByTestId('cloud-edge-cloud-hetzner-region-eu-central')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-edge-region-eu-central-cluster-eu-central-primary')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the right-side detail panel on node click and closes it on dismiss', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
const node = await screen.findByTestId('infra-node-cluster-eu-central-primary')
|
||||
const node = await screen.findByTestId('cloud-node-cluster-eu-central-primary')
|
||||
expect(screen.queryByTestId('infrastructure-detail-panel')).toBeNull()
|
||||
fireEvent.click(node)
|
||||
expect(screen.getByTestId('infrastructure-detail-panel')).toBeTruthy()
|
||||
@ -116,26 +117,26 @@ describe('InfrastructureTopology — hierarchical render', () => {
|
||||
|
||||
it('zooms in on a cluster click — vClusters lose data-dim=true', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
const cluster = await screen.findByTestId('infra-node-cluster-eu-central-primary')
|
||||
const cluster = await screen.findByTestId('cloud-node-cluster-eu-central-primary')
|
||||
|
||||
// Before zoom — vClusters are dim by default.
|
||||
const vcBefore = screen.getByTestId('infra-node-vc-eu-central-dmz')
|
||||
const vcBefore = screen.getByTestId('cloud-node-vc-eu-central-dmz')
|
||||
expect(vcBefore.getAttribute('data-dim')).toBe('true')
|
||||
|
||||
fireEvent.click(cluster)
|
||||
|
||||
// After zoom — vClusters of THIS cluster are bright.
|
||||
const vcAfter = screen.getByTestId('infra-node-vc-eu-central-dmz')
|
||||
const vcAfter = screen.getByTestId('cloud-node-vc-eu-central-dmz')
|
||||
expect(vcAfter.getAttribute('data-dim')).toBe('false')
|
||||
// Zoom-status banner is visible.
|
||||
expect(screen.getByTestId('infrastructure-topology-zoom-status')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-architecture-zoom-status')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InfrastructureTopology — CRUD modal triggers', () => {
|
||||
describe('Architecture — CRUD modal triggers', () => {
|
||||
it('opens the Add Region modal when the top-level button is clicked', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
const btn = await screen.findByTestId('infrastructure-topology-add-region')
|
||||
const btn = await screen.findByTestId('cloud-architecture-add-region')
|
||||
fireEvent.click(btn)
|
||||
const modal = screen.getByTestId('infrastructure-modal-add-region')
|
||||
expect(modal).toBeTruthy()
|
||||
@ -144,14 +145,14 @@ describe('InfrastructureTopology — CRUD modal triggers', () => {
|
||||
|
||||
it('opens the Add Cluster modal from a region detail panel', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infra-node-region-eu-central'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-node-region-eu-central'))
|
||||
fireEvent.click(screen.getByTestId('infrastructure-detail-panel-action-add-cluster'))
|
||||
expect(screen.getByTestId('infrastructure-modal-add-cluster')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the Add vCluster modal from a cluster detail panel', async () => {
|
||||
renderTopologyPage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infra-node-cluster-eu-central-primary'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-node-cluster-eu-central-primary'))
|
||||
fireEvent.click(screen.getByTestId('infrastructure-detail-panel-action-add-vcluster'))
|
||||
expect(screen.getByTestId('infrastructure-modal-add-vcluster')).toBeTruthy()
|
||||
})
|
||||
@ -1,24 +1,19 @@
|
||||
/**
|
||||
* InfrastructureTopology — hierarchical layered SVG canvas for the
|
||||
* Sovereign Infrastructure Topology tab (default landing).
|
||||
* Architecture — hierarchical layered SVG canvas for the Sovereign
|
||||
* Cloud / Architecture sub-page (default landing under /cloud).
|
||||
*
|
||||
* Per founder spec (issue #228):
|
||||
* • 4 visual depths: Cloud → Region → Cluster → vCluster
|
||||
* • Click node → graph zooms in (NOT accordion)
|
||||
* • vClusters render dim until their parent cluster is zoomed
|
||||
* • Right-side detail panel slides in (InfrastructureDetailPanel)
|
||||
* • Layered, NOT force-directed — pure topologyLayout in
|
||||
* `@/lib/topologyLayout`
|
||||
* 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.
|
||||
*
|
||||
* The 4 tabs (Topology / Compute / Storage / Network) are filtered
|
||||
* lenses over ONE backend response. Topology reads the tree directly;
|
||||
* the others use flatten* helpers.
|
||||
* 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.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md:
|
||||
* #2 (no compromise) — pure layout function, no `reactflow`, no
|
||||
* simulation.
|
||||
* #4 (never hardcode) — every status colour comes from the
|
||||
* `--color-*` CSS variables the rest of the portal uses.
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode) — every
|
||||
* status colour comes from the `--color-*` CSS variables the rest of
|
||||
* the portal uses.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -61,7 +56,7 @@ interface ModalState {
|
||||
| 'delete'
|
||||
}
|
||||
|
||||
export function InfrastructureTopology() {
|
||||
export function Architecture() {
|
||||
const { deploymentId, data, isLoading, isError, refetch } = useCloud()
|
||||
|
||||
const [zoom, setZoom] = useState<ZoomState>({
|
||||
@ -162,10 +157,10 @@ export function InfrastructureTopology() {
|
||||
const hasNodes = !!layout && layout.nodes.length > 0
|
||||
|
||||
return (
|
||||
<div data-testid="infrastructure-topology" className="relative">
|
||||
<div data-testid="cloud-architecture" className="relative">
|
||||
{(zoom.zoomedClusterId || zoom.zoomedRegionId) && (
|
||||
<div
|
||||
data-testid="infrastructure-topology-zoom-status"
|
||||
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)]">
|
||||
@ -175,7 +170,7 @@ export function InfrastructureTopology() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-topology-zoom-reset"
|
||||
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)]"
|
||||
>
|
||||
@ -186,25 +181,25 @@ export function InfrastructureTopology() {
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)]"
|
||||
data-testid="infrastructure-topology-canvas"
|
||||
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="infrastructure-topology-loading"
|
||||
data-testid="cloud-architecture-loading"
|
||||
>
|
||||
Loading topology…
|
||||
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="infrastructure-topology-error"
|
||||
data-testid="cloud-architecture-error"
|
||||
>
|
||||
<p className="font-medium text-[var(--color-danger)]">
|
||||
Couldn’t load topology
|
||||
Couldn’t load architecture
|
||||
</p>
|
||||
<p className="text-[var(--color-text-dim)]">
|
||||
The Catalyst API is temporarily unreachable. Retry will start
|
||||
@ -223,30 +218,30 @@ export function InfrastructureTopology() {
|
||||
{!hasNodes && !isLoading && !isError && (
|
||||
<div
|
||||
className="flex h-[480px] flex-col items-center justify-center gap-2 px-6 text-center text-sm"
|
||||
data-testid="infrastructure-topology-empty"
|
||||
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)]">
|
||||
Topology will appear here as soon as the Sovereign cluster
|
||||
reports its first nodes.
|
||||
The cloud architecture will appear here as soon as the
|
||||
Sovereign cluster reports its first nodes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNodes && layout && (
|
||||
<svg
|
||||
data-testid="infrastructure-topology-svg"
|
||||
data-testid="cloud-architecture-svg"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
role="img"
|
||||
aria-label="Sovereign infrastructure topology"
|
||||
aria-label="Sovereign cloud architecture"
|
||||
style={{ display: 'block', minWidth: '100%' }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="infra-topology-arrow"
|
||||
id="cloud-architecture-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
@ -259,7 +254,7 @@ export function InfrastructureTopology() {
|
||||
</defs>
|
||||
|
||||
{/* Depth row labels — anchor the layered intent. */}
|
||||
<g data-testid="infrastructure-topology-depth-labels">
|
||||
<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
|
||||
@ -280,23 +275,23 @@ export function InfrastructureTopology() {
|
||||
</g>
|
||||
|
||||
{/* Edges first so they sit beneath the nodes. */}
|
||||
<g data-testid="infrastructure-topology-edges">
|
||||
<g data-testid="cloud-architecture-edges">
|
||||
{layout.edges.map((e) => (
|
||||
<polyline
|
||||
key={e.id}
|
||||
data-testid={`infra-edge-${e.fromId}-${e.toId}`}
|
||||
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(#infra-topology-arrow)"
|
||||
markerEnd="url(#cloud-architecture-arrow)"
|
||||
opacity={0.6}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Nodes. */}
|
||||
<g data-testid="infrastructure-topology-nodes">
|
||||
<g data-testid="cloud-architecture-nodes">
|
||||
{layout.nodes.map((n) => {
|
||||
const fill = STATUS_FILL[n.status]
|
||||
const ring = STATUS_RING[n.status]
|
||||
@ -304,7 +299,7 @@ export function InfrastructureTopology() {
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
data-testid={`infra-node-${n.id}`}
|
||||
data-testid={`cloud-node-${n.id}`}
|
||||
data-kind={n.kind}
|
||||
data-depth={n.depth}
|
||||
data-status={n.status}
|
||||
@ -363,12 +358,12 @@ export function InfrastructureTopology() {
|
||||
|
||||
{/* 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 Topology view. */}
|
||||
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="infrastructure-topology-add-region"
|
||||
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)]"
|
||||
>
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* InfrastructureCompute.test.tsx — render lock-in for the Compute tab.
|
||||
* CloudCompute.test.tsx — render lock-in for the Compute tab.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Empty state shows when the tree has no clusters / nodes.
|
||||
@ -21,7 +21,7 @@ import {
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { CloudPage } from './CloudPage'
|
||||
import { InfrastructureCompute } from './InfrastructureCompute'
|
||||
import { CloudCompute } from './CloudCompute'
|
||||
import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture'
|
||||
import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
@ -44,7 +44,7 @@ function renderComputePage(data: HierarchicalInfrastructure) {
|
||||
const computeRoute = createRoute({
|
||||
getParentRoute: () => cloudRoute,
|
||||
path: '/compute',
|
||||
component: InfrastructureCompute,
|
||||
component: CloudCompute,
|
||||
})
|
||||
const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute])])
|
||||
const router = createRouter({
|
||||
@ -65,7 +65,7 @@ function renderComputePage(data: HierarchicalInfrastructure) {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('InfrastructureCompute — empty', () => {
|
||||
describe('CloudCompute — empty', () => {
|
||||
it('renders the empty state when there are no clusters or nodes', async () => {
|
||||
const empty: HierarchicalInfrastructure = {
|
||||
cloud: [],
|
||||
@ -73,36 +73,36 @@ describe('InfrastructureCompute — empty', () => {
|
||||
storage: { pvcs: [], buckets: [], volumes: [] },
|
||||
}
|
||||
renderComputePage(empty)
|
||||
expect(await screen.findByTestId('infrastructure-compute-empty')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-compute-empty')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InfrastructureCompute — populated', () => {
|
||||
describe('CloudCompute — populated', () => {
|
||||
it('renders the Pools and Nodes tables', async () => {
|
||||
renderComputePage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-pools-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-nodes-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-pools-count').textContent).toBe('3')
|
||||
expect(await screen.findByTestId('cloud-pools-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-nodes-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-pools-count').textContent).toBe('3')
|
||||
// 4 nodes in cluster-eu-central + 2 in helsinki = 6 total
|
||||
expect(screen.getByTestId('infrastructure-nodes-count').textContent).toBe('6')
|
||||
expect(screen.getByTestId('cloud-nodes-count').textContent).toBe('6')
|
||||
})
|
||||
|
||||
it('renders the bulk-actions strip', async () => {
|
||||
renderComputePage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-compute-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-compute-bulk-scale')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-compute-bulk-drain')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-compute-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-compute-bulk-scale')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-compute-bulk-drain')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens ScalePoolModal when row-level Scale is clicked', async () => {
|
||||
renderComputePage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-pool-row-pool-eu-cp-scale'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-pool-row-pool-eu-cp-scale'))
|
||||
expect(screen.getByTestId('infrastructure-modal-scale-pool')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens NodeActionConfirm (drain) when row-level Drain is clicked', async () => {
|
||||
renderComputePage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-node-row-node-eu-w-0-drain'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-node-row-node-eu-w-0-drain'))
|
||||
expect(screen.getByTestId('infrastructure-modal-node-drain')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* InfrastructureCompute — Compute tab. Flat table grouped by [Cluster ·
|
||||
* Node Pool], reads off the shared infrastructure tree provided by
|
||||
* InfrastructurePage.
|
||||
* CloudCompute — Sovereign Cloud / Compute sub-page. Flat table
|
||||
* grouped by [Cluster · Node Pool], reads off the shared
|
||||
* infrastructure tree provided by CloudPage.
|
||||
*
|
||||
* Per founder spec (issue #228): "Compute — flat table grouped
|
||||
* [Cluster · Node Pool], each row links back to topology. Bulk
|
||||
* actions: scale, drain."
|
||||
* Per founder spec (issue #309 supersedes #228): each row links back
|
||||
* to the Architecture canvas. Bulk actions: scale, drain.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -32,7 +31,7 @@ interface NodeRow {
|
||||
region: RegionSpec
|
||||
}
|
||||
|
||||
export function InfrastructureCompute() {
|
||||
export function CloudCompute() {
|
||||
const { deploymentId, data, isLoading } = useCloud()
|
||||
|
||||
const { pools, nodes } = useMemo(() => {
|
||||
@ -61,18 +60,18 @@ export function InfrastructureCompute() {
|
||||
const isEmpty = !isLoading && pools.length === 0 && nodes.length === 0
|
||||
|
||||
return (
|
||||
<div data-testid="infrastructure-compute">
|
||||
<div data-testid="cloud-compute">
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
|
||||
data-testid="infrastructure-compute-loading"
|
||||
data-testid="cloud-compute-loading"
|
||||
>
|
||||
Loading compute resources…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="infra-empty" data-testid="infrastructure-compute-empty">
|
||||
<div className="infra-empty" data-testid="cloud-compute-empty">
|
||||
<p className="title">No clusters or worker nodes yet.</p>
|
||||
<p className="sub">
|
||||
Once the Sovereign cluster comes up, every k3s cluster and node VM
|
||||
@ -83,11 +82,11 @@ export function InfrastructureCompute() {
|
||||
|
||||
{!isEmpty && data && (
|
||||
<>
|
||||
<div className="infra-bulk-actions" data-testid="infrastructure-compute-bulk">
|
||||
<div className="infra-bulk-actions" data-testid="cloud-compute-bulk">
|
||||
<span className="label">Bulk · {selectedPools.length} pool{selectedPools.length === 1 ? '' : 's'} / {selectedNodes.length} node{selectedNodes.length === 1 ? '' : 's'}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-compute-bulk-scale"
|
||||
data-testid="cloud-compute-bulk-scale"
|
||||
disabled={selectedPools.length !== 1}
|
||||
onClick={() => {
|
||||
const row = pools.find((p) => p.pool.id === selectedPools[0])
|
||||
@ -98,7 +97,7 @@ export function InfrastructureCompute() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-compute-bulk-drain"
|
||||
data-testid="cloud-compute-bulk-drain"
|
||||
disabled={selectedNodes.length !== 1}
|
||||
onClick={() => {
|
||||
const row = nodes.find((n) => n.node.id === selectedNodes[0])
|
||||
@ -109,29 +108,29 @@ export function InfrastructureCompute() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-pools-section">
|
||||
<section className="infra-section" data-testid="cloud-pools-section">
|
||||
<h2>
|
||||
Node Pools <span className="count" data-testid="infrastructure-pools-count">{pools.length}</span>
|
||||
Node Pools <span className="count" data-testid="cloud-pools-count">{pools.length}</span>
|
||||
</h2>
|
||||
<FlatTable
|
||||
testId="infrastructure-pools-table"
|
||||
testId="cloud-pools-table"
|
||||
headers={['', 'Cluster', 'Pool', 'SKU', 'Replicas', 'Status', '']}
|
||||
>
|
||||
{pools.map(({ pool, cluster, region }) => (
|
||||
<tr key={pool.id} data-testid={`infrastructure-pool-row-${pool.id}`}>
|
||||
<tr key={pool.id} data-testid={`cloud-pool-row-${pool.id}`}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPools.includes(pool.id)}
|
||||
onChange={(e) => toggle(e.target.checked, pool.id, setSelectedPools)}
|
||||
data-testid={`infrastructure-pool-row-${pool.id}-select`}
|
||||
data-testid={`cloud-pool-row-${pool.id}-select`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
to={`/provision/$deploymentId/cloud/architecture` as never}
|
||||
params={{ deploymentId } as never}
|
||||
data-testid={`infrastructure-pool-row-${pool.id}-cluster-link`}
|
||||
data-testid={`cloud-pool-row-${pool.id}-cluster-link`}
|
||||
>
|
||||
{cluster.name}
|
||||
</Link>
|
||||
@ -149,7 +148,7 @@ export function InfrastructureCompute() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScalePool({ pool, cluster, region })}
|
||||
data-testid={`infrastructure-pool-row-${pool.id}-scale`}
|
||||
data-testid={`cloud-pool-row-${pool.id}-scale`}
|
||||
style={rowBtn}
|
||||
>
|
||||
Scale
|
||||
@ -157,7 +156,7 @@ export function InfrastructureCompute() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChangeSku({ pool, cluster, region })}
|
||||
data-testid={`infrastructure-pool-row-${pool.id}-change-sku`}
|
||||
data-testid={`cloud-pool-row-${pool.id}-change-sku`}
|
||||
style={rowBtn}
|
||||
>
|
||||
Change SKU
|
||||
@ -192,7 +191,7 @@ export function InfrastructureCompute() {
|
||||
provider: region.provider as CloudProvider,
|
||||
})
|
||||
}
|
||||
data-testid={`infrastructure-pool-add-for-${cluster.id}`}
|
||||
data-testid={`cloud-pool-add-for-${cluster.id}`}
|
||||
>
|
||||
+ Add pool to {cluster.name}
|
||||
</button>
|
||||
@ -201,22 +200,22 @@ export function InfrastructureCompute() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-nodes-section">
|
||||
<section className="infra-section" data-testid="cloud-nodes-section">
|
||||
<h2>
|
||||
Worker Nodes <span className="count" data-testid="infrastructure-nodes-count">{nodes.length}</span>
|
||||
Worker Nodes <span className="count" data-testid="cloud-nodes-count">{nodes.length}</span>
|
||||
</h2>
|
||||
<FlatTable
|
||||
testId="infrastructure-nodes-table"
|
||||
testId="cloud-nodes-table"
|
||||
headers={['', 'Cluster', 'Node', 'SKU', 'Role', 'IP', 'Status', '']}
|
||||
>
|
||||
{nodes.map(({ node, cluster }) => (
|
||||
<tr key={node.id} data-testid={`infrastructure-node-row-${node.id}`}>
|
||||
<tr key={node.id} data-testid={`cloud-node-row-${node.id}`}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNodes.includes(node.id)}
|
||||
onChange={(e) => toggle(e.target.checked, node.id, setSelectedNodes)}
|
||||
data-testid={`infrastructure-node-row-${node.id}-select`}
|
||||
data-testid={`cloud-node-row-${node.id}-select`}
|
||||
/>
|
||||
</td>
|
||||
<td>{cluster.name}</td>
|
||||
@ -231,7 +230,7 @@ export function InfrastructureCompute() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrainNode({ node, cluster, region: data.topology.regions.find((r) => r.clusters.some((c) => c.id === cluster.id))! })}
|
||||
data-testid={`infrastructure-node-row-${node.id}-drain`}
|
||||
data-testid={`cloud-node-row-${node.id}-drain`}
|
||||
style={rowBtn}
|
||||
>
|
||||
Drain
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* InfrastructureNetwork.test.tsx — render lock-in for the Network tab.
|
||||
* CloudNetwork.test.tsx — render lock-in for the Network tab.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Empty state.
|
||||
@ -22,7 +22,7 @@ import {
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { CloudPage } from './CloudPage'
|
||||
import { InfrastructureNetwork } from './InfrastructureNetwork'
|
||||
import { CloudNetwork } from './CloudNetwork'
|
||||
import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture'
|
||||
import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
@ -45,7 +45,7 @@ function renderNetworkPage(data: HierarchicalInfrastructure) {
|
||||
const networkRoute = createRoute({
|
||||
getParentRoute: () => cloudRoute,
|
||||
path: '/network',
|
||||
component: InfrastructureNetwork,
|
||||
component: CloudNetwork,
|
||||
})
|
||||
const tree = rootRoute.addChildren([cloudRoute.addChildren([networkRoute])])
|
||||
const router = createRouter({
|
||||
@ -66,7 +66,7 @@ function renderNetworkPage(data: HierarchicalInfrastructure) {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('InfrastructureNetwork — empty', () => {
|
||||
describe('CloudNetwork — empty', () => {
|
||||
it('renders the empty state when no LBs / peerings / firewalls exist', async () => {
|
||||
const empty: HierarchicalInfrastructure = {
|
||||
cloud: [],
|
||||
@ -74,43 +74,43 @@ describe('InfrastructureNetwork — empty', () => {
|
||||
storage: { pvcs: [], buckets: [], volumes: [] },
|
||||
}
|
||||
renderNetworkPage(empty)
|
||||
expect(await screen.findByTestId('infrastructure-network-empty')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-network-empty')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InfrastructureNetwork — populated', () => {
|
||||
describe('CloudNetwork — populated', () => {
|
||||
it('renders LB / Peering / Firewall tables with counts', async () => {
|
||||
renderNetworkPage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-lbs-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-peerings-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-firewalls-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-lbs-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('infrastructure-peerings-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('infrastructure-firewalls-count').textContent).toBe('1')
|
||||
expect(await screen.findByTestId('cloud-lbs-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-peerings-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-firewalls-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-lbs-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('cloud-peerings-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('cloud-firewalls-count').textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('renders the bulk-actions strip', async () => {
|
||||
renderNetworkPage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-network-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-network-add-peering')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-network-edit-dns')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-network-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-network-add-peering')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-network-edit-dns')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens AddPeeringModal when Add Peering is clicked', async () => {
|
||||
renderNetworkPage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-network-add-peering'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-network-add-peering'))
|
||||
expect(screen.getByTestId('infrastructure-modal-add-peering')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens AddLBModal when per-region Add LB is clicked', async () => {
|
||||
renderNetworkPage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-network-add-lb-region-eu-central'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-network-add-lb-region-eu-central'))
|
||||
expect(screen.getByTestId('infrastructure-modal-add-lb')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens EditFirewallRulesModal from row-level edit', async () => {
|
||||
renderNetworkPage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-firewall-row-fw-eu-central-edit'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-firewall-row-fw-eu-central-edit'))
|
||||
expect(screen.getByTestId('infrastructure-modal-edit-firewall-rules')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,10 @@
|
||||
/**
|
||||
* InfrastructureNetwork — Network tab. Flat table [LB · Peering ·
|
||||
* Firewall · DNS zone], reads off the shared infrastructure tree.
|
||||
* CloudNetwork — Sovereign Cloud / Network sub-page. Flat tables for
|
||||
* load balancers, peerings, firewalls and DNS zones; reads off the
|
||||
* shared infrastructure tree provided by CloudPage.
|
||||
*
|
||||
* Per founder spec (issue #228): "Network — flat table [LB · Peering
|
||||
* · Firewall · DNS zone]. Bulk: add rule, attach."
|
||||
* Per founder spec (issue #309 supersedes #228): bulk actions cover
|
||||
* add rule + attach.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -38,7 +39,7 @@ interface FirewallRow {
|
||||
region: RegionSpec
|
||||
}
|
||||
|
||||
export function InfrastructureNetwork() {
|
||||
export function CloudNetwork() {
|
||||
const { deploymentId, data, isLoading } = useCloud()
|
||||
|
||||
const { lbs, peerings, firewalls, networks } = useMemo(() => {
|
||||
@ -75,15 +76,15 @@ export function InfrastructureNetwork() {
|
||||
!isLoading && lbs.length === 0 && peerings.length === 0 && firewalls.length === 0
|
||||
|
||||
return (
|
||||
<div data-testid="infrastructure-network">
|
||||
<div data-testid="cloud-network">
|
||||
{isLoading && (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="infrastructure-network-loading">
|
||||
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="cloud-network-loading">
|
||||
Loading network resources…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="infra-empty" data-testid="infrastructure-network-empty">
|
||||
<div className="infra-empty" data-testid="cloud-network-empty">
|
||||
<p className="title">No network resources yet.</p>
|
||||
<p className="sub">Load balancers, peerings, firewalls and DNS zones will appear here.</p>
|
||||
</div>
|
||||
@ -91,32 +92,32 @@ export function InfrastructureNetwork() {
|
||||
|
||||
{!isEmpty && data && (
|
||||
<>
|
||||
<div className="infra-bulk-actions" data-testid="infrastructure-network-bulk">
|
||||
<div className="infra-bulk-actions" data-testid="cloud-network-bulk">
|
||||
<span className="label">Bulk actions</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="infrastructure-network-add-peering"
|
||||
data-testid="cloud-network-add-peering"
|
||||
onClick={() => setAddPeeringOpen(true)}
|
||||
>
|
||||
+ Add peering
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-network-edit-dns"
|
||||
data-testid="cloud-network-edit-dns"
|
||||
onClick={() => setEditDNS(`zone-${data.topology.regions[0]?.id ?? 'default'}`)}
|
||||
>
|
||||
Edit DNS zone
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-lbs-section">
|
||||
<section className="infra-section" data-testid="cloud-lbs-section">
|
||||
<h2>
|
||||
Load Balancers <span className="count" data-testid="infrastructure-lbs-count">{lbs.length}</span>
|
||||
Load Balancers <span className="count" data-testid="cloud-lbs-count">{lbs.length}</span>
|
||||
</h2>
|
||||
<FlatTable testId="infrastructure-lbs-table" headers={['Name', 'Public IP', 'Listeners', 'Targets', 'Region', 'Status', '']}>
|
||||
<FlatTable testId="cloud-lbs-table" headers={['Name', 'Public IP', 'Listeners', 'Targets', 'Region', 'Status', '']}>
|
||||
{lbs.map(({ lb, region }) => (
|
||||
<tr key={lb.id} data-testid={`infrastructure-lb-row-${lb.id}`}>
|
||||
<tr key={lb.id} data-testid={`cloud-lb-row-${lb.id}`}>
|
||||
<td>{lb.name}</td>
|
||||
<td style={{ fontFamily: 'monospace' }}>{lb.publicIP}</td>
|
||||
<td>{lb.listeners.map((l) => `${l.protocol}:${l.port}`).join(', ')}</td>
|
||||
@ -136,7 +137,7 @@ export function InfrastructureNetwork() {
|
||||
type="button"
|
||||
style={{ ...rowBtn, borderColor: 'var(--color-accent)', color: 'var(--color-accent)' }}
|
||||
onClick={() => setAddLBFor(r)}
|
||||
data-testid={`infrastructure-network-add-lb-${r.id}`}
|
||||
data-testid={`cloud-network-add-lb-${r.id}`}
|
||||
>
|
||||
+ Add LB to {r.name}
|
||||
</button>
|
||||
@ -144,13 +145,13 @@ export function InfrastructureNetwork() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-peerings-section">
|
||||
<section className="infra-section" data-testid="cloud-peerings-section">
|
||||
<h2>
|
||||
Peerings <span className="count" data-testid="infrastructure-peerings-count">{peerings.length}</span>
|
||||
Peerings <span className="count" data-testid="cloud-peerings-count">{peerings.length}</span>
|
||||
</h2>
|
||||
<FlatTable testId="infrastructure-peerings-table" headers={['Name', 'VPCs', 'Subnets', 'Region', 'Status']}>
|
||||
<FlatTable testId="cloud-peerings-table" headers={['Name', 'VPCs', 'Subnets', 'Region', 'Status']}>
|
||||
{peerings.map(({ peering, region }) => (
|
||||
<tr key={peering.id} data-testid={`infrastructure-peering-row-${peering.id}`}>
|
||||
<tr key={peering.id} data-testid={`cloud-peering-row-${peering.id}`}>
|
||||
<td>{peering.name}</td>
|
||||
<td>{peering.vpcPair}</td>
|
||||
<td style={{ fontFamily: 'monospace' }}>{peering.subnets}</td>
|
||||
@ -170,13 +171,13 @@ export function InfrastructureNetwork() {
|
||||
</FlatTable>
|
||||
</section>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-firewalls-section">
|
||||
<section className="infra-section" data-testid="cloud-firewalls-section">
|
||||
<h2>
|
||||
Firewalls <span className="count" data-testid="infrastructure-firewalls-count">{firewalls.length}</span>
|
||||
Firewalls <span className="count" data-testid="cloud-firewalls-count">{firewalls.length}</span>
|
||||
</h2>
|
||||
<FlatTable testId="infrastructure-firewalls-table" headers={['Name', 'Rules', 'Region', 'Status', '']}>
|
||||
<FlatTable testId="cloud-firewalls-table" headers={['Name', 'Rules', 'Region', 'Status', '']}>
|
||||
{firewalls.map(({ firewall, region }) => (
|
||||
<tr key={firewall.id} data-testid={`infrastructure-firewall-row-${firewall.id}`}>
|
||||
<tr key={firewall.id} data-testid={`cloud-firewall-row-${firewall.id}`}>
|
||||
<td>{firewall.name}</td>
|
||||
<td>{firewall.rules.length}</td>
|
||||
<td>{region.providerRegion}</td>
|
||||
@ -188,7 +189,7 @@ export function InfrastructureNetwork() {
|
||||
type="button"
|
||||
style={rowBtn}
|
||||
onClick={() => setEditFirewall(firewall)}
|
||||
data-testid={`infrastructure-firewall-row-${firewall.id}-edit`}
|
||||
data-testid={`cloud-firewall-row-${firewall.id}-edit`}
|
||||
>
|
||||
Edit rules
|
||||
</button>
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* InfrastructureStorage.test.tsx — render lock-in for the Storage tab.
|
||||
* CloudStorage.test.tsx — render lock-in for the Storage tab.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Empty state.
|
||||
@ -21,7 +21,7 @@ import {
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { CloudPage } from './CloudPage'
|
||||
import { InfrastructureStorage } from './InfrastructureStorage'
|
||||
import { CloudStorage } from './CloudStorage'
|
||||
import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture'
|
||||
import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
@ -44,7 +44,7 @@ function renderStoragePage(data: HierarchicalInfrastructure) {
|
||||
const storageRoute = createRoute({
|
||||
getParentRoute: () => cloudRoute,
|
||||
path: '/storage',
|
||||
component: InfrastructureStorage,
|
||||
component: CloudStorage,
|
||||
})
|
||||
const tree = rootRoute.addChildren([cloudRoute.addChildren([storageRoute])])
|
||||
const router = createRouter({
|
||||
@ -65,7 +65,7 @@ function renderStoragePage(data: HierarchicalInfrastructure) {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('InfrastructureStorage — empty', () => {
|
||||
describe('CloudStorage — empty', () => {
|
||||
it('renders the empty state when no PVCs / buckets / volumes exist', async () => {
|
||||
const empty: HierarchicalInfrastructure = {
|
||||
cloud: [],
|
||||
@ -73,32 +73,32 @@ describe('InfrastructureStorage — empty', () => {
|
||||
storage: { pvcs: [], buckets: [], volumes: [] },
|
||||
}
|
||||
renderStoragePage(empty)
|
||||
expect(await screen.findByTestId('infrastructure-storage-empty')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-storage-empty')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InfrastructureStorage — populated', () => {
|
||||
describe('CloudStorage — populated', () => {
|
||||
it('renders PVC, bucket and volume tables with counts', async () => {
|
||||
renderStoragePage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-pvcs-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-buckets-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-volumes-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-pvcs-count').textContent).toBe('2')
|
||||
expect(screen.getByTestId('infrastructure-buckets-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('infrastructure-volumes-count').textContent).toBe('1')
|
||||
expect(await screen.findByTestId('cloud-pvcs-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-buckets-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-volumes-table')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-pvcs-count').textContent).toBe('2')
|
||||
expect(screen.getByTestId('cloud-buckets-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('cloud-volumes-count').textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('renders the bulk-actions strip', async () => {
|
||||
renderStoragePage(infrastructureTopologyFixture)
|
||||
expect(await screen.findByTestId('infrastructure-storage-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-storage-bulk-snapshot')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-storage-bulk-expand')).toBeTruthy()
|
||||
expect(screen.getByTestId('infrastructure-storage-bulk-delete')).toBeTruthy()
|
||||
expect(await screen.findByTestId('cloud-storage-bulk')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-storage-bulk-snapshot')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-storage-bulk-expand')).toBeTruthy()
|
||||
expect(screen.getByTestId('cloud-storage-bulk-delete')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens ExpandPVCModal on row-level Expand', async () => {
|
||||
renderStoragePage(infrastructureTopologyFixture)
|
||||
fireEvent.click(await screen.findByTestId('infrastructure-pvc-row-pvc-postgres-data-expand'))
|
||||
fireEvent.click(await screen.findByTestId('cloud-pvc-row-pvc-postgres-data-expand'))
|
||||
expect(screen.getByTestId('infrastructure-modal-expand-pvc')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,10 @@
|
||||
/**
|
||||
* InfrastructureStorage — Storage tab. Flat table [PVC · Bucket ·
|
||||
* Volume], reads off the shared infrastructure tree.
|
||||
* CloudStorage — Sovereign Cloud / Storage sub-page. Flat tables for
|
||||
* PVCs, object buckets and block volumes; reads off the shared
|
||||
* infrastructure tree provided by CloudPage.
|
||||
*
|
||||
* Per founder spec (issue #228): "Storage — flat table [PVC · Bucket
|
||||
* · Volume]. Bulk: snapshot, expand, delete."
|
||||
* Per founder spec (issue #309 supersedes #228): bulk actions cover
|
||||
* snapshot, expand, delete.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -13,7 +14,7 @@ import { ModalShell, FormRow, TextInput } from '@/components/CrudModals/_shared'
|
||||
import { pvcAction } from '@/lib/infrastructure-crud'
|
||||
import type { PVCItem } from '@/lib/infrastructure.types'
|
||||
|
||||
export function InfrastructureStorage() {
|
||||
export function CloudStorage() {
|
||||
const { deploymentId, data, isLoading } = useCloud()
|
||||
|
||||
const { pvcs, buckets, volumes } = useMemo(() => {
|
||||
@ -44,15 +45,15 @@ export function InfrastructureStorage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="infrastructure-storage">
|
||||
<div data-testid="cloud-storage">
|
||||
{isLoading && (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="infrastructure-storage-loading">
|
||||
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="cloud-storage-loading">
|
||||
Loading storage resources…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="infra-empty" data-testid="infrastructure-storage-empty">
|
||||
<div className="infra-empty" data-testid="cloud-storage-empty">
|
||||
<p className="title">No storage resources yet.</p>
|
||||
<p className="sub">PVCs, buckets and volumes will appear here as the cluster reports them.</p>
|
||||
</div>
|
||||
@ -60,11 +61,11 @@ export function InfrastructureStorage() {
|
||||
|
||||
{!isEmpty && (
|
||||
<>
|
||||
<div className="infra-bulk-actions" data-testid="infrastructure-storage-bulk">
|
||||
<div className="infra-bulk-actions" data-testid="cloud-storage-bulk">
|
||||
<span className="label">Bulk · {selected.length} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-storage-bulk-snapshot"
|
||||
data-testid="cloud-storage-bulk-snapshot"
|
||||
disabled={selected.filter((s) => s.kind === 'pvc').length === 0}
|
||||
onClick={() => {
|
||||
const pick = selected.find((s) => s.kind === 'pvc')
|
||||
@ -77,7 +78,7 @@ export function InfrastructureStorage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-storage-bulk-expand"
|
||||
data-testid="cloud-storage-bulk-expand"
|
||||
disabled={selected.filter((s) => s.kind === 'pvc').length !== 1}
|
||||
onClick={() => {
|
||||
const pick = selected.find((s) => s.kind === 'pvc')
|
||||
@ -90,7 +91,7 @@ export function InfrastructureStorage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="infrastructure-storage-bulk-delete"
|
||||
data-testid="cloud-storage-bulk-delete"
|
||||
disabled={selected.length !== 1}
|
||||
onClick={() => {
|
||||
const pick = selected[0]
|
||||
@ -108,22 +109,22 @@ export function InfrastructureStorage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-pvcs-section">
|
||||
<section className="infra-section" data-testid="cloud-pvcs-section">
|
||||
<h2>
|
||||
Persistent Volume Claims <span className="count" data-testid="infrastructure-pvcs-count">{pvcs.length}</span>
|
||||
Persistent Volume Claims <span className="count" data-testid="cloud-pvcs-count">{pvcs.length}</span>
|
||||
</h2>
|
||||
<FlatTable
|
||||
testId="infrastructure-pvcs-table"
|
||||
testId="cloud-pvcs-table"
|
||||
headers={['', 'Name', 'Namespace', 'Capacity', 'Used', 'Class', 'Status', '']}
|
||||
>
|
||||
{pvcs.map((p) => (
|
||||
<tr key={p.id} data-testid={`infrastructure-pvc-row-${p.id}`}>
|
||||
<tr key={p.id} data-testid={`cloud-pvc-row-${p.id}`}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.some((s) => s.kind === 'pvc' && s.id === p.id)}
|
||||
onChange={(e) => toggle('pvc', p.id, e.target.checked)}
|
||||
data-testid={`infrastructure-pvc-row-${p.id}-select`}
|
||||
data-testid={`cloud-pvc-row-${p.id}-select`}
|
||||
/>
|
||||
</td>
|
||||
<td>{p.name}</td>
|
||||
@ -138,7 +139,7 @@ export function InfrastructureStorage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandPvc(p)}
|
||||
data-testid={`infrastructure-pvc-row-${p.id}-expand`}
|
||||
data-testid={`cloud-pvc-row-${p.id}-expand`}
|
||||
style={rowBtn}
|
||||
>
|
||||
Expand
|
||||
@ -146,7 +147,7 @@ export function InfrastructureStorage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void pvcAction({ deploymentId, pvcId: p.id, action: 'snapshot' }).catch(() => {})}
|
||||
data-testid={`infrastructure-pvc-row-${p.id}-snapshot`}
|
||||
data-testid={`cloud-pvc-row-${p.id}-snapshot`}
|
||||
style={rowBtn}
|
||||
>
|
||||
Snapshot
|
||||
@ -157,22 +158,22 @@ export function InfrastructureStorage() {
|
||||
</FlatTable>
|
||||
</section>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-buckets-section">
|
||||
<section className="infra-section" data-testid="cloud-buckets-section">
|
||||
<h2>
|
||||
Object Buckets <span className="count" data-testid="infrastructure-buckets-count">{buckets.length}</span>
|
||||
Object Buckets <span className="count" data-testid="cloud-buckets-count">{buckets.length}</span>
|
||||
</h2>
|
||||
<FlatTable
|
||||
testId="infrastructure-buckets-table"
|
||||
testId="cloud-buckets-table"
|
||||
headers={['', 'Name', 'Endpoint', 'Capacity', 'Used', 'Retention']}
|
||||
>
|
||||
{buckets.map((b) => (
|
||||
<tr key={b.id} data-testid={`infrastructure-bucket-row-${b.id}`}>
|
||||
<tr key={b.id} data-testid={`cloud-bucket-row-${b.id}`}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.some((s) => s.kind === 'bucket' && s.id === b.id)}
|
||||
onChange={(e) => toggle('bucket', b.id, e.target.checked)}
|
||||
data-testid={`infrastructure-bucket-row-${b.id}-select`}
|
||||
data-testid={`cloud-bucket-row-${b.id}-select`}
|
||||
/>
|
||||
</td>
|
||||
<td>{b.name}</td>
|
||||
@ -185,22 +186,22 @@ export function InfrastructureStorage() {
|
||||
</FlatTable>
|
||||
</section>
|
||||
|
||||
<section className="infra-section" data-testid="infrastructure-volumes-section">
|
||||
<section className="infra-section" data-testid="cloud-volumes-section">
|
||||
<h2>
|
||||
Block Volumes <span className="count" data-testid="infrastructure-volumes-count">{volumes.length}</span>
|
||||
Block Volumes <span className="count" data-testid="cloud-volumes-count">{volumes.length}</span>
|
||||
</h2>
|
||||
<FlatTable
|
||||
testId="infrastructure-volumes-table"
|
||||
testId="cloud-volumes-table"
|
||||
headers={['', 'Name', 'Capacity', 'Region', 'Attached', 'Status']}
|
||||
>
|
||||
{volumes.map((v) => (
|
||||
<tr key={v.id} data-testid={`infrastructure-volume-row-${v.id}`}>
|
||||
<tr key={v.id} data-testid={`cloud-volume-row-${v.id}`}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.some((s) => s.kind === 'volume' && s.id === v.id)}
|
||||
onChange={(e) => toggle('volume', v.id, e.target.checked)}
|
||||
data-testid={`infrastructure-volume-row-${v.id}-select`}
|
||||
data-testid={`cloud-volume-row-${v.id}-select`}
|
||||
/>
|
||||
</td>
|
||||
<td>{v.name}</td>
|
||||
Loading…
Reference in New Issue
Block a user