feat(catalyst-ui): per-resource Cloud list pages (P3 of #309)

Replaces the three flat-dump sub-pages (CloudCompute / CloudNetwork
/ CloudStorage) with twelve per-resource list pages stacked behind
three category landing pages, all wired into the router under the
new /cloud/<category>/<resource> URL shape.

Pattern parallels JobsPage/JobsTable: header + count badge + back
link, search + filter pills, sortable columns, click-row → slide-in
detail drawer, empty-state and pagination. Status colour palette
matches JobsTable exactly. Source data is the existing
getHierarchicalInfrastructure() tree exposed via the useCloud()
context P1 set up; per-page flatten lambdas pluck the relevant rows.

Resource types shipped (12):

  Compute    Clusters, vClusters, Node Pools, Worker Nodes (real data)
  Network    Load Balancers (real data) + Services / Ingresses /
             DNS Zones (placeholder pages awaiting #321 informers)
  Storage    PVCs, Buckets, Volumes (real data) + Storage Classes
             (placeholder)

Category landing pages (CloudComputePage / CloudNetworkPage /
CloudStoragePage) replace the deleted CloudCompute.tsx /
CloudNetwork.tsx / CloudStorage.tsx; each shows a tile grid with
counts derived from the same shared tree.

Shared scaffolding lives under cloud-list/: typed sort state,
useCloudListState hook (search + sort + filter + pagination, no
setState-in-effect), CSS string, and presentational primitives
(CloudListHeader, CloudListToolbar, FilterPills, SortableTH,
CloudListDetailDrawer, DetailRow, EmptyState, Pagination,
StatusPill). The hook + CSS + sort types live in dedicated files
so the components file stays react-refresh clean.

CloudPage's Sovereign-switcher path-preserving regex was extended
to capture the deepest sub-route (e.g. /cloud/compute/clusters
follows the operator across deployments). Router gains 12 child
routes under the existing /cloud/{compute,network,storage} parents.

Lint goes from 34 baseline errors to 32. All 534 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-04-30 22:06:48 +02:00 committed by e3mrah
parent 05ed026fab
commit e60cc2ca7f
41 changed files with 4168 additions and 1344 deletions

View File

@ -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/<category>/<kind>) ── */
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,

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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<string[]>([])
const [selectedNodes, setSelectedNodes] = useState<string[]>([])
const [scalePool, setScalePool] = useState<PoolRow | null>(null)
const [changeSku, setChangeSku] = useState<PoolRow | null>(null)
const [drainNode, setDrainNode] = useState<NodeRow | null>(null)
const [addPoolFor, setAddPoolFor] = useState<{ cluster: ClusterSpec; provider: CloudProvider } | null>(null)
const isEmpty = !isLoading && pools.length === 0 && nodes.length === 0
return (
<div data-testid="cloud-compute">
{isLoading && (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="cloud-compute-loading"
>
Loading compute resources
</div>
)}
{isEmpty && (
<div className="infra-empty" data-testid="cloud-compute-empty">
<p className="title">No clusters or worker nodes yet.</p>
<p className="sub">
Once the Sovereign cluster comes up, every k3s cluster and node VM
will appear here.
</p>
</div>
)}
{!isEmpty && data && (
<>
<div className="infra-bulk-actions" data-testid="cloud-compute-bulk">
<span className="label">Bulk · {selectedPools.length} pool{selectedPools.length === 1 ? '' : 's'} / {selectedNodes.length} node{selectedNodes.length === 1 ? '' : 's'}</span>
<button
type="button"
data-testid="cloud-compute-bulk-scale"
disabled={selectedPools.length !== 1}
onClick={() => {
const row = pools.find((p) => p.pool.id === selectedPools[0])
if (row) setScalePool(row)
}}
>
Scale
</button>
<button
type="button"
data-testid="cloud-compute-bulk-drain"
disabled={selectedNodes.length !== 1}
onClick={() => {
const row = nodes.find((n) => n.node.id === selectedNodes[0])
if (row) setDrainNode(row)
}}
>
Drain
</button>
</div>
<section className="infra-section" data-testid="cloud-pools-section">
<h2>
Node Pools <span className="count" data-testid="cloud-pools-count">{pools.length}</span>
</h2>
<FlatTable
testId="cloud-pools-table"
headers={['', 'Cluster', 'Pool', 'SKU', 'Replicas', 'Status', '']}
>
{pools.map(({ pool, cluster, region }) => (
<tr key={pool.id} data-testid={`cloud-pool-row-${pool.id}`}>
<td>
<input
type="checkbox"
checked={selectedPools.includes(pool.id)}
onChange={(e) => toggle(e.target.checked, pool.id, setSelectedPools)}
data-testid={`cloud-pool-row-${pool.id}-select`}
/>
</td>
<td>
<Link
to={`/provision/$deploymentId/cloud/architecture` as never}
params={{ deploymentId } as never}
data-testid={`cloud-pool-row-${pool.id}-cluster-link`}
>
{cluster.name}
</Link>
<div style={{ fontSize: '0.7rem', color: 'var(--color-text-dim)' }}>
{region.providerRegion}
</div>
</td>
<td style={{ fontFamily: 'monospace' }}>{pool.id}</td>
<td>{pool.sku}</td>
<td>{pool.replicas}</td>
<td>
<StatusBadge status={pool.status} />
</td>
<td style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setScalePool({ pool, cluster, region })}
data-testid={`cloud-pool-row-${pool.id}-scale`}
style={rowBtn}
>
Scale
</button>
<button
type="button"
onClick={() => setChangeSku({ pool, cluster, region })}
data-testid={`cloud-pool-row-${pool.id}-change-sku`}
style={rowBtn}
>
Change SKU
</button>
</td>
</tr>
))}
{pools.length === 0 && (
<tr>
<td colSpan={7} style={{ color: 'var(--color-text-dim)', textAlign: 'center', padding: 12 }}>
No pools reported.
</td>
</tr>
)}
</FlatTable>
{/* Per-cluster Add Pool buttons */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{data.topology.regions.flatMap((region) =>
region.clusters.map((cluster) => (
<button
key={cluster.id}
type="button"
style={{
...rowBtn,
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
}}
onClick={() =>
setAddPoolFor({
cluster,
provider: region.provider as CloudProvider,
})
}
data-testid={`cloud-pool-add-for-${cluster.id}`}
>
+ Add pool to {cluster.name}
</button>
)),
)}
</div>
</section>
<section className="infra-section" data-testid="cloud-nodes-section">
<h2>
Worker Nodes <span className="count" data-testid="cloud-nodes-count">{nodes.length}</span>
</h2>
<FlatTable
testId="cloud-nodes-table"
headers={['', 'Cluster', 'Node', 'SKU', 'Role', 'IP', 'Status', '']}
>
{nodes.map(({ node, cluster }) => (
<tr key={node.id} data-testid={`cloud-node-row-${node.id}`}>
<td>
<input
type="checkbox"
checked={selectedNodes.includes(node.id)}
onChange={(e) => toggle(e.target.checked, node.id, setSelectedNodes)}
data-testid={`cloud-node-row-${node.id}-select`}
/>
</td>
<td>{cluster.name}</td>
<td>{node.name}</td>
<td>{node.sku}</td>
<td>{node.role}</td>
<td style={{ fontFamily: 'monospace' }}>{node.ip}</td>
<td>
<StatusBadge status={node.status} />
</td>
<td style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setDrainNode({ node, cluster, region: data.topology.regions.find((r) => r.clusters.some((c) => c.id === cluster.id))! })}
data-testid={`cloud-node-row-${node.id}-drain`}
style={rowBtn}
>
Drain
</button>
</td>
</tr>
))}
{nodes.length === 0 && (
<tr>
<td colSpan={8} style={{ color: 'var(--color-text-dim)', textAlign: 'center', padding: 12 }}>
No nodes reported.
</td>
</tr>
)}
</FlatTable>
</section>
</>
)}
{scalePool && (
<ScalePoolModal
open
deploymentId={deploymentId}
pool={scalePool.pool}
onClose={() => setScalePool(null)}
/>
)}
{changeSku && (
<ChangeSKUModal
open
deploymentId={deploymentId}
pool={changeSku.pool}
regionProvider={changeSku.region.provider as CloudProvider}
onClose={() => setChangeSku(null)}
/>
)}
{drainNode && (
<NodeActionConfirm
open
deploymentId={deploymentId}
node={drainNode.node}
action="drain"
onClose={() => setDrainNode(null)}
/>
)}
{addPoolFor && (
<AddNodePoolModal
open
deploymentId={deploymentId}
clusterId={addPoolFor.cluster.id}
regionProvider={addPoolFor.provider}
onClose={() => setAddPoolFor(null)}
/>
)}
</div>
)
}
function toggle(checked: boolean, id: string, setter: React.Dispatch<React.SetStateAction<string[]>>) {
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)))
}
function FlatTable({
testId,
headers,
children,
}: {
testId: string
headers: string[]
children: React.ReactNode
}) {
return (
<table
data-testid={testId}
style={{
width: '100%',
borderCollapse: 'separate',
borderSpacing: 0,
fontSize: '0.82rem',
}}
>
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
style={{
textAlign: 'left',
fontWeight: 600,
fontSize: '0.72rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--color-text-dim)',
padding: '6px 8px',
borderBottom: '1px solid var(--color-border)',
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody style={{ verticalAlign: 'middle' }}>{children}</tbody>
<style>{`
tbody tr td {
padding: 8px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
}
tbody tr:hover { background: var(--color-bg-2); }
`}</style>
</table>
)
}
function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) {
return (
<span
data-status={status}
style={{
display: 'inline-block',
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 700,
padding: '0.1rem 0.45rem',
borderRadius: 999,
background:
status === 'healthy'
? 'color-mix(in srgb, var(--color-success) 18%, transparent)'
: status === 'degraded'
? 'color-mix(in srgb, var(--color-warn) 18%, transparent)'
: status === 'failed'
? 'color-mix(in srgb, var(--color-danger) 18%, transparent)'
: 'color-mix(in srgb, var(--color-text-dim) 18%, transparent)',
color:
status === 'healthy'
? 'var(--color-success)'
: status === 'degraded'
? 'var(--color-warn)'
: status === 'failed'
? 'var(--color-danger)'
: 'var(--color-text-dim)',
}}
>
{status}
</span>
)
}
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',
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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<RegionSpec | null>(null)
const [addPeeringOpen, setAddPeeringOpen] = useState(false)
const [editFirewall, setEditFirewall] = useState<FirewallSpec | null>(null)
const [editDNS, setEditDNS] = useState<string | null>(null)
const isEmpty =
!isLoading && lbs.length === 0 && peerings.length === 0 && firewalls.length === 0
return (
<div data-testid="cloud-network">
{isLoading && (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="cloud-network-loading">
Loading network resources
</div>
)}
{isEmpty && (
<div className="infra-empty" data-testid="cloud-network-empty">
<p className="title">No network resources yet.</p>
<p className="sub">Load balancers, peerings, firewalls and DNS zones will appear here.</p>
</div>
)}
{!isEmpty && data && (
<>
<div className="infra-bulk-actions" data-testid="cloud-network-bulk">
<span className="label">Bulk actions</span>
<button
type="button"
className="primary"
data-testid="cloud-network-add-peering"
onClick={() => setAddPeeringOpen(true)}
>
+ Add peering
</button>
<button
type="button"
data-testid="cloud-network-edit-dns"
onClick={() => setEditDNS(`zone-${data.topology.regions[0]?.id ?? 'default'}`)}
>
Edit DNS zone
</button>
</div>
<section className="infra-section" data-testid="cloud-lbs-section">
<h2>
Load Balancers <span className="count" data-testid="cloud-lbs-count">{lbs.length}</span>
</h2>
<FlatTable testId="cloud-lbs-table" headers={['Name', 'Public IP', 'Listeners', 'Targets', 'Region', 'Status', '']}>
{lbs.map(({ lb, region }) => (
<tr key={lb.id} data-testid={`cloud-lb-row-${lb.id}`}>
<td>{lb.name}</td>
<td style={{ fontFamily: 'monospace' }}>{lb.publicIP}</td>
<td>{lb.listeners.map((l) => `${l.protocol}:${l.port}`).join(', ')}</td>
<td>{`${lb.targets.filter((t) => t.status === 'healthy').length}/${lb.targets.length}`}</td>
<td>{region.providerRegion}</td>
<td>
<StatusBadge status={lb.status} />
</td>
<td />
</tr>
))}
</FlatTable>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
{data.topology.regions.map((r) => (
<button
key={r.id}
type="button"
style={{ ...rowBtn, borderColor: 'var(--color-accent)', color: 'var(--color-accent)' }}
onClick={() => setAddLBFor(r)}
data-testid={`cloud-network-add-lb-${r.id}`}
>
+ Add LB to {r.name}
</button>
))}
</div>
</section>
<section className="infra-section" data-testid="cloud-peerings-section">
<h2>
Peerings <span className="count" data-testid="cloud-peerings-count">{peerings.length}</span>
</h2>
<FlatTable testId="cloud-peerings-table" headers={['Name', 'VPCs', 'Subnets', 'Region', 'Status']}>
{peerings.map(({ peering, region }) => (
<tr key={peering.id} data-testid={`cloud-peering-row-${peering.id}`}>
<td>{peering.name}</td>
<td>{peering.vpcPair}</td>
<td style={{ fontFamily: 'monospace' }}>{peering.subnets}</td>
<td>{region.providerRegion}</td>
<td>
<StatusBadge status={peering.status} />
</td>
</tr>
))}
{peerings.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-dim)', padding: 12 }}>
No peerings yet.
</td>
</tr>
)}
</FlatTable>
</section>
<section className="infra-section" data-testid="cloud-firewalls-section">
<h2>
Firewalls <span className="count" data-testid="cloud-firewalls-count">{firewalls.length}</span>
</h2>
<FlatTable testId="cloud-firewalls-table" headers={['Name', 'Rules', 'Region', 'Status', '']}>
{firewalls.map(({ firewall, region }) => (
<tr key={firewall.id} data-testid={`cloud-firewall-row-${firewall.id}`}>
<td>{firewall.name}</td>
<td>{firewall.rules.length}</td>
<td>{region.providerRegion}</td>
<td>
<StatusBadge status={firewall.status} />
</td>
<td>
<button
type="button"
style={rowBtn}
onClick={() => setEditFirewall(firewall)}
data-testid={`cloud-firewall-row-${firewall.id}-edit`}
>
Edit rules
</button>
</td>
</tr>
))}
{firewalls.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-dim)', padding: 12 }}>
No firewalls yet.
</td>
</tr>
)}
</FlatTable>
</section>
</>
)}
{addLBFor && (
<AddLBModal
open
deploymentId={deploymentId}
regionId={addLBFor.id}
onClose={() => setAddLBFor(null)}
/>
)}
{addPeeringOpen && (
<AddPeeringModal
open
deploymentId={deploymentId}
networks={networks}
onClose={() => setAddPeeringOpen(false)}
/>
)}
{editFirewall && (
<EditFirewallRulesModal
open
deploymentId={deploymentId}
firewall={editFirewall}
onClose={() => setEditFirewall(null)}
/>
)}
{editDNS && (
<EditDNSRecordsModal
open
deploymentId={deploymentId}
zoneId={editDNS}
existingRecords={[]}
onClose={() => setEditDNS(null)}
/>
)}
</div>
)
}
function FlatTable({ testId, headers, children }: { testId: string; headers: string[]; children: React.ReactNode }) {
return (
<table data-testid={testId} style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0, fontSize: '0.82rem' }}>
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} style={{ textAlign: 'left', fontWeight: 600, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-dim)', padding: '6px 8px', borderBottom: '1px solid var(--color-border)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody style={{ verticalAlign: 'middle' }}>{children}</tbody>
<style>{`
tbody tr td { padding: 8px; border-bottom: 1px solid var(--color-border); color: var(--color-text); }
tbody tr:hover { background: var(--color-bg-2); }
`}</style>
</table>
)
}
function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) {
return (
<span data-status={status} style={{ display: 'inline-block', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 700, padding: '0.1rem 0.45rem', borderRadius: 999, background: status === 'healthy' ? 'color-mix(in srgb, var(--color-success) 18%, transparent)' : status === 'degraded' ? 'color-mix(in srgb, var(--color-warn) 18%, transparent)' : status === 'failed' ? 'color-mix(in srgb, var(--color-danger) 18%, transparent)' : 'color-mix(in srgb, var(--color-text-dim) 18%, transparent)', color: status === 'healthy' ? 'var(--color-success)' : status === 'degraded' ? 'var(--color-warn)' : status === 'failed' ? 'var(--color-danger)' : 'var(--color-text-dim)' }}>
{status}
</span>
)
}
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',
}

