diff --git a/products/catalyst/bootstrap/ui/src/app/router.tsx b/products/catalyst/bootstrap/ui/src/app/router.tsx index 48c230ee..515afc6c 100644 --- a/products/catalyst/bootstrap/ui/src/app/router.tsx +++ b/products/catalyst/bootstrap/ui/src/app/router.tsx @@ -26,9 +26,24 @@ import { Dashboard } from '@/pages/sovereign/Dashboard' import { BatchDetail } from '@/pages/sovereign/BatchDetail' import { CloudPage } from '@/pages/sovereign/CloudPage' import { Architecture } from '@/pages/sovereign/Architecture' -import { CloudCompute } from '@/pages/sovereign/CloudCompute' -import { CloudStorage } from '@/pages/sovereign/CloudStorage' -import { CloudNetwork } from '@/pages/sovereign/CloudNetwork' +// Cloud category landing pages (P3 of #309) — replace the previous +// flat-dump CloudCompute / CloudNetwork / CloudStorage components. +import { CloudComputePage } from '@/pages/sovereign/cloud-compute/CloudComputePage' +import { CloudNetworkPage } from '@/pages/sovereign/cloud-network/CloudNetworkPage' +import { CloudStoragePage } from '@/pages/sovereign/cloud-storage/CloudStoragePage' +// Cloud per-resource list pages (P3 of #309). +import { ClustersPage } from '@/pages/sovereign/cloud-compute/ClustersPage' +import { VClustersPage } from '@/pages/sovereign/cloud-compute/VClustersPage' +import { NodePoolsPage } from '@/pages/sovereign/cloud-compute/NodePoolsPage' +import { WorkerNodesPage } from '@/pages/sovereign/cloud-compute/WorkerNodesPage' +import { ServicesPage } from '@/pages/sovereign/cloud-network/ServicesPage' +import { IngressesPage } from '@/pages/sovereign/cloud-network/IngressesPage' +import { LoadBalancersPage } from '@/pages/sovereign/cloud-network/LoadBalancersPage' +import { DnsZonesPage } from '@/pages/sovereign/cloud-network/DnsZonesPage' +import { PvcsPage } from '@/pages/sovereign/cloud-storage/PvcsPage' +import { StorageClassesPage } from '@/pages/sovereign/cloud-storage/StorageClassesPage' +import { BucketsPage } from '@/pages/sovereign/cloud-storage/BucketsPage' +import { VolumesPage } from '@/pages/sovereign/cloud-storage/VolumesPage' // Root const rootRoute = createRootRoute({ component: RootLayout }) @@ -169,19 +184,84 @@ const provisionCloudArchitectureRoute = createRoute({ const provisionCloudComputeRoute = createRoute({ getParentRoute: () => provisionCloudRoute, path: '/compute', - component: CloudCompute, + component: CloudComputePage, }) const provisionCloudStorageRoute = createRoute({ getParentRoute: () => provisionCloudRoute, path: '/storage', - component: CloudStorage, + component: CloudStoragePage, }) const provisionCloudNetworkRoute = createRoute({ getParentRoute: () => provisionCloudRoute, path: '/network', - component: CloudNetwork, + component: CloudNetworkPage, +}) + +/* ── P3: per-resource list pages (under /cloud//) ── */ + +const provisionCloudClustersRoute = createRoute({ + getParentRoute: () => provisionCloudComputeRoute, + path: '/clusters', + component: ClustersPage, +}) +const provisionCloudVClustersRoute = createRoute({ + getParentRoute: () => provisionCloudComputeRoute, + path: '/vclusters', + component: VClustersPage, +}) +const provisionCloudNodePoolsRoute = createRoute({ + getParentRoute: () => provisionCloudComputeRoute, + path: '/node-pools', + component: NodePoolsPage, +}) +const provisionCloudWorkerNodesRoute = createRoute({ + getParentRoute: () => provisionCloudComputeRoute, + path: '/worker-nodes', + component: WorkerNodesPage, +}) + +const provisionCloudServicesRoute = createRoute({ + getParentRoute: () => provisionCloudNetworkRoute, + path: '/services', + component: ServicesPage, +}) +const provisionCloudIngressesRoute = createRoute({ + getParentRoute: () => provisionCloudNetworkRoute, + path: '/ingresses', + component: IngressesPage, +}) +const provisionCloudLBsRoute = createRoute({ + getParentRoute: () => provisionCloudNetworkRoute, + path: '/load-balancers', + component: LoadBalancersPage, +}) +const provisionCloudDnsZonesRoute = createRoute({ + getParentRoute: () => provisionCloudNetworkRoute, + path: '/dns-zones', + component: DnsZonesPage, +}) + +const provisionCloudPvcsRoute = createRoute({ + getParentRoute: () => provisionCloudStorageRoute, + path: '/pvcs', + component: PvcsPage, +}) +const provisionCloudStorageClassesRoute = createRoute({ + getParentRoute: () => provisionCloudStorageRoute, + path: '/storage-classes', + component: StorageClassesPage, +}) +const provisionCloudBucketsRoute = createRoute({ + getParentRoute: () => provisionCloudStorageRoute, + path: '/buckets', + component: BucketsPage, +}) +const provisionCloudVolumesRoute = createRoute({ + getParentRoute: () => provisionCloudStorageRoute, + path: '/volumes', + component: VolumesPage, }) // Legacy /infrastructure/* — every legacy path now redirects to its @@ -320,9 +400,24 @@ const routeTree = rootRoute.addChildren([ provisionCloudRoute.addChildren([ provisionCloudIndexRoute, provisionCloudArchitectureRoute, - provisionCloudComputeRoute, - provisionCloudStorageRoute, - provisionCloudNetworkRoute, + provisionCloudComputeRoute.addChildren([ + provisionCloudClustersRoute, + provisionCloudVClustersRoute, + provisionCloudNodePoolsRoute, + provisionCloudWorkerNodesRoute, + ]), + provisionCloudStorageRoute.addChildren([ + provisionCloudPvcsRoute, + provisionCloudStorageClassesRoute, + provisionCloudBucketsRoute, + provisionCloudVolumesRoute, + ]), + provisionCloudNetworkRoute.addChildren([ + provisionCloudServicesRoute, + provisionCloudIngressesRoute, + provisionCloudLBsRoute, + provisionCloudDnsZonesRoute, + ]), ]), provisionInfrastructureRoute.addChildren([ provisionInfrastructureIndexRoute, diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.test.tsx deleted file mode 100644 index aa6273a8..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * CloudCompute.test.tsx — render lock-in for the Compute tab. - * - * Coverage: - * 1. Empty state shows when the tree has no clusters / nodes. - * 2. Pool + Node tables render with counts and rows. - * 3. Bulk-action strip is present. - * 4. Row-level Scale opens the ScalePoolModal. - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { render, screen, cleanup, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - RouterProvider, - createRouter, - createRootRoute, - createRoute, - createMemoryHistory, - Outlet, -} from '@tanstack/react-router' - -import { CloudPage } from './CloudPage' -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' -import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' - -function renderComputePage(data: HierarchicalInfrastructure) { - useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) - globalThis.fetch = (() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ events: [], state: undefined, done: false }), - } as unknown as Response)) as typeof fetch - - const rootRoute = createRootRoute({ component: () => }) - const cloudRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/provision/$deploymentId/cloud', - component: () => , - }) - const computeRoute = createRoute({ - getParentRoute: () => cloudRoute, - path: '/compute', - component: CloudCompute, - }) - const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute])]) - const router = createRouter({ - routeTree: tree, - history: createMemoryHistory({ - initialEntries: ['/provision/d-1/cloud/compute'], - }), - }) - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }) - return render( - - - , - ) -} - -afterEach(() => cleanup()) - -describe('CloudCompute — empty', () => { - it('renders the empty state when there are no clusters or nodes', async () => { - const empty: HierarchicalInfrastructure = { - cloud: [], - topology: { pattern: 'solo', regions: [] }, - storage: { pvcs: [], buckets: [], volumes: [] }, - } - renderComputePage(empty) - expect(await screen.findByTestId('cloud-compute-empty')).toBeTruthy() - }) -}) - -describe('CloudCompute — populated', () => { - it('renders the Pools and Nodes tables', async () => { - renderComputePage(infrastructureTopologyFixture) - 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('cloud-nodes-count').textContent).toBe('6') - }) - - it('renders the bulk-actions strip', async () => { - renderComputePage(infrastructureTopologyFixture) - 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('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('cloud-node-row-node-eu-w-0-drain')) - expect(screen.getByTestId('infrastructure-modal-node-drain')).toBeTruthy() - }) -}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.tsx deleted file mode 100644 index 020c2f99..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudCompute.tsx +++ /dev/null @@ -1,392 +0,0 @@ -/** - * 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 #309 supersedes #228): each row links back - * to the Architecture canvas. Bulk actions: scale, drain. - */ - -import { useMemo, useState } from 'react' -import { Link } from '@tanstack/react-router' -import { useCloud } from './CloudPage' -import { - ScalePoolModal, - ChangeSKUModal, - AddNodePoolModal, - NodeActionConfirm, -} from '@/components/CrudModals' -import type { ClusterSpec, NodePoolSpec, NodeSpec, RegionSpec } from '@/lib/infrastructure.types' -import type { CloudProvider } from '@/entities/deployment/model' - -interface PoolRow { - pool: NodePoolSpec - cluster: ClusterSpec - region: RegionSpec -} - -interface NodeRow { - node: NodeSpec - cluster: ClusterSpec - region: RegionSpec -} - -export function CloudCompute() { - const { deploymentId, data, isLoading } = useCloud() - - const { pools, nodes } = useMemo(() => { - const pools: PoolRow[] = [] - const nodes: NodeRow[] = [] - if (!data) return { pools, nodes } - for (const region of data.topology.regions ?? []) { - for (const cluster of region.clusters ?? []) { - for (const pool of cluster.nodePools ?? []) pools.push({ pool, cluster, region }) - for (const node of cluster.nodes ?? []) nodes.push({ node, cluster, region }) - } - } - return { pools, nodes } - }, [data]) - - // Bulk-action selection state (operator picks rows + clicks an - // action in the bulk strip). - const [selectedPools, setSelectedPools] = useState([]) - const [selectedNodes, setSelectedNodes] = useState([]) - - const [scalePool, setScalePool] = useState(null) - const [changeSku, setChangeSku] = useState(null) - const [drainNode, setDrainNode] = useState(null) - const [addPoolFor, setAddPoolFor] = useState<{ cluster: ClusterSpec; provider: CloudProvider } | null>(null) - - const isEmpty = !isLoading && pools.length === 0 && nodes.length === 0 - - return ( -
- {isLoading && ( -
- Loading compute resources… -
- )} - - {isEmpty && ( -
-

No clusters or worker nodes yet.

-

- Once the Sovereign cluster comes up, every k3s cluster and node VM - will appear here. -

-
- )} - - {!isEmpty && data && ( - <> -
- Bulk · {selectedPools.length} pool{selectedPools.length === 1 ? '' : 's'} / {selectedNodes.length} node{selectedNodes.length === 1 ? '' : 's'} - - -
- -
-

- Node Pools {pools.length} -

- - {pools.map(({ pool, cluster, region }) => ( - - - toggle(e.target.checked, pool.id, setSelectedPools)} - data-testid={`cloud-pool-row-${pool.id}-select`} - /> - - - - {cluster.name} - -
- {region.providerRegion} -
- - {pool.id} - {pool.sku} - {pool.replicas} - - - - - - - - - ))} - {pools.length === 0 && ( - - - No pools reported. - - - )} -
- - {/* Per-cluster Add Pool buttons */} -
- {data.topology.regions.flatMap((region) => - region.clusters.map((cluster) => ( - - )), - )} -
-
- -
-

- Worker Nodes {nodes.length} -

- - {nodes.map(({ node, cluster }) => ( - - - toggle(e.target.checked, node.id, setSelectedNodes)} - data-testid={`cloud-node-row-${node.id}-select`} - /> - - {cluster.name} - {node.name} - {node.sku} - {node.role} - {node.ip} - - - - - - - - ))} - {nodes.length === 0 && ( - - - No nodes reported. - - - )} - -
- - )} - - {scalePool && ( - setScalePool(null)} - /> - )} - {changeSku && ( - setChangeSku(null)} - /> - )} - {drainNode && ( - setDrainNode(null)} - /> - )} - {addPoolFor && ( - setAddPoolFor(null)} - /> - )} -
- ) -} - -function toggle(checked: boolean, id: string, setter: React.Dispatch>) { - setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))) -} - -function FlatTable({ - testId, - headers, - children, -}: { - testId: string - headers: string[] - children: React.ReactNode -}) { - return ( - - - - {headers.map((h, i) => ( - - ))} - - - {children} - -
- {h} -
- ) -} - -function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) { - return ( - - {status} - - ) -} - -const rowBtn: React.CSSProperties = { - border: '1px solid var(--color-border)', - background: 'transparent', - color: 'var(--color-text)', - padding: '3px 8px', - borderRadius: 5, - fontSize: '0.72rem', - cursor: 'pointer', -} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.test.tsx deleted file mode 100644 index 5978fcfb..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * CloudNetwork.test.tsx — render lock-in for the Network tab. - * - * Coverage: - * 1. Empty state. - * 2. LB / Peering / Firewall tables with counts. - * 3. Bulk-actions strip. - * 4. Per-region Add LB triggers AddLBModal. - * 5. Add Peering button opens AddPeeringModal. - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { render, screen, cleanup, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - RouterProvider, - createRouter, - createRootRoute, - createRoute, - createMemoryHistory, - Outlet, -} from '@tanstack/react-router' - -import { CloudPage } from './CloudPage' -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' -import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' - -function renderNetworkPage(data: HierarchicalInfrastructure) { - useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) - globalThis.fetch = (() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ events: [], state: undefined, done: false }), - } as unknown as Response)) as typeof fetch - - const rootRoute = createRootRoute({ component: () => }) - const cloudRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/provision/$deploymentId/cloud', - component: () => , - }) - const networkRoute = createRoute({ - getParentRoute: () => cloudRoute, - path: '/network', - component: CloudNetwork, - }) - const tree = rootRoute.addChildren([cloudRoute.addChildren([networkRoute])]) - const router = createRouter({ - routeTree: tree, - history: createMemoryHistory({ - initialEntries: ['/provision/d-1/cloud/network'], - }), - }) - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }) - return render( - - - , - ) -} - -afterEach(() => cleanup()) - -describe('CloudNetwork — empty', () => { - it('renders the empty state when no LBs / peerings / firewalls exist', async () => { - const empty: HierarchicalInfrastructure = { - cloud: [], - topology: { pattern: 'solo', regions: [] }, - storage: { pvcs: [], buckets: [], volumes: [] }, - } - renderNetworkPage(empty) - expect(await screen.findByTestId('cloud-network-empty')).toBeTruthy() - }) -}) - -describe('CloudNetwork — populated', () => { - it('renders LB / Peering / Firewall tables with counts', async () => { - renderNetworkPage(infrastructureTopologyFixture) - 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('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('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('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('cloud-firewall-row-fw-eu-central-edit')) - expect(screen.getByTestId('infrastructure-modal-edit-firewall-rules')).toBeTruthy() - }) -}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.tsx deleted file mode 100644 index c1ae2545..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudNetwork.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * 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 #309 supersedes #228): bulk actions cover - * add rule + attach. - */ - -import { useMemo, useState } from 'react' -import { useCloud } from './CloudPage' -import { - AddLBModal, - AddPeeringModal, - EditFirewallRulesModal, - EditDNSRecordsModal, -} from '@/components/CrudModals' -import type { - FirewallSpec, - LoadBalancerSpec, - NetworkSpec, - PeeringSpec, - RegionSpec, -} from '@/lib/infrastructure.types' - -interface LBRow { - lb: LoadBalancerSpec - region: RegionSpec - clusterId: string -} - -interface PeeringRow { - peering: PeeringSpec - region: RegionSpec -} - -interface FirewallRow { - firewall: FirewallSpec - region: RegionSpec -} - -export function CloudNetwork() { - const { deploymentId, data, isLoading } = useCloud() - - const { lbs, peerings, firewalls, networks } = useMemo(() => { - const lbs: LBRow[] = [] - const peerings: PeeringRow[] = [] - const firewalls: FirewallRow[] = [] - const networks: NetworkSpec[] = [] - if (!data) return { lbs, peerings, firewalls, networks } - for (const region of data.topology.regions ?? []) { - for (const cluster of region.clusters ?? []) { - for (const lb of cluster.loadBalancers ?? []) { - lbs.push({ - lb: { ...lb, listeners: lb.listeners ?? [], targets: lb.targets ?? [] }, - region, - clusterId: cluster.id, - }) - } - } - for (const net of region.networks ?? []) { - networks.push(net) - for (const p of net.peerings ?? []) peerings.push({ peering: p, region }) - for (const f of net.firewalls ?? []) firewalls.push({ firewall: f, region }) - } - } - return { lbs, peerings, firewalls, networks } - }, [data]) - - const [addLBFor, setAddLBFor] = useState(null) - const [addPeeringOpen, setAddPeeringOpen] = useState(false) - const [editFirewall, setEditFirewall] = useState(null) - const [editDNS, setEditDNS] = useState(null) - - const isEmpty = - !isLoading && lbs.length === 0 && peerings.length === 0 && firewalls.length === 0 - - return ( -
- {isLoading && ( -
- Loading network resources… -
- )} - - {isEmpty && ( -
-

No network resources yet.

-

Load balancers, peerings, firewalls and DNS zones will appear here.

-
- )} - - {!isEmpty && data && ( - <> -
- Bulk actions - - -
- -
-

- Load Balancers {lbs.length} -

- - {lbs.map(({ lb, region }) => ( - - {lb.name} - {lb.publicIP} - {lb.listeners.map((l) => `${l.protocol}:${l.port}`).join(', ')} - {`${lb.targets.filter((t) => t.status === 'healthy').length}/${lb.targets.length}`} - {region.providerRegion} - - - - - - ))} - -
- {data.topology.regions.map((r) => ( - - ))} -
-
- -
-

- Peerings {peerings.length} -

- - {peerings.map(({ peering, region }) => ( - - {peering.name} - {peering.vpcPair} - {peering.subnets} - {region.providerRegion} - - - - - ))} - {peerings.length === 0 && ( - - - No peerings yet. - - - )} - -
- -
-

- Firewalls {firewalls.length} -

- - {firewalls.map(({ firewall, region }) => ( - - {firewall.name} - {firewall.rules.length} - {region.providerRegion} - - - - - - - - ))} - {firewalls.length === 0 && ( - - - No firewalls yet. - - - )} - -
- - )} - - {addLBFor && ( - setAddLBFor(null)} - /> - )} - {addPeeringOpen && ( - setAddPeeringOpen(false)} - /> - )} - {editFirewall && ( - setEditFirewall(null)} - /> - )} - {editDNS && ( - setEditDNS(null)} - /> - )} -
- ) -} - -function FlatTable({ testId, headers, children }: { testId: string; headers: string[]; children: React.ReactNode }) { - return ( - - - - {headers.map((h, i) => ( - - ))} - - - {children} - -
- {h} -
- ) -} - -function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) { - return ( - - {status} - - ) -} - -const rowBtn: React.CSSProperties = { - border: '1px solid var(--color-border)', - background: 'transparent', - color: 'var(--color-text)', - padding: '3px 8px', - borderRadius: 5, - fontSize: '0.72rem', - cursor: 'pointer', -} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudPage.tsx index c8323529..3bf7e990 100644 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudPage.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudPage.tsx @@ -239,11 +239,20 @@ export function CloudPage({ function handleSwitch(nextId: string) { if (nextId === deploymentId) return // Preserve the current sub-page when switching Sovereigns: if the - // operator is on /cloud/compute, keep them on compute under the - // new deployment. Falls back to /architecture otherwise. - const suffixMatch = pathname.match(/\/(architecture|compute|storage|network|topology)$/) - const suffix = - suffixMatch && suffixMatch[1] !== 'topology' ? suffixMatch[1] : 'architecture' + // operator is on /cloud/compute/clusters, keep them on the same + // sub-route under the new deployment. Falls back to /architecture + // otherwise. Captures both the category and any deeper resource + // segment (P3 of #309). + const cloudIdx = pathname.indexOf('/cloud/') + let suffix = 'architecture' + if (cloudIdx >= 0) { + const tail = pathname.slice(cloudIdx + '/cloud/'.length).replace(/\/$/, '') + // Whitelist: only preserve known suffixes. Anything else (legacy + // /topology, malformed, …) collapses to /architecture. + if (tail.length > 0 && /^[a-z][a-z-]*(\/[a-z][a-z-]*)?$/.test(tail) && tail !== 'topology') { + suffix = tail + } + } navigate({ to: `/provision/$deploymentId/cloud/${suffix}` as never, params: { deploymentId: nextId } as never, diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.test.tsx deleted file mode 100644 index 9831c1b1..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CloudStorage.test.tsx — render lock-in for the Storage tab. - * - * Coverage: - * 1. Empty state. - * 2. PVCs / Buckets / Volumes tables with counts. - * 3. Bulk actions strip. - * 4. Row-level Expand opens the ExpandPVCModal. - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { render, screen, cleanup, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - RouterProvider, - createRouter, - createRootRoute, - createRoute, - createMemoryHistory, - Outlet, -} from '@tanstack/react-router' - -import { CloudPage } from './CloudPage' -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' -import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' - -function renderStoragePage(data: HierarchicalInfrastructure) { - useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) - globalThis.fetch = (() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ events: [], state: undefined, done: false }), - } as unknown as Response)) as typeof fetch - - const rootRoute = createRootRoute({ component: () => }) - const cloudRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/provision/$deploymentId/cloud', - component: () => , - }) - const storageRoute = createRoute({ - getParentRoute: () => cloudRoute, - path: '/storage', - component: CloudStorage, - }) - const tree = rootRoute.addChildren([cloudRoute.addChildren([storageRoute])]) - const router = createRouter({ - routeTree: tree, - history: createMemoryHistory({ - initialEntries: ['/provision/d-1/cloud/storage'], - }), - }) - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }) - return render( - - - , - ) -} - -afterEach(() => cleanup()) - -describe('CloudStorage — empty', () => { - it('renders the empty state when no PVCs / buckets / volumes exist', async () => { - const empty: HierarchicalInfrastructure = { - cloud: [], - topology: { pattern: 'solo', regions: [] }, - storage: { pvcs: [], buckets: [], volumes: [] }, - } - renderStoragePage(empty) - expect(await screen.findByTestId('cloud-storage-empty')).toBeTruthy() - }) -}) - -describe('CloudStorage — populated', () => { - it('renders PVC, bucket and volume tables with counts', async () => { - renderStoragePage(infrastructureTopologyFixture) - 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('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('cloud-pvc-row-pvc-postgres-data-expand')) - expect(screen.getByTestId('infrastructure-modal-expand-pvc')).toBeTruthy() - }) -}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.tsx deleted file mode 100644 index 57d4fc59..00000000 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/CloudStorage.tsx +++ /dev/null @@ -1,325 +0,0 @@ -/** - * 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 #309 supersedes #228): bulk actions cover - * snapshot, expand, delete. - */ - -import { useMemo, useState } from 'react' -import { useCloud } from './CloudPage' -import { DeleteCascadeConfirm } from '@/components/CrudModals' -import { ModalShell, FormRow, TextInput } from '@/components/CrudModals/_shared' -import { pvcAction } from '@/lib/infrastructure-crud' -import type { PVCItem } from '@/lib/infrastructure.types' - -export function CloudStorage() { - const { deploymentId, data, isLoading } = useCloud() - - const { pvcs, buckets, volumes } = useMemo(() => { - if (!data) return { pvcs: [], buckets: [], volumes: [] } - return { - pvcs: data.storage?.pvcs ?? [], - buckets: data.storage?.buckets ?? [], - volumes: data.storage?.volumes ?? [], - } - }, [data]) - - const [selected, setSelected] = useState<{ kind: string; id: string }[]>([]) - const [expandPvc, setExpandPvc] = useState(null) - const [deleteRow, setDeleteRow] = useState<{ - resource: 'pvcs' | 'volumes' | 'buckets' - id: string - label: string - } | null>(null) - - const isEmpty = !isLoading && pvcs.length === 0 && buckets.length === 0 && volumes.length === 0 - - function toggle(kind: string, id: string, checked: boolean) { - setSelected((prev) => - checked - ? [...prev, { kind, id }] - : prev.filter((s) => !(s.kind === kind && s.id === id)), - ) - } - - return ( -
- {isLoading && ( -
- Loading storage resources… -
- )} - - {isEmpty && ( -
-

No storage resources yet.

-

PVCs, buckets and volumes will appear here as the cluster reports them.

-
- )} - - {!isEmpty && ( - <> -
- Bulk · {selected.length} selected - - - -
- -
-

- Persistent Volume Claims {pvcs.length} -

- - {pvcs.map((p) => ( - - - s.kind === 'pvc' && s.id === p.id)} - onChange={(e) => toggle('pvc', p.id, e.target.checked)} - data-testid={`cloud-pvc-row-${p.id}-select`} - /> - - {p.name} - {p.namespace} - {p.capacity} - {p.used || '—'} - {p.storageClass} - - - - - - - - - ))} - -
- -
-

- Object Buckets {buckets.length} -

- - {buckets.map((b) => ( - - - s.kind === 'bucket' && s.id === b.id)} - onChange={(e) => toggle('bucket', b.id, e.target.checked)} - data-testid={`cloud-bucket-row-${b.id}-select`} - /> - - {b.name} - {b.endpoint} - {b.capacity} - {b.used || '—'} - {b.retentionDays || 'indefinite'} - - ))} - -
- -
-

- Block Volumes {volumes.length} -

- - {volumes.map((v) => ( - - - s.kind === 'volume' && s.id === v.id)} - onChange={(e) => toggle('volume', v.id, e.target.checked)} - data-testid={`cloud-volume-row-${v.id}-select`} - /> - - {v.name} - {v.capacity} - {v.region} - {v.attachedTo || 'detached'} - - - - - ))} - -
- - )} - - {expandPvc && ( - setExpandPvc(null)} - /> - )} - {deleteRow && ( - setDeleteRow(null)} - /> - )} -
- ) -} - -function ExpandPVCModal({ - deploymentId, - pvc, - onClose, -}: { - deploymentId: string - pvc: PVCItem - onClose: () => void -}) { - const [capacity, setCapacity] = useState(pvc.capacity) - const [submitting, setSubmitting] = useState(false) - return ( - { - setSubmitting(true) - try { - await pvcAction({ deploymentId, pvcId: pvc.id, action: 'expand', newCapacity: capacity }) - onClose() - } catch (err) { - console.error('expand pvc failed', err) - } finally { - setSubmitting(false) - } - }, - loading: submitting, - disabled: !capacity.trim() || capacity === pvc.capacity, - }} - > - - {}} testId="expand-pvc-modal-current" /> - - - - - - ) -} - -function FlatTable({ testId, headers, children }: { testId: string; headers: string[]; children: React.ReactNode }) { - return ( - - - - {headers.map((h, i) => ( - - ))} - - - {children} - -
- {h} -
- ) -} - -function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) { - return ( - - {status} - - ) -} - -const rowBtn: React.CSSProperties = { - border: '1px solid var(--color-border)', - background: 'transparent', - color: 'var(--color-text)', - padding: '3px 8px', - borderRadius: 5, - fontSize: '0.72rem', - cursor: 'pointer', -} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.test.tsx new file mode 100644 index 00000000..6bab17fd --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.test.tsx @@ -0,0 +1,90 @@ +/** + * CloudComputePage.test.tsx — landing page for /cloud/compute (P3 of + * #309). Asserts that the four tiles render with counts derived from + * the fixture topology. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { CloudComputePage } from './CloudComputePage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderLanding(data: HierarchicalInfrastructure) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => , + }) + const computeRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/compute', + component: CloudComputePage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/compute'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('CloudComputePage', () => { + it('renders 4 tiles (clusters / vclusters / node-pools / worker-nodes)', async () => { + renderLanding(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-compute-page-tile-clusters')).toBeTruthy() + expect(screen.getByTestId('cloud-compute-page-tile-vclusters')).toBeTruthy() + expect(screen.getByTestId('cloud-compute-page-tile-node-pools')).toBeTruthy() + expect(screen.getByTestId('cloud-compute-page-tile-worker-nodes')).toBeTruthy() + }) + + it('counts derive from the fixture (2 clusters, 4 vclusters, 3 node-pools, 6 worker nodes)', async () => { + renderLanding(infrastructureTopologyFixture) + expect((await screen.findByTestId('cloud-compute-page-tile-clusters-count')).textContent).toBe('2') + expect(screen.getByTestId('cloud-compute-page-tile-vclusters-count').textContent).toBe('4') + expect(screen.getByTestId('cloud-compute-page-tile-node-pools-count').textContent).toBe('3') + expect(screen.getByTestId('cloud-compute-page-tile-worker-nodes-count').textContent).toBe('6') + }) + + it('each tile is a Link to the per-resource list page', async () => { + renderLanding(infrastructureTopologyFixture) + const clustersLink = (await screen.findByTestId('cloud-compute-page-tile-clusters')) as HTMLAnchorElement + expect(clustersLink.tagName).toBe('A') + expect(clustersLink.getAttribute('href') ?? '').toMatch(/\/cloud\/compute\/clusters$/) + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.tsx new file mode 100644 index 00000000..40467572 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/CloudComputePage.tsx @@ -0,0 +1,115 @@ +/** + * CloudComputePage — Sovereign Cloud / Compute landing page (P3 of + * issue #309). Replaces the previous flat dump in CloudCompute.tsx. + * + * Renders a tile grid summarising the four resource types in the + * Compute category: Clusters, vClusters, Node Pools, Worker Nodes. + * Each tile is a to the per-resource list page. + */ + +import { useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { useCloud } from '../CloudPage' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' + +interface ComputeTile { + id: 'clusters' | 'vclusters' | 'node-pools' | 'worker-nodes' + label: string + tagline: string +} + +const COMPUTE_TILES: readonly ComputeTile[] = [ + { + id: 'clusters', + label: 'Clusters', + tagline: 'k3s / k8s control planes — one per region', + }, + { + id: 'vclusters', + label: 'vClusters', + tagline: 'Logical isolation per Sovereign tenant', + }, + { + id: 'node-pools', + label: 'Node Pools', + tagline: 'Worker pools grouped by SKU + role', + }, + { + id: 'worker-nodes', + label: 'Worker Nodes', + tagline: 'Individual VMs / kubelets reporting in', + }, +] + +export function CloudComputePage() { + const { deploymentId, data, isLoading } = useCloud() + + // Per-tile counts derived once from the shared infrastructure tree. + const counts = useMemo(() => { + const out: Record = { + 'clusters': 0, + 'vclusters': 0, + 'node-pools': 0, + 'worker-nodes': 0, + } + if (!data) return out + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + out['clusters'] += 1 + out['vclusters'] += cluster.vclusters?.length ?? 0 + out['node-pools'] += cluster.nodePools?.length ?? 0 + out['worker-nodes'] += cluster.nodes?.length ?? 0 + } + } + return out + }, [data]) + + return ( +
+ +
+

+ Compute +

+

+ Clusters, vClusters, node pools and worker nodes for this Sovereign. +

+
+ + {isLoading ? ( +
+ Loading compute resources… +
+ ) : ( +
+ {COMPUTE_TILES.map((tile) => ( + +
+ {tile.label} + + {counts[tile.id]} + +
+

{tile.tagline}

+ + ))} +
+ )} +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.test.tsx new file mode 100644 index 00000000..86d683f8 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.test.tsx @@ -0,0 +1,119 @@ +/** + * ClustersPage.test.tsx — list-page lock-in for /cloud/compute/clusters. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { ClustersPage } from './ClustersPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderClusters(data: HierarchicalInfrastructure) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => , + }) + const computeRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/compute', + component: () => , + }) + const clustersRoute = createRoute({ + getParentRoute: () => computeRoute, + path: '/clusters', + component: ClustersPage, + }) + const tree = rootRoute.addChildren([ + cloudRoute.addChildren([computeRoute.addChildren([clustersRoute])]), + ]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/compute/clusters'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('ClustersPage', () => { + it('renders header + count badge + back link', async () => { + renderClusters(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-clusters-page')).toBeTruthy() + expect(screen.getByTestId('cloud-clusters-title').textContent).toContain('Clusters') + expect(screen.getByTestId('cloud-clusters-count').textContent).toBe('2') + expect(screen.getByTestId('cloud-clusters-back')).toBeTruthy() + }) + + it('renders 2 cluster rows from the fixture', async () => { + renderClusters(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-clusters-row-cluster-eu-central-primary')).toBeTruthy() + expect(screen.getByTestId('cloud-clusters-row-cluster-eu-helsinki-secondary')).toBeTruthy() + }) + + it('clicking a row opens the detail drawer', async () => { + renderClusters(infrastructureTopologyFixture) + fireEvent.click(await screen.findByTestId('cloud-clusters-row-cluster-eu-central-primary')) + expect(screen.getByTestId('cloud-clusters-detail')).toBeTruthy() + expect(screen.getByTestId('cloud-clusters-detail-body').textContent).toContain('omantel-primary') + }) + + it('clicking the close button closes the detail drawer', async () => { + renderClusters(infrastructureTopologyFixture) + fireEvent.click(await screen.findByTestId('cloud-clusters-row-cluster-eu-central-primary')) + expect(screen.getByTestId('cloud-clusters-detail')).toBeTruthy() + fireEvent.click(screen.getByTestId('cloud-clusters-detail-close')) + expect(screen.queryByTestId('cloud-clusters-detail')).toBeNull() + }) + + it('search filters rows', async () => { + renderClusters(infrastructureTopologyFixture) + const search = (await screen.findByTestId('cloud-clusters-search')) as HTMLInputElement + fireEvent.change(search, { target: { value: 'helsinki' } }) + expect(screen.queryByTestId('cloud-clusters-row-cluster-eu-central-primary')).toBeNull() + expect(screen.getByTestId('cloud-clusters-row-cluster-eu-helsinki-secondary')).toBeTruthy() + }) + + it('empty data renders the empty state', async () => { + const empty: HierarchicalInfrastructure = { + cloud: [], + topology: { pattern: 'solo', regions: [] }, + storage: { pvcs: [], buckets: [], volumes: [] }, + } + renderClusters(empty) + expect(await screen.findByTestId('cloud-clusters-empty')).toBeTruthy() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.tsx new file mode 100644 index 00000000..7cc309a7 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/ClustersPage.tsx @@ -0,0 +1,246 @@ +/** + * ClustersPage — list view for /cloud/compute/clusters (P3 of #309). + * + * Pattern parallels JobsPage / JobsTable: header + toolbar + sortable + * table + per-row click → detail drawer. Source data is the shared + * hierarchical infrastructure tree exposed via useCloud(); we flatten + * regions[].clusters[] into one row per cluster with parent region + * carried alongside for the detail drawer. + * + * Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every + * provider / region / status string flows from the shared tree — + * there's no inlined "k3s" or "fsn1". + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { + ClusterSpec, + RegionSpec, + TopologyStatus, +} from '@/lib/infrastructure.types' + +interface ClusterRow { + id: string + cluster: ClusterSpec + region: RegionSpec +} + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] + +const TEST_ID = 'cloud-clusters' + +function flattenClusters( + data: ReturnType['data'], +): ClusterRow[] { + if (!data) return [] + const rows: ClusterRow[] = [] + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + rows.push({ id: cluster.id, cluster, region }) + } + } + return rows +} + +function compareClusters(a: ClusterRow, b: ClusterRow, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + const ca = a.cluster + const cb = b.cluster + switch (sort.column) { + case 'name': + return dir * ca.name.localeCompare(cb.name) + case 'region': + return dir * a.region.providerRegion.localeCompare(b.region.providerRegion) + case 'provider': + return dir * a.region.provider.localeCompare(b.region.provider) + case 'type': + return dir * ca.version.localeCompare(cb.version) + case 'status': + return dir * ca.status.localeCompare(cb.status) + case 'nodeCount': + return dir * (ca.nodeCount - cb.nodeCount) + case 'vclusterCount': + return dir * ((ca.vclusters?.length ?? 0) - (cb.vclusters?.length ?? 0)) + default: + return 0 + } +} + +export function ClustersPage() { + const { deploymentId, data, isLoading } = useCloud() + + const rows = useMemo(() => flattenClusters(data), [data]) + const regionOptions = useMemo(() => { + const set = new Set() + for (const r of rows) set.add(r.region.providerRegion) + return [...set].sort() + }, [rows]) + + const [statusFilter, setStatusFilter] = useState('') + const [regionFilter, setRegionFilter] = useState('') + + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.cluster.name.toLowerCase().includes(s) || + row.cluster.id.toLowerCase().includes(s) || + row.region.providerRegion.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => { + if (statusFilter && row.cluster.status !== statusFilter) return false + if (regionFilter && row.region.providerRegion !== regionFilter) return false + return true + }, + comparator: compareClusters, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading clusters… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + + + + } + /> + +
+ + + + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + + + )) + )} + +
+ No clusters match the current filters. +
{row.cluster.name}{row.region.providerRegion}{row.region.provider}{row.cluster.version}{row.cluster.nodeCount}{row.cluster.vclusters?.length ?? 0}
+
+ + + + )} + + setOpenRow(null)} + title={openRow ? `Cluster — ${openRow.cluster.name}` : ''} + > + {openRow ? ( + <> + + + + + + } /> + + + + + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.test.tsx new file mode 100644 index 00000000..aba0faab --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.test.tsx @@ -0,0 +1,86 @@ +/** + * NodePoolsPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { NodePoolsPage } from './NodePoolsPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const computeRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/compute', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => computeRoute, + path: '/node-pools', + component: NodePoolsPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/compute/node-pools'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('NodePoolsPage', () => { + it('renders 3 pool rows from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-node-pools-row-pool-eu-cp')).toBeTruthy() + expect(screen.getByTestId('cloud-node-pools-row-pool-eu-worker')).toBeTruthy() + expect(screen.getByTestId('cloud-node-pools-row-pool-hel-cp')).toBeTruthy() + expect(screen.getByTestId('cloud-node-pools-count').textContent).toBe('3') + }) + + it('detail drawer surfaces machine type + replicas', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-node-pools-row-pool-eu-worker')) + const body = screen.getByTestId('cloud-node-pools-detail-body') + expect(body.textContent).toContain('cpx32') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.tsx new file mode 100644 index 00000000..d90aaa4a --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/NodePoolsPage.tsx @@ -0,0 +1,200 @@ +/** + * NodePoolsPage — list view for /cloud/compute/node-pools (P3 of #309). + * Flattens regions[].clusters[].nodePools[] into one row per pool. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { + ClusterSpec, + NodePoolSpec, + RegionSpec, + TopologyStatus, +} from '@/lib/infrastructure.types' + +interface NodePoolRow { + id: string + pool: NodePoolSpec + cluster: ClusterSpec + region: RegionSpec +} + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-node-pools' + +function flatten(data: ReturnType['data']): NodePoolRow[] { + if (!data) return [] + const rows: NodePoolRow[] = [] + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + for (const pool of cluster.nodePools ?? []) { + rows.push({ id: pool.id, pool, cluster, region }) + } + } + } + return rows +} + +function compare(a: NodePoolRow, b: NodePoolRow, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.pool.id.localeCompare(b.pool.id) + case 'parentCluster': + return dir * a.cluster.name.localeCompare(b.cluster.name) + case 'sku': + return dir * a.pool.sku.localeCompare(b.pool.sku) + case 'replicas': + return dir * (a.pool.replicas - b.pool.replicas) + case 'status': + return dir * a.pool.status.localeCompare(b.pool.status) + default: + return 0 + } +} + +export function NodePoolsPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => flatten(data), [data]) + + const [statusFilter, setStatusFilter] = useState('') + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.pool.id.toLowerCase().includes(s) || + row.pool.sku.toLowerCase().includes(s) || + row.cluster.name.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => !statusFilter || row.pool.status === statusFilter, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading node pools… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + } + /> +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + )) + )} + +
+ No node pools match the current filters. +
{row.pool.id}{row.cluster.name}{row.pool.sku}{row.pool.replicas}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `Node Pool — ${openRow.pool.id}` : ''} + > + {openRow ? ( + <> + + + + } /> + + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.test.tsx new file mode 100644 index 00000000..0f95542b --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.test.tsx @@ -0,0 +1,87 @@ +/** + * VClustersPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { VClustersPage } from './VClustersPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage(data: HierarchicalInfrastructure) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => , + }) + const computeRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/compute', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => computeRoute, + path: '/vclusters', + component: VClustersPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/compute/vclusters'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('VClustersPage', () => { + it('renders 4 vCluster rows from the fixture', async () => { + renderPage(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-vclusters-row-vc-eu-central-dmz')).toBeTruthy() + expect(screen.getByTestId('cloud-vclusters-row-vc-eu-central-rtz')).toBeTruthy() + expect(screen.getByTestId('cloud-vclusters-row-vc-eu-central-mgmt')).toBeTruthy() + expect(screen.getByTestId('cloud-vclusters-row-vc-hel-rtz')).toBeTruthy() + expect(screen.getByTestId('cloud-vclusters-count').textContent).toBe('4') + }) + + it('clicking a row opens the detail drawer with parent cluster info', async () => { + renderPage(infrastructureTopologyFixture) + fireEvent.click(await screen.findByTestId('cloud-vclusters-row-vc-eu-central-dmz')) + const body = screen.getByTestId('cloud-vclusters-detail-body') + expect(body.textContent).toContain('omantel-primary') + expect(body.textContent).toContain('dmz') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.tsx new file mode 100644 index 00000000..6bc0ba2d --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/VClustersPage.tsx @@ -0,0 +1,202 @@ +/** + * VClustersPage — list view for /cloud/compute/vclusters (P3 of #309). + * Flattens regions[].clusters[].vclusters[] into one row per vCluster. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { + ClusterSpec, + RegionSpec, + TopologyStatus, + VClusterSpec, +} from '@/lib/infrastructure.types' + +interface VClusterRow { + id: string + vcluster: VClusterSpec + cluster: ClusterSpec + region: RegionSpec +} + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-vclusters' + +function flattenVClusters( + data: ReturnType['data'], +): VClusterRow[] { + if (!data) return [] + const rows: VClusterRow[] = [] + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + for (const vc of cluster.vclusters ?? []) { + rows.push({ id: vc.id, vcluster: vc, cluster, region }) + } + } + } + return rows +} + +function compare(a: VClusterRow, b: VClusterRow, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.vcluster.name.localeCompare(b.vcluster.name) + case 'parentCluster': + return dir * a.cluster.name.localeCompare(b.cluster.name) + case 'region': + return dir * a.region.providerRegion.localeCompare(b.region.providerRegion) + case 'isolation': + return dir * a.vcluster.isolationMode.localeCompare(b.vcluster.isolationMode) + case 'status': + return dir * a.vcluster.status.localeCompare(b.vcluster.status) + default: + return 0 + } +} + +export function VClustersPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => flattenVClusters(data), [data]) + + const [statusFilter, setStatusFilter] = useState('') + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.vcluster.name.toLowerCase().includes(s) || + row.vcluster.id.toLowerCase().includes(s) || + row.cluster.name.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => !statusFilter || row.vcluster.status === statusFilter, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading vClusters… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + } + /> +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + )) + )} + +
+ No vClusters match the current filters. +
{row.vcluster.name}{row.cluster.name}{row.region.providerRegion}{row.vcluster.isolationMode}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `vCluster — ${openRow.vcluster.name}` : ''} + > + {openRow ? ( + <> + + + + } /> + + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.test.tsx new file mode 100644 index 00000000..5dae498e --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.test.tsx @@ -0,0 +1,93 @@ +/** + * WorkerNodesPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { WorkerNodesPage } from './WorkerNodesPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const computeRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/compute', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => computeRoute, + path: '/worker-nodes', + component: WorkerNodesPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([computeRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/compute/worker-nodes'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('WorkerNodesPage', () => { + it('renders 6 node rows from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-worker-nodes-row-node-eu-cp-0')).toBeTruthy() + expect(screen.getByTestId('cloud-worker-nodes-count').textContent).toBe('6') + }) + + it('role filter narrows to control-plane', async () => { + renderPage() + const roleSelect = (await screen.findByTestId('cloud-worker-nodes-filter-role')) as HTMLSelectElement + fireEvent.change(roleSelect, { target: { value: 'control-plane' } }) + expect(screen.getByTestId('cloud-worker-nodes-row-node-eu-cp-0')).toBeTruthy() + expect(screen.queryByTestId('cloud-worker-nodes-row-node-eu-w-0')).toBeNull() + }) + + it('detail drawer surfaces hostname + IP', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-worker-nodes-row-node-eu-w-0')) + const body = screen.getByTestId('cloud-worker-nodes-detail-body') + expect(body.textContent).toContain('eu-w-0') + expect(body.textContent).toContain('10.0.1.10') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.tsx new file mode 100644 index 00000000..7bb31ce6 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-compute/WorkerNodesPage.tsx @@ -0,0 +1,227 @@ +/** + * WorkerNodesPage — list view for /cloud/compute/worker-nodes (P3 of #309). + * Flattens regions[].clusters[].nodes[] into one row per node VM. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { + ClusterSpec, + NodeSpec, + RegionSpec, + TopologyStatus, +} from '@/lib/infrastructure.types' + +interface NodeRow { + id: string + node: NodeSpec + cluster: ClusterSpec + region: RegionSpec +} + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-worker-nodes' + +function flatten(data: ReturnType['data']): NodeRow[] { + if (!data) return [] + const rows: NodeRow[] = [] + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + for (const node of cluster.nodes ?? []) { + rows.push({ id: node.id, node, cluster, region }) + } + } + } + return rows +} + +function compare(a: NodeRow, b: NodeRow, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'hostname': + return dir * a.node.name.localeCompare(b.node.name) + case 'parentCluster': + return dir * a.cluster.name.localeCompare(b.cluster.name) + case 'role': + return dir * a.node.role.localeCompare(b.node.role) + case 'kubeletVersion': + return dir * a.cluster.version.localeCompare(b.cluster.version) + case 'sku': + return dir * a.node.sku.localeCompare(b.node.sku) + case 'status': + return dir * a.node.status.localeCompare(b.node.status) + default: + return 0 + } +} + +export function WorkerNodesPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => flatten(data), [data]) + + const [statusFilter, setStatusFilter] = useState('') + const [roleFilter, setRoleFilter] = useState('') + const roleOptions = useMemo(() => { + const set = new Set() + for (const r of rows) set.add(r.node.role) + return [...set].sort() + }, [rows]) + + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.node.name.toLowerCase().includes(s) || + row.node.id.toLowerCase().includes(s) || + row.node.ip.toLowerCase().includes(s) || + row.cluster.name.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => { + if (statusFilter && row.node.status !== statusFilter) return false + if (roleFilter && row.node.role !== roleFilter) return false + return true + }, + comparator: compare, + defaultSort: { column: 'hostname', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading worker nodes… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + + + + } + /> +
+ + + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + + )) + )} + +
+ No worker nodes match the current filters. +
{row.node.name}{row.cluster.name}{row.node.role}{row.cluster.version}{row.node.sku}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `Worker Node — ${openRow.node.name}` : ''} + > + {openRow ? ( + <> + + + + + + } /> + + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/CloudListPlaceholder.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/CloudListPlaceholder.tsx new file mode 100644 index 00000000..c158350c --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/CloudListPlaceholder.tsx @@ -0,0 +1,78 @@ +/** + * CloudListPlaceholder — empty surface for resource list pages whose + * data is not yet fed by an informer (Services / Ingresses / + * DNS Zones / Storage Classes — see issue #321 for the informer rollout). + * + * Renders the canonical list-page header so the route exists and is + * navigable, then a clear empty state explaining the gap with a link + * back to the documentation tracking issue. + * + * Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall, target shape) — the + * page surface lands in target shape now; the data wiring fills in + * later without changing the URL or component contract. + */ + +import { useCloud } from '../CloudPage' +import { + CloudListHeader, + EmptyState, +} from './cloudListShared' +import { CLOUD_LIST_CSS } from './cloudListCss' + +interface CloudListPlaceholderProps { + /** Per-page testid prefix (e.g. "cloud-services"). */ + testId: string + /** Plural resource title (e.g. "Services"). */ + title: string + /** Tagline beneath the title. */ + tagline: string + /** Empty-state body — usually references the informer issue. */ + bodyText: string + /** Optional documentation URL (rendered as a link). */ + docsHref?: string +} + +export function CloudListPlaceholder({ + testId, + title, + tagline, + bodyText, + docsHref, +}: CloudListPlaceholderProps) { + const { deploymentId } = useCloud() + return ( +
+ + + + {bodyText} + {docsHref ? ( + <> + {' '} + + Tracking issue ↗ + + + ) : null} + + } + /> +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListCss.ts b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListCss.ts new file mode 100644 index 00000000..15c41627 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListCss.ts @@ -0,0 +1,308 @@ +/** + * cloudListCss — shared CSS for the Cloud per-resource list pages + * (P3 of #309). Lives in its own module so the components file + * (cloudListShared.tsx) only exports React components, keeping the + * react-refresh/only-export-components rule clean. + */ + +export const CLOUD_LIST_CSS = ` +.cloud-list-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.75rem; +} +.cloud-list-search-wrap { + position: relative; + flex: 1 1 280px; + min-width: 240px; + max-width: 480px; +} +.cloud-list-search-icon { + position: absolute; + left: 0.6rem; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + color: var(--color-text-dim); +} +.cloud-list-search-input { + width: 100%; + padding: 0.45rem 0.7rem 0.45rem 1.9rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); + font-size: 0.85rem; + outline: none; + transition: border-color 0.15s ease; +} +.cloud-list-search-input:focus { border-color: var(--color-accent); } + +.cloud-list-filters { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} +.cloud-list-filter-label { + display: inline-flex; + flex-direction: column; + gap: 0.15rem; +} +.cloud-list-filter-caption { + font-size: 0.62rem; + color: var(--color-text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.cloud-list-filter-select { + padding: 0.32rem 0.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-size: 0.82rem; + cursor: pointer; +} +.cloud-list-result-count { + font-size: 0.72rem; + color: var(--color-text-dim); + align-self: flex-end; + margin-left: auto; + padding-bottom: 0.32rem; + font-variant-numeric: tabular-nums; +} + +.cloud-list-table-scroll { + width: 100%; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); +} +.cloud-list-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.cloud-list-th { + padding: 0.55rem 0.8rem; + text-align: left; + background: color-mix(in srgb, var(--color-border) 35%, transparent); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-dim); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + white-space: nowrap; + user-select: none; +} +.cloud-list-th-sortable { cursor: pointer; } +.cloud-list-th-sortable:hover { color: var(--color-text); } +.cloud-list-th-content { display: inline-flex; gap: 4px; align-items: center; } +.cloud-list-th-arrow { color: var(--color-accent); } + +.cloud-list-row { + border-bottom: 1px solid var(--color-border); + transition: background-color 0.12s ease; + cursor: pointer; +} +.cloud-list-row:last-of-type { border-bottom: none; } +.cloud-list-row:hover { + background: color-mix(in srgb, var(--color-accent) 5%, transparent); +} +.cloud-list-cell { + padding: 0.55rem 0.8rem; + vertical-align: middle; + color: var(--color-text); +} +.cloud-list-cell-mono { font-family: var(--font-mono, ui-monospace, monospace); } +.cloud-list-cell-name { font-weight: 500; color: var(--color-text-strong); } +.cloud-list-empty-row { + padding: 2rem 1rem; + text-align: center; + color: var(--color-text-dim); + font-size: 0.85rem; +} + +.cloud-list-status { + display: inline-block; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; + padding: 0.1rem 0.45rem; + border-radius: 999px; + white-space: nowrap; +} +.cloud-list-status[data-status="healthy"] { background: color-mix(in srgb, var(--color-success) 18%, transparent); color: var(--color-success); } +.cloud-list-status[data-status="degraded"] { background: color-mix(in srgb, var(--color-warn) 18%, transparent); color: var(--color-warn); } +.cloud-list-status[data-status="failed"] { background: color-mix(in srgb, var(--color-danger) 18%, transparent); color: var(--color-danger); } +.cloud-list-status[data-status="unknown"] { background: color-mix(in srgb, var(--color-text-dim) 18%, transparent); color: var(--color-text-dim); } + +.cloud-list-pagination { + display: flex; + gap: 0.6rem; + align-items: center; + justify-content: flex-end; + padding: 0.6rem 0.2rem 0; + font-size: 0.8rem; + color: var(--color-text-dim); +} +.cloud-list-pagination button { + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text); + border-radius: 6px; + padding: 0.25rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.cloud-list-pagination button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.cloud-list-pagination-label { + font-variant-numeric: tabular-nums; +} + +.cloud-list-empty { + margin-top: 2rem; + text-align: center; + color: var(--color-text-dim); + padding: 2rem 1rem; + border: 1px dashed var(--color-border); + border-radius: 12px; + background: var(--color-bg-2); +} +.cloud-list-empty-title { + font-size: 0.95rem; + color: var(--color-text-strong); + font-weight: 600; + margin: 0 0 0.3rem; +} +.cloud-list-empty-body { + font-size: 0.82rem; + margin: 0; +} + +.cloud-list-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 60; + background: color-mix(in srgb, #000 45%, transparent); + display: flex; + justify-content: flex-end; +} +.cloud-list-drawer { + width: min(520px, 92vw); + height: 100vh; + background: var(--color-bg-2); + border-left: 1px solid var(--color-border); + display: flex; + flex-direction: column; + animation: cloud-list-drawer-slide 0.16s ease-out; +} +@keyframes cloud-list-drawer-slide { + from { transform: translateX(40px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +.cloud-list-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} +.cloud-list-drawer-title { + font-size: 1rem; + font-weight: 600; + color: var(--color-text-strong); + margin: 0; +} +.cloud-list-drawer-close { + background: transparent; + border: none; + color: var(--color-text-dim); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0 0.3rem; +} +.cloud-list-drawer-close:hover { color: var(--color-text); } +.cloud-list-drawer-body { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.cloud-list-detail-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 0.6rem; + padding: 0.45rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); +} +.cloud-list-detail-row:last-of-type { border-bottom: none; } +.cloud-list-detail-row-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-dim); +} +.cloud-list-detail-row-value { + font-size: 0.85rem; + color: var(--color-text); + word-break: break-word; +} +.cloud-list-detail-row-mono { font-family: var(--font-mono, ui-monospace, monospace); } + +.cloud-list-tile-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} +.cloud-list-tile { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); + color: var(--color-text); + text-decoration: none; + transition: border-color 0.12s ease, transform 0.12s ease; +} +.cloud-list-tile:hover { + border-color: var(--color-accent); + transform: translateY(-1px); +} +.cloud-list-tile-name { + font-size: 0.95rem; + font-weight: 600; + color: var(--color-text-strong); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} +.cloud-list-tile-count { + font-size: 0.7rem; + font-weight: 600; + padding: 0.08rem 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-border) 60%, transparent); + color: var(--color-text-dim); +} +.cloud-list-tile-tagline { + font-size: 0.78rem; + color: var(--color-text-dim); + margin: 0; +} +` diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListShared.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListShared.tsx new file mode 100644 index 00000000..57fcaefb --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/cloudListShared.tsx @@ -0,0 +1,355 @@ +/** + * cloudListShared — shared scaffolding for the Cloud per-resource list + * pages (P3 of #309). Every list page ships: + * • H1 + count badge + tagline + back-link + * • search + filter pills (kind-appropriate) + * • sortable columns, click-row → drawer + * • slide-in drawer rendered into an overlay + * + * The shape mirrors JobsTable.tsx (status colour tokens, monospace + * chips, hover row tint) so the pages read as one consistent surface. + * + * Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every label, + * column id, status string and CSS token comes from a typed input or + * a CSS variable — there's no inlined provider name or hex colour. + */ + +import { useEffect, type ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import type { TopologyStatus } from '@/lib/infrastructure.types' +import type { SortState } from './sortState' + +/* ── Header ──────────────────────────────────────────────────────── */ + +interface CloudListHeaderProps { + /** Plural resource name as user-visible — e.g. "Clusters". */ + title: string + /** Short tagline beneath the title. */ + tagline: string + /** Total resource count (badge in the title). */ + count: number + /** Stable deployment id (powers the back-link target). */ + deploymentId: string + /** Per-page testid prefix, e.g. "cloud-clusters" → "cloud-clusters-page". */ + testId: string +} + +export function CloudListHeader({ + title, + tagline, + count, + deploymentId, + testId, +}: CloudListHeaderProps) { + return ( +
+
+

+ {title} + + {count} + +

+

{tagline}

+
+ + ← Back to Cloud + +
+ ) +} + +/* ── Status pill — same palette as JobsTable / CloudCompute ─────── */ + +export function StatusPill({ status }: { status: TopologyStatus }) { + return ( + + {status} + + ) +} + +/* ── Filter pill (status / region / etc) ────────────────────────── */ + +interface FilterPillsProps { + label: string + options: readonly T[] + selected: T | '' + onChange: (next: T | '') => void + testId: string +} + +export function FilterPills({ + label, + options, + selected, + onChange, + testId, +}: FilterPillsProps) { + return ( + + ) +} + +/* ── Toolbar (search + filters + count) ─────────────────────────── */ + +interface CloudListToolbarProps { + /** Per-page testid prefix. */ + testId: string + /** Current search value (controlled). */ + search: string + onSearchChange: (next: string) => void + /** Visible / total count for the live-region announcement. */ + visibleCount: number + totalCount: number + /** Render slot for filter pills. */ + filters?: ReactNode +} + +export function CloudListToolbar({ + testId, + search, + onSearchChange, + visibleCount, + totalCount, + filters, +}: CloudListToolbarProps) { + return ( +
+
+ + + + + onSearchChange(e.target.value)} + data-testid={`${testId}-search`} + aria-label="Search resources" + /> +
+
+ {filters} + + {visibleCount}/{totalCount} + +
+
+ ) +} + +/* ── Sortable column header ─────────────────────────────────────── */ + +interface SortableTHProps { + column: string + label: string + state: SortState + onChange: (next: SortState) => void + testId: string +} + +export function SortableTH({ column, label, state, onChange, testId }: SortableTHProps) { + const active = state.column === column + return ( + { + if (!active) onChange({ column, dir: 'asc' }) + else onChange({ column, dir: state.dir === 'asc' ? 'desc' : 'asc' }) + }} + data-testid={testId} + data-sort-active={active ? 'true' : 'false'} + data-sort-dir={active ? state.dir : ''} + > + + {label} + {active ? ( + + {state.dir === 'asc' ? '↑' : '↓'} + + ) : null} + + + ) +} + +/* ── Pagination ─────────────────────────────────────────────────── */ + +interface PaginationProps { + page: number + pageSize: number + total: number + onPageChange: (next: number) => void + testId: string +} + +export function Pagination({ page, pageSize, total, onPageChange, testId }: PaginationProps) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + if (pageCount <= 1) return null + return ( +
+ + + Page {page + 1} of {pageCount} + + +
+ ) +} + +/* ── Detail drawer ──────────────────────────────────────────────── */ + +interface CloudListDetailDrawerProps { + /** When non-null the drawer is open; render a controlled component. */ + open: boolean + onClose: () => void + title: string + testId: string + children: ReactNode +} + +export function CloudListDetailDrawer({ + open, + onClose, + title, + testId, + children, +}: CloudListDetailDrawerProps) { + // Esc closes the drawer. + useEffect(() => { + if (!open) return + function onKey(ev: KeyboardEvent) { + if (ev.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + if (!open) return null + return ( +
+ +
+ ) +} + +/* ── Detail rows (key/value pairs) ──────────────────────────────── */ + +interface DetailRowProps { + label: string + value: ReactNode + mono?: boolean + testId?: string +} + +export function DetailRow({ label, value, mono = false, testId }: DetailRowProps) { + return ( +
+ {label} + + {value} + +
+ ) +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +interface EmptyStateProps { + testId: string + title: string + body: ReactNode +} + +export function EmptyState({ testId, title, body }: EmptyStateProps) { + return ( +
+

{title}

+

{body}

+
+ ) +} + diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/sortState.ts b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/sortState.ts new file mode 100644 index 00000000..48ebcd3a --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/sortState.ts @@ -0,0 +1,12 @@ +/** + * Shared sort-state types for the Cloud list pages (P3 of #309). + * Lives in its own module so both the component file and the + * useCloudListState hook can import it without forming a cycle. + */ + +export type SortDir = 'asc' | 'desc' + +export interface SortState { + column: string + dir: SortDir +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/useCloudListState.ts b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/useCloudListState.ts new file mode 100644 index 00000000..07e1da2f --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-list/useCloudListState.ts @@ -0,0 +1,72 @@ +/** + * useCloudListState — shared state hook for the per-resource Cloud + * list pages (P3 of #309). Owns search / sort / pagination state and + * derives the visible slice from a comparator + filter callback. + * + * Separated from the component-laden cloudListShared.tsx so the + * react-refresh/only-export-components rule stays clean: each file + * exports either components OR utilities, not both. + */ + +import { useMemo, useState } from 'react' +import type { SortState } from './sortState' + +interface UseListStateOpts { + rows: readonly T[] + /** Pre-computed search predicate (case-insensitive substring on a name). */ + matchSearch: (row: T, q: string) => boolean + /** Optional pre-computed extra filter (e.g. status pill). */ + matchExtra?: (row: T) => boolean + /** Pure sort function consuming the SortState. */ + comparator: (a: T, b: T, sort: SortState) => number + /** Default sort. */ + defaultSort: SortState + pageSize?: number +} + +export function useCloudListState(opts: UseListStateOpts) { + const [search, setSearch] = useState('') + const [sort, setSort] = useState(opts.defaultSort) + const [page, setPage] = useState(0) + + const filtered = useMemo( + () => + opts.rows.filter((row) => { + if (opts.matchExtra && !opts.matchExtra(row)) return false + if (!opts.matchSearch(row, search)) return false + return true + }), + [opts, search], + ) + + const sorted = useMemo( + () => [...filtered].sort((a, b) => opts.comparator(a, b, sort)), + [filtered, opts, sort], + ) + + const pageSize = opts.pageSize ?? 50 + const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize)) + + // Clamp the current page derivatively (no setState-in-effect) — when + // filtering shrinks the result set the visible window stays in + // bounds without an effect cascading another render. + const effectivePage = Math.min(page, pageCount - 1) + + const visible = useMemo( + () => sorted.slice(effectivePage * pageSize, effectivePage * pageSize + pageSize), + [sorted, effectivePage, pageSize], + ) + + return { + search, + setSearch, + sort, + setSort, + page: effectivePage, + setPage, + pageSize, + filtered, + sorted, + visible, + } +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.test.tsx new file mode 100644 index 00000000..dfb8135b --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.test.tsx @@ -0,0 +1,85 @@ +/** + * CloudNetworkPage.test.tsx — landing page for /cloud/network (P3). + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { CloudNetworkPage } from './CloudNetworkPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderLanding(data: HierarchicalInfrastructure) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => , + }) + const netRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/network', + component: CloudNetworkPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([netRoute])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/network'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('CloudNetworkPage', () => { + it('renders 4 tiles (services / ingresses / load-balancers / dns-zones)', async () => { + renderLanding(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-network-page-tile-services')).toBeTruthy() + expect(screen.getByTestId('cloud-network-page-tile-ingresses')).toBeTruthy() + expect(screen.getByTestId('cloud-network-page-tile-load-balancers')).toBeTruthy() + expect(screen.getByTestId('cloud-network-page-tile-dns-zones')).toBeTruthy() + }) + + it('Load Balancers tile shows the fixture count (1)', async () => { + renderLanding(infrastructureTopologyFixture) + expect((await screen.findByTestId('cloud-network-page-tile-load-balancers-count')).textContent).toBe('1') + }) + + it('placeholder tiles show — for the count', async () => { + renderLanding(infrastructureTopologyFixture) + expect((await screen.findByTestId('cloud-network-page-tile-services-count')).textContent).toBe('—') + expect(screen.getByTestId('cloud-network-page-tile-ingresses-count').textContent).toBe('—') + expect(screen.getByTestId('cloud-network-page-tile-dns-zones-count').textContent).toBe('—') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.tsx new file mode 100644 index 00000000..dfea5dc7 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/CloudNetworkPage.tsx @@ -0,0 +1,122 @@ +/** + * CloudNetworkPage — Sovereign Cloud / Network landing page (P3 of + * issue #309). Replaces the previous flat dump in CloudNetwork.tsx. + * + * Renders a tile grid for the four resource types in the Network + * category: Services, Ingresses, Load Balancers, DNS Zones. The + * informer-fed tiles show a count; placeholder tiles show "—". + */ + +import { useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { useCloud } from '../CloudPage' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' + +interface NetworkTile { + id: 'services' | 'ingresses' | 'load-balancers' | 'dns-zones' + label: string + tagline: string + /** Whether the tree carries data for this resource yet. */ + hasData: boolean +} + +const NETWORK_TILES: readonly NetworkTile[] = [ + { + id: 'services', + label: 'Services', + tagline: 'Awaiting service informer (#321).', + hasData: false, + }, + { + id: 'ingresses', + label: 'Ingresses', + tagline: 'Awaiting ingress informer (#321).', + hasData: false, + }, + { + id: 'load-balancers', + label: 'Load Balancers', + tagline: 'Cloud-provisioned LBs fronting clusters.', + hasData: true, + }, + { + id: 'dns-zones', + label: 'DNS Zones', + tagline: 'Awaiting external-dns informer (#321).', + hasData: false, + }, +] + +export function CloudNetworkPage() { + const { deploymentId, data, isLoading } = useCloud() + + const counts = useMemo(() => { + const out: Record = { + services: null, + ingresses: null, + 'load-balancers': 0, + 'dns-zones': null, + } + if (!data) return out + let lbCount = 0 + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + lbCount += cluster.loadBalancers?.length ?? 0 + } + } + out['load-balancers'] = lbCount + return out + }, [data]) + + return ( +
+ +
+

+ Network +

+

+ Services, ingresses, load balancers and DNS zones for this Sovereign. +

+
+ + {isLoading ? ( +
+ Loading network resources… +
+ ) : ( +
+ {NETWORK_TILES.map((tile) => { + const c = counts[tile.id] + return ( + +
+ {tile.label} + + {tile.hasData && c !== null ? c : '—'} + +
+

{tile.tagline}

+ + ) + })} +
+ )} +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/DnsZonesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/DnsZonesPage.tsx new file mode 100644 index 00000000..b186533d --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/DnsZonesPage.tsx @@ -0,0 +1,18 @@ +/** + * DnsZonesPage — placeholder list page for /cloud/network/dns-zones + * (P3 of #309). Pending the external-dns informer (#321). + */ + +import { CloudListPlaceholder } from '../cloud-list/CloudListPlaceholder' + +export function DnsZonesPage() { + return ( + + ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/IngressesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/IngressesPage.tsx new file mode 100644 index 00000000..4e959de3 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/IngressesPage.tsx @@ -0,0 +1,18 @@ +/** + * IngressesPage — placeholder list page for /cloud/network/ingresses + * (P3 of #309). Pending the ingress informer (#321). + */ + +import { CloudListPlaceholder } from '../cloud-list/CloudListPlaceholder' + +export function IngressesPage() { + return ( + + ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.test.tsx new file mode 100644 index 00000000..bb6ee851 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.test.tsx @@ -0,0 +1,86 @@ +/** + * LoadBalancersPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { LoadBalancersPage } from './LoadBalancersPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const networkRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/network', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => networkRoute, + path: '/load-balancers', + component: LoadBalancersPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([networkRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/network/load-balancers'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('LoadBalancersPage', () => { + it('renders 1 LB row from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-load-balancers-row-lb-eu-central-edge')).toBeTruthy() + expect(screen.getByTestId('cloud-load-balancers-count').textContent).toBe('1') + }) + + it('detail drawer surfaces public IP + listeners', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-load-balancers-row-lb-eu-central-edge')) + const body = screen.getByTestId('cloud-load-balancers-detail-body') + expect(body.textContent).toContain('116.203.42.1') + expect(body.textContent).toContain('tcp:80') + expect(body.textContent).toContain('tcp:443') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.tsx new file mode 100644 index 00000000..78b5b95c --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/LoadBalancersPage.tsx @@ -0,0 +1,214 @@ +/** + * LoadBalancersPage — list view for /cloud/network/load-balancers + * (P3 of #309). Flattens regions[].clusters[].loadBalancers[] into one + * row per LB. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { + ClusterSpec, + LoadBalancerSpec, + RegionSpec, + TopologyStatus, +} from '@/lib/infrastructure.types' + +interface LBRow { + id: string + lb: LoadBalancerSpec + cluster: ClusterSpec + region: RegionSpec +} + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-load-balancers' + +function flatten(data: ReturnType['data']): LBRow[] { + if (!data) return [] + const rows: LBRow[] = [] + for (const region of data.topology.regions ?? []) { + for (const cluster of region.clusters ?? []) { + for (const lb of cluster.loadBalancers ?? []) { + rows.push({ id: lb.id, lb, cluster, region }) + } + } + } + return rows +} + +function compare(a: LBRow, b: LBRow, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.lb.name.localeCompare(b.lb.name) + case 'region': + return dir * a.region.providerRegion.localeCompare(b.region.providerRegion) + case 'listeners': + return dir * ((a.lb.listeners?.length ?? 0) - (b.lb.listeners?.length ?? 0)) + case 'targets': + return dir * ((a.lb.targets?.length ?? 0) - (b.lb.targets?.length ?? 0)) + case 'status': + return dir * a.lb.status.localeCompare(b.lb.status) + default: + return 0 + } +} + +function formatListeners(lb: LoadBalancerSpec): string { + if (!lb.listeners?.length) return '—' + return lb.listeners.map((l) => `${l.protocol}:${l.port}`).join(', ') +} + +export function LoadBalancersPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => flatten(data), [data]) + + const [statusFilter, setStatusFilter] = useState('') + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.lb.name.toLowerCase().includes(s) || + row.lb.id.toLowerCase().includes(s) || + row.lb.publicIP.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => !statusFilter || row.lb.status === statusFilter, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading load balancers… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + } + /> +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => { + const healthy = row.lb.targets?.filter((t) => t.status === 'healthy').length ?? 0 + const total = row.lb.targets?.length ?? 0 + return ( + setOpenRow(row)} + > + + + + + + + ) + }) + )} + +
+ No load balancers match the current filters. +
{row.lb.name}{row.region.providerRegion}{formatListeners(row.lb)}{`${healthy}/${total}`}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `Load Balancer — ${openRow.lb.name}` : ''} + > + {openRow ? ( + <> + + + + + t.status === 'healthy').length ?? 0}/${openRow.lb.targets?.length ?? 0} healthy`} + /> + } /> + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.test.tsx new file mode 100644 index 00000000..de940994 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.test.tsx @@ -0,0 +1,91 @@ +/** + * ServicesPage.test.tsx — placeholder lock-in. Asserts the empty state + * + the documentation link land in the expected shape. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { ServicesPage } from './ServicesPage' +import { IngressesPage } from './IngressesPage' +import { DnsZonesPage } from './DnsZonesPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPlaceholder(path: string, Page: () => React.ReactElement, suffix: string) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const networkRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/network', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => networkRoute, + path: `/${suffix}`, + component: Page, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([networkRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: [path] }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('Network placeholder pages', () => { + it('ServicesPage renders header + empty state + docs link', async () => { + renderPlaceholder('/provision/d-1/cloud/network/services', ServicesPage, 'services') + expect(await screen.findByTestId('cloud-services-page')).toBeTruthy() + expect(screen.getByTestId('cloud-services-empty')).toBeTruthy() + expect(screen.getByTestId('cloud-services-docs-link')).toBeTruthy() + }) + + it('IngressesPage renders empty state', async () => { + renderPlaceholder('/provision/d-1/cloud/network/ingresses', IngressesPage, 'ingresses') + expect(await screen.findByTestId('cloud-ingresses-page')).toBeTruthy() + expect(screen.getByTestId('cloud-ingresses-empty')).toBeTruthy() + }) + + it('DnsZonesPage renders empty state', async () => { + renderPlaceholder('/provision/d-1/cloud/network/dns-zones', DnsZonesPage, 'dns-zones') + expect(await screen.findByTestId('cloud-dns-zones-page')).toBeTruthy() + expect(screen.getByTestId('cloud-dns-zones-empty')).toBeTruthy() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.tsx new file mode 100644 index 00000000..e0a96e62 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-network/ServicesPage.tsx @@ -0,0 +1,20 @@ +/** + * ServicesPage — placeholder list page for /cloud/network/services + * (P3 of #309). The data wiring depends on the service-informer + * rollout tracked in #321; the page surface ships now so the route + * exists. + */ + +import { CloudListPlaceholder } from '../cloud-list/CloudListPlaceholder' + +export function ServicesPage() { + return ( + + ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.test.tsx new file mode 100644 index 00000000..f365d0e7 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.test.tsx @@ -0,0 +1,85 @@ +/** + * BucketsPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { BucketsPage } from './BucketsPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const stRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/storage', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => stRoute, + path: '/buckets', + component: BucketsPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([stRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/storage/buckets'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('BucketsPage', () => { + it('renders 1 bucket row from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-buckets-row-bucket-backups')).toBeTruthy() + expect(screen.getByTestId('cloud-buckets-count').textContent).toBe('1') + }) + + it('detail drawer surfaces endpoint + retention', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-buckets-row-bucket-backups')) + const body = screen.getByTestId('cloud-buckets-detail-body') + expect(body.textContent).toContain('seaweedfs') + expect(body.textContent).toContain('30') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.tsx new file mode 100644 index 00000000..6f6131e1 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/BucketsPage.tsx @@ -0,0 +1,165 @@ +/** + * BucketsPage — list view for /cloud/storage/buckets (P3 of #309). + * Reads data.storage.buckets from the shared infrastructure tree. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + Pagination, + SortableTH, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { BucketItem } from '@/lib/infrastructure.types' + +const TEST_ID = 'cloud-buckets' + +function compare(a: BucketItem, b: BucketItem, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.name.localeCompare(b.name) + case 'endpoint': + return dir * a.endpoint.localeCompare(b.endpoint) + case 'capacity': + return dir * a.capacity.localeCompare(b.capacity) + case 'used': + return dir * (a.used ?? '').localeCompare(b.used ?? '') + case 'retentionDays': + return dir * (a.retentionDays ?? '').localeCompare(b.retentionDays ?? '') + default: + return 0 + } +} + +/** + * The current backend doesn't carry an explicit region per bucket + * (it's encoded into the endpoint). We surface the endpoint host as + * the "provider" surface and skip the region column. Status is also + * not exposed today; treat all buckets as healthy on the table. + */ +export function BucketsPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => data?.storage?.buckets ?? [], [data]) + + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.name.toLowerCase().includes(s) || + row.id.toLowerCase().includes(s) || + row.endpoint.toLowerCase().includes(s) + ) + }, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading buckets… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + )) + )} + +
+ No buckets match the current filters. +
{row.name}{row.endpoint}{row.capacity}{row.used || '—'}{row.retentionDays || 'indefinite'}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `Bucket — ${openRow.name}` : ''} + > + {openRow ? ( + <> + + + + + + + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.test.tsx new file mode 100644 index 00000000..af2ca735 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.test.tsx @@ -0,0 +1,85 @@ +/** + * CloudStoragePage.test.tsx — landing page for /cloud/storage (P3). + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { CloudStoragePage } from './CloudStoragePage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderLanding(data: HierarchicalInfrastructure) { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => , + }) + const stRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/storage', + component: CloudStoragePage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([stRoute])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/storage'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('CloudStoragePage', () => { + it('renders 4 tiles (pvcs / storage-classes / buckets / volumes)', async () => { + renderLanding(infrastructureTopologyFixture) + expect(await screen.findByTestId('cloud-storage-page-tile-pvcs')).toBeTruthy() + expect(screen.getByTestId('cloud-storage-page-tile-storage-classes')).toBeTruthy() + expect(screen.getByTestId('cloud-storage-page-tile-buckets')).toBeTruthy() + expect(screen.getByTestId('cloud-storage-page-tile-volumes')).toBeTruthy() + }) + + it('counts derive from the fixture (2 pvcs, 1 bucket, 1 volume)', async () => { + renderLanding(infrastructureTopologyFixture) + expect((await screen.findByTestId('cloud-storage-page-tile-pvcs-count')).textContent).toBe('2') + expect(screen.getByTestId('cloud-storage-page-tile-buckets-count').textContent).toBe('1') + expect(screen.getByTestId('cloud-storage-page-tile-volumes-count').textContent).toBe('1') + }) + + it('Storage Classes shows — placeholder count', async () => { + renderLanding(infrastructureTopologyFixture) + expect((await screen.findByTestId('cloud-storage-page-tile-storage-classes-count')).textContent).toBe('—') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.tsx new file mode 100644 index 00000000..0b50e639 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/CloudStoragePage.tsx @@ -0,0 +1,116 @@ +/** + * CloudStoragePage — Sovereign Cloud / Storage landing page (P3 of + * issue #309). Replaces the previous flat dump in CloudStorage.tsx. + * + * Renders a tile grid for the four resource types in the Storage + * category: PVCs, Storage Classes, Buckets, Volumes. + */ + +import { useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { useCloud } from '../CloudPage' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' + +interface StorageTile { + id: 'pvcs' | 'storage-classes' | 'buckets' | 'volumes' + label: string + tagline: string + hasData: boolean +} + +const STORAGE_TILES: readonly StorageTile[] = [ + { + id: 'pvcs', + label: 'PVCs', + tagline: 'Persistent volume claims across all namespaces.', + hasData: true, + }, + { + id: 'storage-classes', + label: 'Storage Classes', + tagline: 'Awaiting storage-class informer (#321).', + hasData: false, + }, + { + id: 'buckets', + label: 'Buckets', + tagline: 'S3-compatible buckets (SeaweedFS / provider).', + hasData: true, + }, + { + id: 'volumes', + label: 'Volumes', + tagline: 'Cloud block volumes attached to nodes.', + hasData: true, + }, +] + +export function CloudStoragePage() { + const { deploymentId, data, isLoading } = useCloud() + + const counts = useMemo(() => { + const out: Record = { + pvcs: 0, + 'storage-classes': null, + buckets: 0, + volumes: 0, + } + if (!data) return out + out.pvcs = data.storage?.pvcs?.length ?? 0 + out.buckets = data.storage?.buckets?.length ?? 0 + out.volumes = data.storage?.volumes?.length ?? 0 + return out + }, [data]) + + return ( +
+ +
+

+ Storage +

+

+ PVCs, storage classes, buckets and block volumes for this Sovereign. +

+
+ + {isLoading ? ( +
+ Loading storage resources… +
+ ) : ( +
+ {STORAGE_TILES.map((tile) => { + const c = counts[tile.id] + return ( + +
+ {tile.label} + + {tile.hasData && c !== null ? c : '—'} + +
+

{tile.tagline}

+ + ) + })} +
+ )} +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.test.tsx new file mode 100644 index 00000000..9f5ea63b --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.test.tsx @@ -0,0 +1,86 @@ +/** + * PvcsPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { PvcsPage } from './PvcsPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const stRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/storage', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => stRoute, + path: '/pvcs', + component: PvcsPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([stRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/storage/pvcs'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('PvcsPage', () => { + it('renders 2 PVC rows from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-pvcs-row-pvc-postgres-data')).toBeTruthy() + expect(screen.getByTestId('cloud-pvcs-row-pvc-redis-data')).toBeTruthy() + expect(screen.getByTestId('cloud-pvcs-count').textContent).toBe('2') + }) + + it('detail drawer surfaces capacity + storage class', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-pvcs-row-pvc-postgres-data')) + const body = screen.getByTestId('cloud-pvcs-detail-body') + expect(body.textContent).toContain('20Gi') + expect(body.textContent).toContain('local-path') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.tsx new file mode 100644 index 00000000..04e3d6ef --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/PvcsPage.tsx @@ -0,0 +1,194 @@ +/** + * PvcsPage — list view for /cloud/storage/pvcs (P3 of #309). Reads + * data.storage.pvcs from the shared infrastructure tree. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { PVCItem, TopologyStatus } from '@/lib/infrastructure.types' + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-pvcs' + +function compare(a: PVCItem, b: PVCItem, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.name.localeCompare(b.name) + case 'namespace': + return dir * a.namespace.localeCompare(b.namespace) + case 'capacity': + return dir * a.capacity.localeCompare(b.capacity) + case 'storageClass': + return dir * a.storageClass.localeCompare(b.storageClass) + case 'status': + return dir * a.status.localeCompare(b.status) + default: + return 0 + } +} + +export function PvcsPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => data?.storage?.pvcs ?? [], [data]) + + const [statusFilter, setStatusFilter] = useState('') + const [classFilter, setClassFilter] = useState('') + const classOptions = useMemo(() => { + const set = new Set() + for (const r of rows) set.add(r.storageClass) + return [...set].sort() + }, [rows]) + + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.name.toLowerCase().includes(s) || + row.namespace.toLowerCase().includes(s) || + row.id.toLowerCase().includes(s) + ) + }, + matchExtra: (row) => { + if (statusFilter && row.status !== statusFilter) return false + if (classFilter && row.storageClass !== classFilter) return false + return true + }, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading PVCs… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + + + + } + /> +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + )) + )} + +
+ No PVCs match the current filters. +
{row.name}{row.namespace}{row.capacity}{row.storageClass}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `PVC — ${openRow.name}` : ''} + > + {openRow ? ( + <> + + + + + + + } /> + + ) : null} + +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.test.tsx new file mode 100644 index 00000000..21320769 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.test.tsx @@ -0,0 +1,78 @@ +/** + * StorageClassesPage.test.tsx — placeholder lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { StorageClassesPage } from './StorageClassesPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const stRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/storage', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => stRoute, + path: '/storage-classes', + component: StorageClassesPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([stRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/storage/storage-classes'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('StorageClassesPage', () => { + it('renders header + empty state + docs link', async () => { + renderPage() + expect(await screen.findByTestId('cloud-storage-classes-page')).toBeTruthy() + expect(screen.getByTestId('cloud-storage-classes-empty')).toBeTruthy() + expect(screen.getByTestId('cloud-storage-classes-docs-link')).toBeTruthy() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.tsx new file mode 100644 index 00000000..3a3f0486 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/StorageClassesPage.tsx @@ -0,0 +1,19 @@ +/** + * StorageClassesPage — placeholder list page for + * /cloud/storage/storage-classes (P3 of #309). Pending the + * storage-class informer (#321). + */ + +import { CloudListPlaceholder } from '../cloud-list/CloudListPlaceholder' + +export function StorageClassesPage() { + return ( + + ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.test.tsx new file mode 100644 index 00000000..3fc776ad --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.test.tsx @@ -0,0 +1,85 @@ +/** + * VolumesPage.test.tsx — list-page lock-in. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' + +import { CloudPage } from '../CloudPage' +import { VolumesPage } from './VolumesPage' +import { infrastructureTopologyFixture } from '@/test/fixtures/infrastructure-topology.fixture' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +function renderPage() { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ events: [], state: undefined, done: false }), + } as unknown as Response)) as typeof fetch + + const rootRoute = createRootRoute({ component: () => }) + const cloudRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/cloud', + component: () => ( + + ), + }) + const stRoute = createRoute({ + getParentRoute: () => cloudRoute, + path: '/storage', + component: () => , + }) + const route = createRoute({ + getParentRoute: () => stRoute, + path: '/volumes', + component: VolumesPage, + }) + const tree = rootRoute.addChildren([cloudRoute.addChildren([stRoute.addChildren([route])])]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ['/provision/d-1/cloud/storage/volumes'], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + if (typeof window !== 'undefined') window.localStorage.clear() +}) +afterEach(() => cleanup()) + +describe('VolumesPage', () => { + it('renders 1 volume row from the fixture', async () => { + renderPage() + expect(await screen.findByTestId('cloud-volumes-row-vol-postgres-eu')).toBeTruthy() + expect(screen.getByTestId('cloud-volumes-count').textContent).toBe('1') + }) + + it('detail drawer surfaces capacity + attachment', async () => { + renderPage() + fireEvent.click(await screen.findByTestId('cloud-volumes-row-vol-postgres-eu')) + const body = screen.getByTestId('cloud-volumes-detail-body') + expect(body.textContent).toContain('50Gi') + expect(body.textContent).toContain('node-eu-w-0') + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.tsx new file mode 100644 index 00000000..10ddd943 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/cloud-storage/VolumesPage.tsx @@ -0,0 +1,193 @@ +/** + * VolumesPage — list view for /cloud/storage/volumes (P3 of #309). + * Reads data.storage.volumes from the shared infrastructure tree. + */ + +import { useMemo, useState } from 'react' +import { useCloud } from '../CloudPage' +import { + CloudListDetailDrawer, + CloudListHeader, + CloudListToolbar, + DetailRow, + EmptyState, + FilterPills, + Pagination, + SortableTH, + StatusPill, +} from '../cloud-list/cloudListShared' +import { CLOUD_LIST_CSS } from '../cloud-list/cloudListCss' +import { useCloudListState } from '../cloud-list/useCloudListState' +import type { SortState } from '../cloud-list/sortState' +import type { TopologyStatus, VolumeItem } from '@/lib/infrastructure.types' + +const STATUSES: readonly TopologyStatus[] = ['healthy', 'degraded', 'failed', 'unknown'] +const TEST_ID = 'cloud-volumes' + +function compare(a: VolumeItem, b: VolumeItem, sort: SortState): number { + const dir = sort.dir === 'asc' ? 1 : -1 + switch (sort.column) { + case 'name': + return dir * a.name.localeCompare(b.name) + case 'region': + return dir * a.region.localeCompare(b.region) + case 'attachedTo': + return dir * (a.attachedTo ?? '').localeCompare(b.attachedTo ?? '') + case 'capacity': + return dir * a.capacity.localeCompare(b.capacity) + case 'status': + return dir * a.status.localeCompare(b.status) + default: + return 0 + } +} + +export function VolumesPage() { + const { deploymentId, data, isLoading } = useCloud() + const rows = useMemo(() => data?.storage?.volumes ?? [], [data]) + + const [statusFilter, setStatusFilter] = useState('') + const [regionFilter, setRegionFilter] = useState('') + const regionOptions = useMemo(() => { + const set = new Set() + for (const r of rows) set.add(r.region) + return [...set].sort() + }, [rows]) + + const list = useCloudListState({ + rows, + matchSearch: (row, q) => { + if (!q.trim()) return true + const s = q.toLowerCase() + return ( + row.name.toLowerCase().includes(s) || + row.id.toLowerCase().includes(s) || + (row.attachedTo ?? '').toLowerCase().includes(s) + ) + }, + matchExtra: (row) => { + if (statusFilter && row.status !== statusFilter) return false + if (regionFilter && row.region !== regionFilter) return false + return true + }, + comparator: compare, + defaultSort: { column: 'name', dir: 'asc' }, + }) + + const [openRow, setOpenRow] = useState(null) + + return ( +
+ + + + {isLoading ? ( +
+ Loading volumes… +
+ ) : rows.length === 0 ? ( + + ) : ( + <> + + + + + } + /> +
+ + + + + + + + + + + + {list.visible.length === 0 ? ( + + + + ) : ( + list.visible.map((row) => ( + setOpenRow(row)} + > + + + + + + + )) + )} + +
+ No volumes match the current filters. +
{row.name}{row.region}{row.attachedTo || 'detached'}{row.capacity}
+
+ + + )} + + setOpenRow(null)} + title={openRow ? `Volume — ${openRow.name}` : ''} + > + {openRow ? ( + <> + + + + + + } /> + + ) : null} + +
+ ) +}