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:
parent
05ed026fab
commit
e60cc2ca7f
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}
|
||||
@ -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$/)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
`
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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('—')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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('—')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user