View File

@ -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,

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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<PVCItem | null>(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 (
<div data-testid="cloud-storage">
{isLoading && (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]" data-testid="cloud-storage-loading">
Loading storage resources
</div>
)}
{isEmpty && (
<div className="infra-empty" data-testid="cloud-storage-empty">
<p className="title">No storage resources yet.</p>
<p className="sub">PVCs, buckets and volumes will appear here as the cluster reports them.</p>
</div>
)}
{!isEmpty && (
<>
<div className="infra-bulk-actions" data-testid="cloud-storage-bulk">
<span className="label">Bulk · {selected.length} selected</span>
<button
type="button"
data-testid="cloud-storage-bulk-snapshot"
disabled={selected.filter((s) => s.kind === 'pvc').length === 0}
onClick={() => {
const pick = selected.find((s) => s.kind === 'pvc')
if (!pick) return
void pvcAction({ deploymentId, pvcId: pick.id, action: 'snapshot' }).catch(() => {})
setSelected([])
}}
>
Snapshot
</button>
<button
type="button"
data-testid="cloud-storage-bulk-expand"
disabled={selected.filter((s) => s.kind === 'pvc').length !== 1}
onClick={() => {
const pick = selected.find((s) => s.kind === 'pvc')
if (!pick) return
const target = pvcs.find((p) => p.id === pick.id)
if (target) setExpandPvc(target)
}}
>
Expand
</button>
<button
type="button"
data-testid="cloud-storage-bulk-delete"
disabled={selected.length !== 1}
onClick={() => {
const pick = selected[0]
if (!pick) return
if (pick.kind === 'pvc') {
const t = pvcs.find((p) => p.id === pick.id)
if (t) setDeleteRow({ resource: 'pvcs', id: t.id, label: t.name })
} else if (pick.kind === 'volume') {
const t = volumes.find((v) => v.id === pick.id)
if (t) setDeleteRow({ resource: 'volumes', id: t.id, label: t.name })
}
}}
>
Delete
</button>
</div>
<section className="infra-section" data-testid="cloud-pvcs-section">
<h2>
Persistent Volume Claims <span className="count" data-testid="cloud-pvcs-count">{pvcs.length}</span>
</h2>
<FlatTable
testId="cloud-pvcs-table"
headers={['', 'Name', 'Namespace', 'Capacity', 'Used', 'Class', 'Status', '']}
>
{pvcs.map((p) => (
<tr key={p.id} data-testid={`cloud-pvc-row-${p.id}`}>
<td>
<input
type="checkbox"
checked={selected.some((s) => s.kind === 'pvc' && s.id === p.id)}
onChange={(e) => toggle('pvc', p.id, e.target.checked)}
data-testid={`cloud-pvc-row-${p.id}-select`}
/>
</td>
<td>{p.name}</td>
<td>{p.namespace}</td>
<td>{p.capacity}</td>
<td>{p.used || '—'}</td>
<td>{p.storageClass}</td>
<td>
<StatusBadge status={p.status} />
</td>
<td style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setExpandPvc(p)}
data-testid={`cloud-pvc-row-${p.id}-expand`}
style={rowBtn}
>
Expand
</button>
<button
type="button"
onClick={() => void pvcAction({ deploymentId, pvcId: p.id, action: 'snapshot' }).catch(() => {})}
data-testid={`cloud-pvc-row-${p.id}-snapshot`}
style={rowBtn}
>
Snapshot
</button>
</td>
</tr>
))}
</FlatTable>
</section>
<section className="infra-section" data-testid="cloud-buckets-section">
<h2>
Object Buckets <span className="count" data-testid="cloud-buckets-count">{buckets.length}</span>
</h2>
<FlatTable
testId="cloud-buckets-table"
headers={['', 'Name', 'Endpoint', 'Capacity', 'Used', 'Retention']}
>
{buckets.map((b) => (
<tr key={b.id} data-testid={`cloud-bucket-row-${b.id}`}>
<td>
<input
type="checkbox"
checked={selected.some((s) => s.kind === 'bucket' && s.id === b.id)}
onChange={(e) => toggle('bucket', b.id, e.target.checked)}
data-testid={`cloud-bucket-row-${b.id}-select`}
/>
</td>
<td>{b.name}</td>
<td style={{ fontFamily: 'monospace' }}>{b.endpoint}</td>
<td>{b.capacity}</td>
<td>{b.used || '—'}</td>
<td>{b.retentionDays || 'indefinite'}</td>
</tr>
))}
</FlatTable>
</section>
<section className="infra-section" data-testid="cloud-volumes-section">
<h2>
Block Volumes <span className="count" data-testid="cloud-volumes-count">{volumes.length}</span>
</h2>
<FlatTable
testId="cloud-volumes-table"
headers={['', 'Name', 'Capacity', 'Region', 'Attached', 'Status']}
>
{volumes.map((v) => (
<tr key={v.id} data-testid={`cloud-volume-row-${v.id}`}>
<td>
<input
type="checkbox"
checked={selected.some((s) => s.kind === 'volume' && s.id === v.id)}
onChange={(e) => toggle('volume', v.id, e.target.checked)}
data-testid={`cloud-volume-row-${v.id}-select`}
/>
</td>
<td>{v.name}</td>
<td>{v.capacity}</td>
<td>{v.region}</td>
<td>{v.attachedTo || 'detached'}</td>
<td>
<StatusBadge status={v.status} />
</td>
</tr>
))}
</FlatTable>
</section>
</>
)}
{expandPvc && (
<ExpandPVCModal
deploymentId={deploymentId}
pvc={expandPvc}
onClose={() => setExpandPvc(null)}
/>
)}
{deleteRow && (
<DeleteCascadeConfirm
open
deploymentId={deploymentId}
resource={deleteRow.resource}
resourceId={deleteRow.id}
resourceLabel={deleteRow.label}
onClose={() => setDeleteRow(null)}
/>
)}
</div>
)
}
function ExpandPVCModal({
deploymentId,
pvc,
onClose,
}: {
deploymentId: string
pvc: PVCItem
onClose: () => void
}) {
const [capacity, setCapacity] = useState(pvc.capacity)
const [submitting, setSubmitting] = useState(false)
return (
<ModalShell
id="expand-pvc"
open
title="Expand PVC"
subtitle={`PVC ${pvc.name}`}
onClose={onClose}
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{
label: 'Expand',
onClick: async () => {
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,
}}
>
<FormRow label="Current capacity">
<TextInput value={pvc.capacity} onChange={() => {}} testId="expand-pvc-modal-current" />
</FormRow>
<FormRow label="New capacity" hint="Format like 20Gi, 500Gi.">
<TextInput value={capacity} onChange={setCapacity} testId="expand-pvc-modal-capacity" />
</FormRow>
</ModalShell>
)
}
function FlatTable({ testId, headers, children }: { testId: string; headers: string[]; children: React.ReactNode }) {
return (
<table data-testid={testId} style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0, fontSize: '0.82rem' }}>
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} style={{ textAlign: 'left', fontWeight: 600, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-dim)', padding: '6px 8px', borderBottom: '1px solid var(--color-border)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody style={{ verticalAlign: 'middle' }}>{children}</tbody>
<style>{`
tbody tr td { padding: 8px; border-bottom: 1px solid var(--color-border); color: var(--color-text); }
tbody tr:hover { background: var(--color-bg-2); }
`}</style>
</table>
)
}
function StatusBadge({ status }: { status: 'healthy' | 'degraded' | 'failed' | 'unknown' }) {
return (
<span data-status={status} style={{ display: 'inline-block', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 700, padding: '0.1rem 0.45rem', borderRadius: 999, background: status === 'healthy' ? 'color-mix(in srgb, var(--color-success) 18%, transparent)' : status === 'degraded' ? 'color-mix(in srgb, var(--color-warn) 18%, transparent)' : status === 'failed' ? 'color-mix(in srgb, var(--color-danger) 18%, transparent)' : 'color-mix(in srgb, var(--color-text-dim) 18%, transparent)', color: status === 'healthy' ? 'var(--color-success)' : status === 'degraded' ? 'var(--color-warn)' : status === 'failed' ? 'var(--color-danger)' : 'var(--color-text-dim)' }}>
{status}
</span>
)
}
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',
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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$/)
})
})

View File

@ -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 <Link> 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<ComputeTile['id'], number> = {
'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 (
<div data-testid="cloud-compute-page">
<style>{CLOUD_LIST_CSS}</style>
<header className="mb-3">
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid="cloud-compute-page-title"
>
Compute
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Clusters, vClusters, node pools and worker nodes for this Sovereign.
</p>
</header>
{isLoading ? (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="cloud-compute-page-loading"
>
Loading compute resources
</div>
) : (
<div className="cloud-list-tile-grid" data-testid="cloud-compute-page-tiles">
{COMPUTE_TILES.map((tile) => (
<Link
key={tile.id}
to={`/provision/$deploymentId/cloud/compute/${tile.id}` as never}
params={{ deploymentId } as never}
className="cloud-list-tile"
data-testid={`cloud-compute-page-tile-${tile.id}`}
>
<div className="cloud-list-tile-name">
<span>{tile.label}</span>
<span
className="cloud-list-tile-count"
data-testid={`cloud-compute-page-tile-${tile.id}-count`}
>
{counts[tile.id]}
</span>
</div>
<p className="cloud-list-tile-tagline">{tile.tagline}</p>
</Link>
))}
</div>
)}
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
const computeRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/compute',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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<typeof useCloud>['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<string[]>(() => {
const set = new Set<string>()
for (const r of rows) set.add(r.region.providerRegion)
return [...set].sort()
}, [rows])
const [statusFilter, setStatusFilter] = useState<TopologyStatus | ''>('')
const [regionFilter, setRegionFilter] = useState<string>('')
const list = useCloudListState<ClusterRow>({
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<ClusterRow | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Clusters"
tagline="k3s / k8s control planes — one row per cluster across all regions."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading clusters
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No clusters yet."
body="Once the Sovereign control plane comes up, every k3s cluster will appear here."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<>
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
<FilterPills
label="Region"
options={regionOptions}
selected={regionFilter}
onChange={setRegionFilter}
testId={`${TEST_ID}-filter-region`}
/>
</>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-region`} column="region" label="Region" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-provider`} column="provider" label="Provider" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-type`} column="type" label="Type" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-nodeCount`} column="nodeCount" label="Nodes" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-vclusterCount`} column="vclusterCount" label="vClusters" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={7} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No clusters match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.cluster.name}</td>
<td className="cloud-list-cell">{row.region.providerRegion}</td>
<td className="cloud-list-cell">{row.region.provider}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.cluster.version}</td>
<td className="cloud-list-cell"><StatusPill status={row.cluster.status} /></td>
<td className="cloud-list-cell">{row.cluster.nodeCount}</td>
<td className="cloud-list-cell">{row.cluster.vclusters?.length ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Cluster — ${openRow.cluster.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.cluster.name} />
<DetailRow label="ID" value={openRow.cluster.id} mono />
<DetailRow label="Region" value={openRow.region.providerRegion} />
<DetailRow label="Provider" value={openRow.region.provider} />
<DetailRow label="Type" value={openRow.cluster.version} mono />
<DetailRow label="Status" value={<StatusPill status={openRow.cluster.status} />} />
<DetailRow label="Worker count" value={openRow.region.workerCount} />
<DetailRow label="Total nodes" value={openRow.cluster.nodeCount} />
<DetailRow label="Node pools" value={openRow.cluster.nodePools?.length ?? 0} />
<DetailRow label="vClusters" value={openRow.cluster.vclusters?.length ?? 0} />
<DetailRow label="Load balancers" value={openRow.cluster.loadBalancers?.length ?? 0} />
<DetailRow label="Worker SKU" value={openRow.region.skuWorker} mono />
<DetailRow label="Control-plane SKU" value={openRow.region.skuCp} mono />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const computeRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/compute',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<typeof useCloud>['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<TopologyStatus | ''>('')
const list = useCloudListState<NodePoolRow>({
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<NodePoolRow | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Node Pools"
tagline="Worker pools grouped by SKU + role; one row per pool across all clusters."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading node pools
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No node pools yet."
body="Pools appear once their cluster has scheduled at least one worker."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-parentCluster`} column="parentCluster" label="Parent cluster" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-sku`} column="sku" label="Machine type" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-replicas`} column="replicas" label="Replicas" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No node pools match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name cloud-list-cell-mono">{row.pool.id}</td>
<td className="cloud-list-cell">{row.cluster.name}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.pool.sku}</td>
<td className="cloud-list-cell">{row.pool.replicas}</td>
<td className="cloud-list-cell"><StatusPill status={row.pool.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Node Pool — ${openRow.pool.id}` : ''}
>
{openRow ? (
<>
<DetailRow label="Pool ID" value={openRow.pool.id} mono />
<DetailRow label="Machine type" value={openRow.pool.sku} mono />
<DetailRow label="Replicas" value={openRow.pool.replicas} />
<DetailRow label="Status" value={<StatusPill status={openRow.pool.status} />} />
<DetailRow label="Parent cluster" value={openRow.cluster.name} />
<DetailRow label="Cluster ID" value={openRow.cluster.id} mono />
<DetailRow label="Region" value={openRow.region.providerRegion} />
<DetailRow label="Provider" value={openRow.region.provider} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
const computeRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/compute',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<typeof useCloud>['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<TopologyStatus | ''>('')
const list = useCloudListState<VClusterRow>({
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<VClusterRow | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="vClusters"
tagline="Logical isolation slices (DMZ / RTZ / MGMT) inside each physical cluster."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading vClusters
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No vClusters yet."
body="vClusters are provisioned alongside their parent k3s cluster."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-parentCluster`} column="parentCluster" label="Parent cluster" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-region`} column="region" label="Region" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-isolation`} column="isolation" label="Isolation" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No vClusters match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.vcluster.name}</td>
<td className="cloud-list-cell">{row.cluster.name}</td>
<td className="cloud-list-cell">{row.region.providerRegion}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.vcluster.isolationMode}</td>
<td className="cloud-list-cell"><StatusPill status={row.vcluster.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `vCluster — ${openRow.vcluster.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.vcluster.name} />
<DetailRow label="ID" value={openRow.vcluster.id} mono />
<DetailRow label="Isolation" value={openRow.vcluster.isolationMode} mono />
<DetailRow label="Status" value={<StatusPill status={openRow.vcluster.status} />} />
<DetailRow label="Parent cluster" value={openRow.cluster.name} />
<DetailRow label="Cluster ID" value={openRow.cluster.id} mono />
<DetailRow label="Region" value={openRow.region.providerRegion} />
<DetailRow label="Provider" value={openRow.region.provider} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const computeRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/compute',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<typeof useCloud>['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<TopologyStatus | ''>('')
const [roleFilter, setRoleFilter] = useState<string>('')
const roleOptions = useMemo<string[]>(() => {
const set = new Set<string>()
for (const r of rows) set.add(r.node.role)
return [...set].sort()
}, [rows])
const list = useCloudListState<NodeRow>({
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<NodeRow | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Worker Nodes"
tagline="Individual VMs / kubelets — one row per node across every cluster."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading worker nodes
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No worker nodes yet."
body="Nodes appear here as soon as their kubelet registers with the cluster."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<>
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
<FilterPills
label="Role"
options={roleOptions}
selected={roleFilter}
onChange={setRoleFilter}
testId={`${TEST_ID}-filter-role`}
/>
</>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-hostname`} column="hostname" label="Hostname" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-parentCluster`} column="parentCluster" label="Parent cluster" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-role`} column="role" label="Role" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-kubeletVersion`} column="kubeletVersion" label="Kubelet" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-sku`} column="sku" label="SKU" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={6} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No worker nodes match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name cloud-list-cell-mono">{row.node.name}</td>
<td className="cloud-list-cell">{row.cluster.name}</td>
<td className="cloud-list-cell">{row.node.role}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.cluster.version}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.node.sku}</td>
<td className="cloud-list-cell"><StatusPill status={row.node.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Worker Node — ${openRow.node.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Hostname" value={openRow.node.name} mono />
<DetailRow label="ID" value={openRow.node.id} mono />
<DetailRow label="Role" value={openRow.node.role} />
<DetailRow label="SKU" value={openRow.node.sku} mono />
<DetailRow label="IP" value={openRow.node.ip} mono />
<DetailRow label="Status" value={<StatusPill status={openRow.node.status} />} />
<DetailRow label="Kubelet" value={openRow.cluster.version} mono />
<DetailRow label="Parent cluster" value={openRow.cluster.name} />
<DetailRow label="Region" value={openRow.region.providerRegion} />
<DetailRow label="Provider" value={openRow.region.provider} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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 (
<div data-testid={`${testId}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={testId}
title={title}
tagline={tagline}
count={0}
deploymentId={deploymentId}
/>
<EmptyState
testId={`${testId}-empty`}
title={`No ${title.toLowerCase()} yet.`}
body={
<>
{bodyText}
{docsHref ? (
<>
{' '}
<a
href={docsHref}
target="_blank"
rel="noreferrer"
className="text-[var(--color-accent)] underline"
data-testid={`${testId}-docs-link`}
>
Tracking issue
</a>
</>
) : null}
</>
}
/>
</div>
)
}

View File

@ -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;
}
`

View File

@ -0,0 +1,355 @@
/**
* cloudListShared shared scaffolding for the Cloud per-resource list
* pages (P3 of #309). Every list page ships:
* <CloudListHeader /> H1 + count badge + tagline + back-link
* <CloudListToolbar /> search + filter pills (kind-appropriate)
* <CloudListTable /> sortable columns, click-row drawer
* <CloudListDetail /> 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 (
<header
className="mb-3 flex items-start justify-between gap-4"
data-testid={`${testId}-header`}
>
<div>
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid={`${testId}-title`}
>
{title}
<span
className="ml-2 inline-flex items-center rounded-full bg-[color-mix(in_srgb,var(--color-border)_60%,transparent)] px-2 py-0.5 align-middle text-xs font-semibold text-[var(--color-text-dim)]"
data-testid={`${testId}-count`}
>
{count}
</span>
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">{tagline}</p>
</div>
<Link
to={'/provision/$deploymentId/cloud' as never}
params={{ deploymentId } as never}
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid={`${testId}-back`}
>
Back to Cloud
</Link>
</header>
)
}
/* ── Status pill — same palette as JobsTable / CloudCompute ─────── */
export function StatusPill({ status }: { status: TopologyStatus }) {
return (
<span
data-status={status}
className="cloud-list-status"
>
{status}
</span>
)
}
/* ── Filter pill (status / region / etc) ────────────────────────── */
interface FilterPillsProps<T extends string> {
label: string
options: readonly T[]
selected: T | ''
onChange: (next: T | '') => void
testId: string
}
export function FilterPills<T extends string>({
label,
options,
selected,
onChange,
testId,
}: FilterPillsProps<T>) {
return (
<label className="cloud-list-filter-label">
<span className="cloud-list-filter-caption">{label}</span>
<select
value={selected}
onChange={(e) => onChange((e.target.value as T) || '')}
className="cloud-list-filter-select"
data-testid={testId}
aria-label={`Filter by ${label.toLowerCase()}`}
>
<option value="">All</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
)
}
/* ── 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 (
<div className="cloud-list-toolbar" data-testid={`${testId}-toolbar`}>
<div className="cloud-list-search-wrap">
<svg className="cloud-list-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.35-4.35" strokeLinecap="round" />
</svg>
<input
type="search"
placeholder="Search by name…"
className="cloud-list-search-input"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
data-testid={`${testId}-search`}
aria-label="Search resources"
/>
</div>
<div className="cloud-list-filters">
{filters}
<span
className="cloud-list-result-count"
data-testid={`${testId}-result-count`}
aria-live="polite"
>
{visibleCount}/{totalCount}
</span>
</div>
</div>
)
}
/* ── 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 (
<th
data-col={column}
className="cloud-list-th cloud-list-th-sortable"
onClick={() => {
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 : ''}
>
<span className="cloud-list-th-content">
{label}
{active ? (
<span className="cloud-list-th-arrow" aria-hidden>
{state.dir === 'asc' ? '↑' : '↓'}
</span>
) : null}
</span>
</th>
)
}
/* ── 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 (
<div className="cloud-list-pagination" data-testid={`${testId}-pagination`}>
<button
type="button"
onClick={() => onPageChange(Math.max(0, page - 1))}
disabled={page <= 0}
data-testid={`${testId}-pagination-prev`}
>
Prev
</button>
<span className="cloud-list-pagination-label" data-testid={`${testId}-pagination-page`}>
Page {page + 1} of {pageCount}
</span>
<button
type="button"
onClick={() => onPageChange(Math.min(pageCount - 1, page + 1))}
disabled={page >= pageCount - 1}
data-testid={`${testId}-pagination-next`}
>
Next
</button>
</div>
)
}
/* ── 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 (
<div
className="cloud-list-drawer-backdrop"
data-testid={`${testId}-backdrop`}
onClick={onClose}
role="presentation"
>
<aside
className="cloud-list-drawer"
data-testid={testId}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<header className="cloud-list-drawer-header">
<h2 className="cloud-list-drawer-title">{title}</h2>
<button
type="button"
onClick={onClose}
className="cloud-list-drawer-close"
data-testid={`${testId}-close`}
aria-label="Close detail"
>
×
</button>
</header>
<div className="cloud-list-drawer-body" data-testid={`${testId}-body`}>
{children}
</div>
</aside>
</div>
)
}
/* ── 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 (
<div className="cloud-list-detail-row" data-testid={testId}>
<span className="cloud-list-detail-row-label">{label}</span>
<span className={`cloud-list-detail-row-value ${mono ? 'cloud-list-detail-row-mono' : ''}`}>
{value}
</span>
</div>
)
}
/* ── Empty state ────────────────────────────────────────────────── */
interface EmptyStateProps {
testId: string
title: string
body: ReactNode
}
export function EmptyState({ testId, title, body }: EmptyStateProps) {
return (
<div className="cloud-list-empty" data-testid={testId}>
<p className="cloud-list-empty-title">{title}</p>
<p className="cloud-list-empty-body">{body}</p>
</div>
)
}

View File

@ -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
}

View File

@ -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<T> {
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<T>(opts: UseListStateOpts<T>) {
const [search, setSearch] = useState('')
const [sort, setSort] = useState<SortState>(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,
}
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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('—')
})
})

View File

@ -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<NetworkTile['id'], number | null> = {
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 (
<div data-testid="cloud-network-page">
<style>{CLOUD_LIST_CSS}</style>
<header className="mb-3">
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid="cloud-network-page-title"
>
Network
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Services, ingresses, load balancers and DNS zones for this Sovereign.
</p>
</header>
{isLoading ? (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="cloud-network-page-loading"
>
Loading network resources
</div>
) : (
<div className="cloud-list-tile-grid" data-testid="cloud-network-page-tiles">
{NETWORK_TILES.map((tile) => {
const c = counts[tile.id]
return (
<Link
key={tile.id}
to={`/provision/$deploymentId/cloud/network/${tile.id}` as never}
params={{ deploymentId } as never}
className="cloud-list-tile"
data-testid={`cloud-network-page-tile-${tile.id}`}
>
<div className="cloud-list-tile-name">
<span>{tile.label}</span>
<span
className="cloud-list-tile-count"
data-testid={`cloud-network-page-tile-${tile.id}-count`}
>
{tile.hasData && c !== null ? c : '—'}
</span>
</div>
<p className="cloud-list-tile-tagline">{tile.tagline}</p>
</Link>
)
})}
</div>
)}
</div>
)
}

View File

@ -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 (
<CloudListPlaceholder
testId="cloud-dns-zones"
title="DNS Zones"
tagline="DNS zones managed by the Sovereign control plane (Dynadot, PowerDNS)."
bodyText="DNS-zone data is not in the current informer set. The external-dns informer rollout is tracked separately."
docsHref="https://github.com/openova-io/openova/issues/321"
/>
)
}

View File

@ -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 (
<CloudListPlaceholder
testId="cloud-ingresses"
title="Ingresses"
tagline="HTTP/HTTPS ingresses fronting workloads across clusters."
bodyText="Ingress data is not in the current informer set. The ingress informer rollout is tracked separately."
docsHref="https://github.com/openova-io/openova/issues/321"
/>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const networkRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/network',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<typeof useCloud>['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<TopologyStatus | ''>('')
const list = useCloudListState<LBRow>({
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<LBRow | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Load Balancers"
tagline="Cloud-provisioned LBs fronting clusters; one row per LB across all regions."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading load balancers
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No load balancers yet."
body="Once Service-type=LoadBalancer is provisioned, balancers appear here."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-region`} column="region" label="Region" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-listeners`} column="listeners" label="Listeners" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-targets`} column="targets" label="Targets" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No load balancers match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => {
const healthy = row.lb.targets?.filter((t) => t.status === 'healthy').length ?? 0
const total = row.lb.targets?.length ?? 0
return (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.lb.name}</td>
<td className="cloud-list-cell">{row.region.providerRegion}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{formatListeners(row.lb)}</td>
<td className="cloud-list-cell">{`${healthy}/${total}`}</td>
<td className="cloud-list-cell"><StatusPill status={row.lb.status} /></td>
</tr>
)
})
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Load Balancer — ${openRow.lb.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.lb.name} />
<DetailRow label="ID" value={openRow.lb.id} mono />
<DetailRow label="Public IP" value={openRow.lb.publicIP} mono />
<DetailRow label="Listeners" value={formatListeners(openRow.lb)} mono />
<DetailRow
label="Targets"
value={`${openRow.lb.targets?.filter((t) => t.status === 'healthy').length ?? 0}/${openRow.lb.targets?.length ?? 0} healthy`}
/>
<DetailRow label="Status" value={<StatusPill status={openRow.lb.status} />} />
<DetailRow label="Region" value={openRow.region.providerRegion} />
<DetailRow label="Provider" value={openRow.region.provider} />
<DetailRow label="Parent cluster" value={openRow.cluster.name} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const networkRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/network',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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 (
<CloudListPlaceholder
testId="cloud-services"
title="Services"
tagline="Per-namespace Kubernetes services across all clusters."
bodyText="Services data is not in the current informer set. The service informer rollout is tracked separately."
docsHref="https://github.com/openova-io/openova/issues/321"
/>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const stRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/storage',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<readonly BucketItem[]>(() => data?.storage?.buckets ?? [], [data])
const list = useCloudListState<BucketItem>({
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<BucketItem | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Buckets"
tagline="S3-compatible buckets — SeaweedFS or provider-native."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading buckets
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No buckets yet."
body="Buckets appear once SeaweedFS or a provider-native bucket is provisioned."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-endpoint`} column="endpoint" label="Endpoint" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-capacity`} column="capacity" label="Capacity" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-used`} column="used" label="Used" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-retentionDays`} column="retentionDays" label="Retention" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No buckets match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.name}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.endpoint}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.capacity}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.used || '—'}</td>
<td className="cloud-list-cell">{row.retentionDays || 'indefinite'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Bucket — ${openRow.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.name} />
<DetailRow label="ID" value={openRow.id} mono />
<DetailRow label="Endpoint" value={openRow.endpoint} mono />
<DetailRow label="Capacity" value={openRow.capacity} mono />
<DetailRow label="Used" value={openRow.used || '—'} mono />
<DetailRow label="Retention" value={openRow.retentionDays || 'indefinite'} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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('—')
})
})

View File

@ -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<StorageTile['id'], number | null> = {
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 (
<div data-testid="cloud-storage-page">
<style>{CLOUD_LIST_CSS}</style>
<header className="mb-3">
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid="cloud-storage-page-title"
>
Storage
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
PVCs, storage classes, buckets and block volumes for this Sovereign.
</p>
</header>
{isLoading ? (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="cloud-storage-page-loading"
>
Loading storage resources
</div>
) : (
<div className="cloud-list-tile-grid" data-testid="cloud-storage-page-tiles">
{STORAGE_TILES.map((tile) => {
const c = counts[tile.id]
return (
<Link
key={tile.id}
to={`/provision/$deploymentId/cloud/storage/${tile.id}` as never}
params={{ deploymentId } as never}
className="cloud-list-tile"
data-testid={`cloud-storage-page-tile-${tile.id}`}
>
<div className="cloud-list-tile-name">
<span>{tile.label}</span>
<span
className="cloud-list-tile-count"
data-testid={`cloud-storage-page-tile-${tile.id}-count`}
>
{tile.hasData && c !== null ? c : '—'}
</span>
</div>
<p className="cloud-list-tile-tagline">{tile.tagline}</p>
</Link>
)
})}
</div>
)}
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const stRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/storage',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<readonly PVCItem[]>(() => data?.storage?.pvcs ?? [], [data])
const [statusFilter, setStatusFilter] = useState<TopologyStatus | ''>('')
const [classFilter, setClassFilter] = useState<string>('')
const classOptions = useMemo<string[]>(() => {
const set = new Set<string>()
for (const r of rows) set.add(r.storageClass)
return [...set].sort()
}, [rows])
const list = useCloudListState<PVCItem>({
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<PVCItem | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="PVCs"
tagline="Persistent volume claims across all namespaces and clusters."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading PVCs
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No PVCs yet."
body="PVCs appear here once stateful workloads claim storage."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<>
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
<FilterPills
label="Class"
options={classOptions}
selected={classFilter}
onChange={setClassFilter}
testId={`${TEST_ID}-filter-class`}
/>
</>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-namespace`} column="namespace" label="Namespace" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-capacity`} column="capacity" label="Capacity" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-storageClass`} column="storageClass" label="Storage class" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No PVCs match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.name}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.namespace}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.capacity}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.storageClass}</td>
<td className="cloud-list-cell"><StatusPill status={row.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `PVC — ${openRow.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.name} />
<DetailRow label="ID" value={openRow.id} mono />
<DetailRow label="Namespace" value={openRow.namespace} mono />
<DetailRow label="Capacity" value={openRow.capacity} mono />
<DetailRow label="Used" value={openRow.used || '—'} mono />
<DetailRow label="Storage class" value={openRow.storageClass} mono />
<DetailRow label="Status" value={<StatusPill status={openRow.status} />} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const stRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/storage',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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()
})
})

View File

@ -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 (
<CloudListPlaceholder
testId="cloud-storage-classes"
title="Storage Classes"
tagline="Cluster-wide storage classes (local-path, longhorn, csi-cinder, etc.)."
bodyText="Storage class data is not in the current informer set. The storage-class informer rollout is tracked separately."
docsHref="https://github.com/openova-io/openova/issues/321"
/>
)
}

View File

@ -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: () => <Outlet /> })
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => (
<CloudPage disableStream initialDataOverride={infrastructureTopologyFixture} deploymentsOverride={[]} />
),
})
const stRoute = createRoute({
getParentRoute: () => cloudRoute,
path: '/storage',
component: () => <Outlet />,
})
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(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
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')
})
})

View File

@ -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<readonly VolumeItem[]>(() => data?.storage?.volumes ?? [], [data])
const [statusFilter, setStatusFilter] = useState<TopologyStatus | ''>('')
const [regionFilter, setRegionFilter] = useState<string>('')
const regionOptions = useMemo<string[]>(() => {
const set = new Set<string>()
for (const r of rows) set.add(r.region)
return [...set].sort()
}, [rows])
const list = useCloudListState<VolumeItem>({
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<VolumeItem | null>(null)
return (
<div data-testid={`${TEST_ID}-page`}>
<style>{CLOUD_LIST_CSS}</style>
<CloudListHeader
testId={TEST_ID}
title="Volumes"
tagline="Cloud block volumes attached to nodes."
count={rows.length}
deploymentId={deploymentId}
/>
{isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]">
Loading volumes
</div>
) : rows.length === 0 ? (
<EmptyState
testId={`${TEST_ID}-empty`}
title="No volumes yet."
body="Volumes appear here as soon as a stateful workload claims one."
/>
) : (
<>
<CloudListToolbar
testId={TEST_ID}
search={list.search}
onSearchChange={list.setSearch}
visibleCount={list.sorted.length}
totalCount={rows.length}
filters={
<>
<FilterPills
label="Status"
options={STATUSES}
selected={statusFilter}
onChange={setStatusFilter}
testId={`${TEST_ID}-filter-status`}
/>
<FilterPills
label="Region"
options={regionOptions}
selected={regionFilter}
onChange={setRegionFilter}
testId={`${TEST_ID}-filter-region`}
/>
</>
}
/>
<div className="cloud-list-table-scroll">
<table className="cloud-list-table" data-testid={`${TEST_ID}-table`}>
<thead>
<tr>
<SortableTH testId={`${TEST_ID}-th-name`} column="name" label="Name" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-region`} column="region" label="Region" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-attachedTo`} column="attachedTo" label="Attachment" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-capacity`} column="capacity" label="Capacity" state={list.sort} onChange={list.setSort} />
<SortableTH testId={`${TEST_ID}-th-status`} column="status" label="Status" state={list.sort} onChange={list.setSort} />
</tr>
</thead>
<tbody>
{list.visible.length === 0 ? (
<tr>
<td colSpan={5} className="cloud-list-empty-row" data-testid={`${TEST_ID}-table-empty`}>
No volumes match the current filters.
</td>
</tr>
) : (
list.visible.map((row) => (
<tr
key={row.id}
className="cloud-list-row"
data-testid={`${TEST_ID}-row-${row.id}`}
onClick={() => setOpenRow(row)}
>
<td className="cloud-list-cell cloud-list-cell-name">{row.name}</td>
<td className="cloud-list-cell">{row.region}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.attachedTo || 'detached'}</td>
<td className="cloud-list-cell cloud-list-cell-mono">{row.capacity}</td>
<td className="cloud-list-cell"><StatusPill status={row.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
testId={TEST_ID}
page={list.page}
pageSize={list.pageSize}
total={list.sorted.length}
onPageChange={list.setPage}
/>
</>
)}
<CloudListDetailDrawer
testId={`${TEST_ID}-detail`}
open={!!openRow}
onClose={() => setOpenRow(null)}
title={openRow ? `Volume — ${openRow.name}` : ''}
>
{openRow ? (
<>
<DetailRow label="Name" value={openRow.name} />
<DetailRow label="ID" value={openRow.id} mono />
<DetailRow label="Capacity" value={openRow.capacity} mono />
<DetailRow label="Region" value={openRow.region} />
<DetailRow label="Attached to" value={openRow.attachedTo || 'detached'} mono />
<DetailRow label="Status" value={<StatusPill status={openRow.status} />} />
</>
) : null}
</CloudListDetailDrawer>
</div>
)
}