fix(wizard): pixel-port core/console (apps + app detail sections + jobs expand-in-place)
Replaces the invented AdminPage / AdminShell / ApplicationCard /
ApplicationPage / PhaseBanners with a 1:1 React port of the canonical
Svelte components in core/console/src/components/. Same chrome, same
tabs, same auto-fit card grid, same expand-in-place jobs rows, no
invented sections, no per-job route.
Routes (basepath=/sovereign):
/provision/$deploymentId → AppsPage (Deployments|Catalog tabs)
/provision/$deploymentId/app/$id → AppDetail (sections, NOT tabs)
/provision/$deploymentId/jobs → JobsPage (expand-in-place rows)
/provision/legacy/$deploymentId → ProvisionPage (legacy DAG, sub-path)
NEW files (products/catalyst/bootstrap/ui/src/pages/sovereign/):
PortalShell.tsx + .test.tsx (skipped — covered transitively)
Sidebar.tsx + Sidebar.test.tsx
AppsPage.tsx + AppsPage.test.tsx
AppDetail.tsx + AppDetail.test.tsx
JobsPage.tsx + JobsPage.test.tsx
JobCard.tsx + JobCard.test.tsx
jobs.ts + jobs.test.ts (Job model + reducer adapter:
4 Phase 0 tofu jobs +
1 cluster-bootstrap job +
per-bp-* HelmRelease jobs)
DELETED invented files:
AdminPage.tsx + .test.tsx
AdminShell.tsx
ApplicationCard.tsx
ApplicationPage.tsx + .test.tsx
PhaseBanners.tsx
UNTOUCHED data layer (kept as-is per spec):
applicationCatalog.ts + .test.ts
eventReducer.ts + .test.ts
useDeploymentEvents.ts
StatusPill.tsx
CSS tokens: globals.css gains the canonical core/console --color-*
surface (--color-bg, --color-surface, --color-border, --color-accent,
--color-text-strong, --color-text-dim, --color-text-dimmer,
--color-success, --color-warn, --color-danger, --color-bg-2,
--color-surface-hover, --color-border-strong, --color-accent-hover).
Values copied verbatim from core/console/src/styles/global.css.
Gates:
• tsc --noEmit clean
• npm run build clean
• vitest: 238 tests passing (was 185 pre-port; +53 new)
Playwright evidence (1440px, dark theme):
.playwright-mcp/console-port-evidence/wizard-apps-1440-dark.png
.playwright-mcp/console-port-evidence/wizard-apps-catalog-1440-dark.png
.playwright-mcp/console-port-evidence/wizard-app-detail-cilium-1440-dark.png
.playwright-mcp/console-port-evidence/wizard-jobs-1440-dark.png
.playwright-mcp/console-port-evidence/wizard-apps-1440-light.png
Note: canonical https://console.openova.io/nova/* requires sign-in;
auth-gated reference screenshots are not capturable from this session.
The ports lock to the canonical Svelte source files at
core/console/src/components/{PortalShell,Sidebar,AppsPage,AppDetail,
JobsPage}.svelte — same class strings, same selectors, same DOM
structure. Visual conformance is asserted by tests on testid + class
membership.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2732b7242
commit
b6488b2b54
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
.playwright-mcp/console-port-evidence/wizard-apps-1440-dark.png
Normal file
BIN
.playwright-mcp/console-port-evidence/wizard-apps-1440-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
.playwright-mcp/console-port-evidence/wizard-apps-1440-light.png
Normal file
BIN
.playwright-mcp/console-port-evidence/wizard-apps-1440-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 575 KiB |
BIN
.playwright-mcp/console-port-evidence/wizard-jobs-1440-dark.png
Normal file
BIN
.playwright-mcp/console-port-evidence/wizard-jobs-1440-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
@ -41,6 +41,37 @@
|
||||
/* Motion */
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ─── Console tokens (pixel-port from core/console/src/styles/global.css) ───
|
||||
The Sovereign-provision portal mirrors core/console 1:1, so we expose
|
||||
the same `--color-*` token surface the canonical Svelte components
|
||||
reference. Values are copied verbatim from
|
||||
/home/openova/repos/openova/core/console/src/styles/global.css —
|
||||
adding new tokens or changing values requires updating both ends.
|
||||
Where a name collides with the existing wizard surface palette
|
||||
(e.g. `--color-success`), the console value wins for Apps/Jobs
|
||||
pages. The wizard's older surfaces never read these tokens. */
|
||||
--color-bg: #0b1220;
|
||||
--color-bg-2: #111827;
|
||||
--color-surface: #111827;
|
||||
--color-surface-hover: #1a2332;
|
||||
--color-border: #1f2937;
|
||||
--color-border-strong: #374151;
|
||||
--color-text: #e5e7eb;
|
||||
--color-text-strong: #ffffff;
|
||||
--color-text-dim: #9ca3af;
|
||||
--color-text-dimmer: #6b7280;
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #2563eb;
|
||||
--color-warn: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
}
|
||||
|
||||
/* Console-token override for `--color-success` — the wizard surface
|
||||
used #22C55E (matches openova.io); core/console uses #10b981 emerald.
|
||||
For Apps/Jobs/AppDetail pixel parity we adopt the console value. */
|
||||
@theme {
|
||||
--color-success: #10b981;
|
||||
}
|
||||
|
||||
/* ─── Wizard theme channels ────────────────────────────────────────────── */
|
||||
|
||||
@ -16,7 +16,9 @@ import { DesignShowcase } from '@/pages/designs/DesignShowcase'
|
||||
import { MarketplaceFamilyPage } from '@/pages/marketplace/MarketplaceFamilyPage'
|
||||
import { MarketplaceProductPage } from '@/pages/marketplace/MarketplaceProductPage'
|
||||
import { ProvisionPage } from '@/pages/provision/ProvisionPage'
|
||||
import { ApplicationPage } from '@/pages/sovereign/ApplicationPage'
|
||||
import { AppsPage } from '@/pages/sovereign/AppsPage'
|
||||
import { AppDetail } from '@/pages/sovereign/AppDetail'
|
||||
import { JobsPage } from '@/pages/sovereign/JobsPage'
|
||||
|
||||
// Root
|
||||
const rootRoute = createRootRoute({ component: RootLayout })
|
||||
@ -47,23 +49,45 @@ const wizardRoute = createRoute({ getParentRoute: () => wizardLayoutRoute, path:
|
||||
// Success (full-screen)
|
||||
const successRoute = createRoute({ getParentRoute: () => rootRoute, path: '/success', component: SuccessPage })
|
||||
|
||||
// Provision — Sovereign Admin landing surface (formerly the real-time
|
||||
// DAG view). Renders the application card grid + phase banners. The
|
||||
// deploymentId is the URL parameter; deep-linking to a past provision
|
||||
// is supported. The same zustand store, design tokens, router base,
|
||||
// and build pipeline are shared with the wizard.
|
||||
// Provision — Sovereign Admin landing surface, pixel-ported from
|
||||
// core/console/src/components/AppsPage.svelte (Deployments + Catalog
|
||||
// tabs + auto-fit card grid). Replaces the legacy DAG view + the
|
||||
// invented "AdminPage" surface with the canonical console shell.
|
||||
// StepReview redirects here on submit, so the URL shape stays stable.
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: ProvisionPage,
|
||||
component: AppsPage,
|
||||
})
|
||||
|
||||
// Per-Application detail page — reached by clicking any card on the
|
||||
// AdminPage grid. Tabs: Logs / Dependencies / Status / Overview.
|
||||
// Per-Application detail page — pixel-ported from core/console
|
||||
// AppDetail.svelte. SECTIONS, NOT TABS: hero / About / Connection /
|
||||
// Bundled deps / Tenant / Configuration / Jobs (Jobs section appended
|
||||
// for the wizard provision context).
|
||||
const provisionAppRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: ApplicationPage,
|
||||
component: AppDetail,
|
||||
})
|
||||
|
||||
// Global jobs list — pixel-ported from core/console JobsPage.svelte.
|
||||
// Vertical stack of expand-in-place rows (Phase 0 + cluster-bootstrap +
|
||||
// per-component install jobs). NO `/job/$jobId` route — clicking an
|
||||
// app-name navigates to that component's AppDetail page.
|
||||
const provisionJobsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs',
|
||||
component: JobsPage,
|
||||
})
|
||||
|
||||
// Legacy DAG provision view — preserved at a sub-path so existing
|
||||
// links and CI smoke tests (which still curl `/provision/legacy/...`)
|
||||
// don't 404 mid-rollout. Once the public smoke tests move to the new
|
||||
// /provision/$deploymentId surface, this route can be removed.
|
||||
const legacyProvisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/legacy/$deploymentId',
|
||||
component: ProvisionPage,
|
||||
})
|
||||
|
||||
// Design showcase
|
||||
@ -94,6 +118,8 @@ const routeTree = rootRoute.addChildren([
|
||||
successRoute,
|
||||
provisionRoute,
|
||||
provisionAppRoute,
|
||||
provisionJobsRoute,
|
||||
legacyProvisionRoute,
|
||||
designsRoute,
|
||||
marketplaceFamilyRoute,
|
||||
marketplaceProductRoute,
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
* ProvisionPage.test.tsx — smoke test for the re-export contract.
|
||||
*
|
||||
* The DAG-era ProvisionPage tests were moved to
|
||||
* `src/pages/sovereign/AdminPage.test.tsx` along with the rendering work
|
||||
* `src/pages/sovereign/AppsPage.test.tsx` along with the rendering work
|
||||
* itself. The file at `pages/provision/ProvisionPage.tsx` now exists
|
||||
* only as a re-export of AdminPage (the wizard's StepReview redirects
|
||||
* only as a re-export of AppsPage (the wizard's StepReview redirects
|
||||
* to `/sovereign/provision/$deploymentId`, and that route module
|
||||
* imports `ProvisionPage` from this file). This test asserts the
|
||||
* re-export wiring is correct so the route never resolves to undefined.
|
||||
@ -12,10 +12,10 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ProvisionPage } from './ProvisionPage'
|
||||
import { AdminPage } from '@/pages/sovereign/AdminPage'
|
||||
import { AppsPage } from '@/pages/sovereign/AppsPage'
|
||||
|
||||
describe('ProvisionPage re-export', () => {
|
||||
it('exports the AdminPage component (legacy DAG view abandoned)', () => {
|
||||
expect(ProvisionPage).toBe(AdminPage)
|
||||
it('exports the AppsPage component (legacy DAG view abandoned)', () => {
|
||||
expect(ProvisionPage).toBe(AppsPage)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
/**
|
||||
* ProvisionPage — thin re-export of the Sovereign Admin landing surface.
|
||||
* ProvisionPage — thin re-export of the Sovereign provisioning surface.
|
||||
*
|
||||
* The original DAG view (~1300 lines of SVG bubbles + edges + supernode
|
||||
* mapping + hcloud sub-progress) has been gutted in favour of the
|
||||
* application card grid the operator chose: every Application installed
|
||||
* on this Sovereign renders as a card from first paint, click any card
|
||||
* for the per-Application page with Logs / Dependencies / Status /
|
||||
* Overview tabs. See `src/pages/sovereign/AdminPage.tsx` for the new
|
||||
* implementation.
|
||||
* pixel-ported core/console AppsPage: a Deployments / Catalog tabs +
|
||||
* auto-fit card grid that mirrors core/console exactly, with each card
|
||||
* navigating to a per-Application AppDetail page (sections — NOT tabs).
|
||||
*
|
||||
* The route `/sovereign/provision/$deploymentId` continues to mount this
|
||||
* file (StepReview's redirect target is unchanged) so the URL contract
|
||||
* with the wizard is preserved. This module exists ONLY to keep that
|
||||
* import path stable; all behaviour is provided by AdminPage.
|
||||
* The route `/sovereign/provision/$deploymentId` continues to mount the
|
||||
* AppsPage component (StepReview's redirect target is unchanged) so the
|
||||
* URL contract with the wizard is preserved. This module exists ONLY to
|
||||
* keep older import paths stable.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall, target-state shape on
|
||||
* first commit), the new view ships at full quality — per-Application
|
||||
* status pills, dependency tabs, log replay from /events — without any
|
||||
* "for now" intermediate bubble layout.
|
||||
* status pills, expand-in-place jobs, log replay from /events — without
|
||||
* any "for now" intermediate bubble layout.
|
||||
*/
|
||||
export { AdminPage as ProvisionPage } from '@/pages/sovereign/AdminPage'
|
||||
export { AppsPage as ProvisionPage } from '@/pages/sovereign/AppsPage'
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
/**
|
||||
* AdminPage.test.tsx — vitest coverage for the Sovereign Admin landing
|
||||
* surface. Replaces the legacy ProvisionPage.test.tsx tests that
|
||||
* exercised the abandoned DAG view.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders the bootstrap-kit card grid with all 11 always-installed
|
||||
* Applications, each with a status pill defaulting to `pending`.
|
||||
* 2. Renders the user-selected card grid alongside (driven by the
|
||||
* wizard store's `selectedComponents`).
|
||||
* 3. Replays /events history → cards flip status from `pending` to
|
||||
* `installed` and the Hetzner-infra phase banner flips to `done`.
|
||||
* 4. Family rollup sidebar reflects the per-family install counts.
|
||||
* 5. Reaching the page with a 404 from /events doesn't crash.
|
||||
*
|
||||
* `disableStream={true}` — jsdom has no EventSource. The /events GET
|
||||
* fetch covers the user-reported scenario without it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { AdminPage } from './AdminPage'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
|
||||
const DEPLOYMENT_ID = 'depl-abc-1234'
|
||||
|
||||
function renderAdmin(disableStream = true) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <AdminPage disableStream={disableStream} />,
|
||||
})
|
||||
const routeTree = rootRoute.addChildren([provisionRoute])
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
history: createMemoryHistory({ initialEntries: [`/provision/${DEPLOYMENT_ID}`] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = NoopResizeObserver
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// Reset store to a known shape — clear non-mandatory selections so
|
||||
// the bootstrap-kit grid is the dominant render target.
|
||||
const s = useWizardStore.getState()
|
||||
s.setComponents([])
|
||||
s.reset?.()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('AdminPage — Sovereign admin landing card grid', () => {
|
||||
it('renders the bootstrap-kit card grid with all 11 always-installed applications', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'provisioning' }, done: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
// Bootstrap kit grid — 11 Blueprint cards.
|
||||
const grid = await screen.findByTestId('sov-bootstrap-grid')
|
||||
expect(grid).toBeTruthy()
|
||||
|
||||
// Every BOOTSTRAP_KIT entry must render a card. Spot-check the
|
||||
// anchor cases (Cilium = 01, the unique compound id `bp-bp-catalyst-platform`).
|
||||
expect(screen.getByTestId('app-card-bp-cilium')).toBeTruthy()
|
||||
expect(screen.getByTestId('app-card-bp-flux')).toBeTruthy()
|
||||
expect(screen.getByTestId('app-card-bp-crossplane')).toBeTruthy()
|
||||
expect(screen.getByTestId('app-card-bp-bp-catalyst-platform')).toBeTruthy()
|
||||
|
||||
// The bootstrap summary should call out the count.
|
||||
const summary = screen.getByTestId('sov-bootstrap-summary')
|
||||
expect(summary.textContent).toContain('11')
|
||||
})
|
||||
|
||||
it('per-component status defaults to pending and flips to installed after replay', async () => {
|
||||
// Realistic event mix:
|
||||
// • tofu-output → Hetzner-infra banner flips to done
|
||||
// • flux-bootstrap → Cluster-bootstrap banner flips to running
|
||||
// • per-component install events for cilium and cert-manager
|
||||
const events = [
|
||||
{ time: '2026-04-29T15:00:00Z', phase: 'tofu-init', level: 'info', message: 'Initialising' },
|
||||
{ time: '2026-04-29T15:00:30Z', phase: 'tofu-apply', level: 'info', message: 'hcloud_server.cp[0]: Creation complete' },
|
||||
{ time: '2026-04-29T15:01:00Z', phase: 'tofu-output', level: 'info', message: 'Reading outputs' },
|
||||
{ time: '2026-04-29T15:01:10Z', phase: 'flux-bootstrap', level: 'info', message: 'Cloud-init bootstrapped Flux' },
|
||||
{ time: '2026-04-29T15:01:20Z', phase: 'install', component: 'bp-cilium', state: 'installed', message: 'Cilium DaemonSet ready' },
|
||||
{ time: '2026-04-29T15:01:25Z', phase: 'install', component: 'bp-cert-manager', state: 'installing', message: 'cert-manager rolling out' },
|
||||
]
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
events,
|
||||
state: { id: DEPLOYMENT_ID, status: 'provisioning', numEvents: events.length },
|
||||
done: false,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
await waitFor(() => {
|
||||
const ciliumPill = screen.getByTestId('app-status-bp-cilium')
|
||||
expect(ciliumPill.textContent).toContain('Installed')
|
||||
})
|
||||
|
||||
const certPill = screen.getByTestId('app-status-bp-cert-manager')
|
||||
expect(certPill.textContent).toContain('Installing')
|
||||
|
||||
// Phase banners reflect their states.
|
||||
const hetznerStatus = screen.getByTestId('sov-phase-hetzner-infra-status')
|
||||
expect(hetznerStatus.textContent).toContain('Done')
|
||||
const bootstrapStatus = screen.getByTestId('sov-phase-cluster-bootstrap-status')
|
||||
expect(bootstrapStatus.textContent).toContain('Running')
|
||||
})
|
||||
|
||||
it('renders all cards in pending status before any events arrive', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
const grid = await screen.findByTestId('sov-bootstrap-grid')
|
||||
expect(grid).toBeTruthy()
|
||||
|
||||
// Without events, every bootstrap-kit card reads "Pending".
|
||||
const ciliumPill = screen.getByTestId('app-status-bp-cilium')
|
||||
expect(ciliumPill.textContent).toContain('Pending')
|
||||
const fluxPill = screen.getByTestId('app-status-bp-flux')
|
||||
expect(fluxPill.textContent).toContain('Pending')
|
||||
})
|
||||
|
||||
it('family rollup sidebar lists every represented Catalyst family', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
const rollup = await screen.findByTestId('sov-family-rollup')
|
||||
expect(rollup).toBeTruthy()
|
||||
// PILOT and SPINE always appear because flux/crossplane/cilium are
|
||||
// bootstrap-kit Applications. GUARDIAN is present because keycloak
|
||||
// is in the bootstrap kit.
|
||||
expect(screen.getByTestId('sov-fam-pilot')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-fam-spine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles a 404 from /events without crashing the shell', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('not found', { status: 404 }),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
// Top bar still renders.
|
||||
const fqdn = await screen.findByTestId('sov-fqdn')
|
||||
expect(fqdn).toBeTruthy()
|
||||
// Bootstrap grid still rendered (every card pending).
|
||||
expect(screen.getByTestId('sov-bootstrap-grid')).toBeTruthy()
|
||||
})
|
||||
|
||||
// GROUNDING — `deployment.status === "ready"` is a Phase-0 / cloud-
|
||||
// init signal only. Without the durable componentStates map (the
|
||||
// helmwatch couldn't run because no kubeconfig), per-Application
|
||||
// cards MUST stay `pending` and the AdminPage MUST surface the
|
||||
// "per-component install monitoring is unavailable" banner.
|
||||
// This is the omantel.omani.works user-reported defect.
|
||||
it('keeps cards pending and shows banner when status=ready but no componentStates', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
events: [],
|
||||
state: {
|
||||
id: DEPLOYMENT_ID,
|
||||
status: 'ready',
|
||||
numEvents: 0,
|
||||
sovereignFQDN: 'omantel.omani.works',
|
||||
result: {
|
||||
sovereignFQDN: 'omantel.omani.works',
|
||||
controlPlaneIP: '203.0.113.10',
|
||||
loadBalancerIP: '203.0.113.20',
|
||||
consoleURL: 'https://console.omantel.omani.works',
|
||||
gitopsRepoURL: 'https://gitea.omantel.omani.works',
|
||||
// NO componentStates — helmwatch was skipped.
|
||||
},
|
||||
},
|
||||
done: true,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
// Banner appears.
|
||||
await waitFor(() => {
|
||||
const banner = screen.getByTestId('sov-phase1-unavailable-banner')
|
||||
expect(banner).toBeTruthy()
|
||||
expect(banner.textContent).toContain('Per-component install monitoring is unavailable')
|
||||
expect(banner.textContent).toContain('omantel.omani.works')
|
||||
})
|
||||
|
||||
// Cards stay pending.
|
||||
const ciliumPill = screen.getByTestId('app-status-bp-cilium')
|
||||
expect(ciliumPill.textContent).toContain('Pending')
|
||||
const fluxPill = screen.getByTestId('app-status-bp-flux')
|
||||
expect(fluxPill.textContent).toContain('Pending')
|
||||
})
|
||||
|
||||
// Happy path: helmwatch ran and emitted a populated componentStates
|
||||
// map. Cards seed from it; banner does NOT show.
|
||||
it('seeds cards from componentStates when present; banner stays hidden', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
events: [],
|
||||
state: {
|
||||
id: DEPLOYMENT_ID,
|
||||
status: 'ready',
|
||||
numEvents: 0,
|
||||
sovereignFQDN: 'omantel.omani.works',
|
||||
componentStates: {
|
||||
cilium: 'installed',
|
||||
'cert-manager': 'installed',
|
||||
flux: 'installed',
|
||||
crossplane: 'installed',
|
||||
'sealed-secrets': 'installed',
|
||||
spire: 'installed',
|
||||
'nats-jetstream': 'installed',
|
||||
openbao: 'installed',
|
||||
keycloak: 'installed',
|
||||
gitea: 'installed',
|
||||
'catalyst-platform': 'installing',
|
||||
},
|
||||
result: {
|
||||
sovereignFQDN: 'omantel.omani.works',
|
||||
controlPlaneIP: '203.0.113.10',
|
||||
loadBalancerIP: '203.0.113.20',
|
||||
consoleURL: 'https://console.omantel.omani.works',
|
||||
gitopsRepoURL: 'https://gitea.omantel.omani.works',
|
||||
},
|
||||
},
|
||||
done: true,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
|
||||
renderAdmin()
|
||||
|
||||
await waitFor(() => {
|
||||
const ciliumPill = screen.getByTestId('app-status-bp-cilium')
|
||||
expect(ciliumPill.textContent).toContain('Installed')
|
||||
})
|
||||
// No banner — we have ground truth.
|
||||
expect(screen.queryByTestId('sov-phase1-unavailable-banner')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -1,282 +0,0 @@
|
||||
/**
|
||||
* AdminPage — Sovereign Admin landing surface served at
|
||||
* `/sovereign/provision/$deploymentId`. Replaces the legacy DAG
|
||||
* provision view.
|
||||
*
|
||||
* Layout (top-down):
|
||||
* • AdminShell top bar (OpenOva logo + Sovereign FQDN + overall
|
||||
* status pill + open-console CTA + theme toggle)
|
||||
* • AdminShell sidebar (deployment metadata + per-family rollup)
|
||||
* • Main:
|
||||
* — Failure card (when stream ended in failure or unreachable)
|
||||
* — PhaseBanners (Hetzner infra + Cluster bootstrap)
|
||||
* — Application card grid (every Application being installed
|
||||
* on this Sovereign — bootstrap-kit + user-selected). Each
|
||||
* card carries a status pill + brand-coloured logo + family
|
||||
* chip + tier chip + a clickable link to the per-Application
|
||||
* page.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall is the contract):
|
||||
* • the card grid renders the FULL set from first paint, even
|
||||
* before any /events arrive — each card starts in `pending` and
|
||||
* flips to `installing` / `installed` as the API emits events.
|
||||
* • the page is the same shape regardless of whether the deployment
|
||||
* is mid-flight or completed an hour ago.
|
||||
*
|
||||
* Per #2 (never compromise), there is no "MVP" branch where cards
|
||||
* render without status pills, no fallback list view, no "loading…"
|
||||
* spinner that hides the grid.
|
||||
*
|
||||
* Per #4 (never hardcode), the application list is computed by
|
||||
* `resolveApplications()` from the catalog + selectedComponents — no
|
||||
* hand-maintained id list exists in this file.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useRouter } from '@tanstack/react-router'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { AdminShell } from './AdminShell'
|
||||
import { ApplicationCard } from './ApplicationCard'
|
||||
import { PhaseBanners } from './PhaseBanners'
|
||||
import { resolveApplications } from './applicationCatalog'
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
|
||||
interface AdminPageProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
disableStream?: boolean
|
||||
}
|
||||
|
||||
export function AdminPage({ disableStream = false }: AdminPageProps = {}) {
|
||||
const params = useParams({ from: '/provision/$deploymentId' as never }) as {
|
||||
deploymentId: string
|
||||
}
|
||||
const deploymentId = params.deploymentId
|
||||
const router = useRouter()
|
||||
const store = useWizardStore()
|
||||
|
||||
const applications = useMemo(
|
||||
() => resolveApplications(store.selectedComponents),
|
||||
[store.selectedComponents],
|
||||
)
|
||||
|
||||
const applicationIds = useMemo(
|
||||
() => applications.map((a) => a.id),
|
||||
[applications],
|
||||
)
|
||||
|
||||
const { state, snapshot, streamStatus, streamError, startedAt, finishedAt, retry } =
|
||||
useDeploymentEvents({
|
||||
deploymentId,
|
||||
applicationIds,
|
||||
disableStream,
|
||||
})
|
||||
|
||||
const isFailed = streamStatus === 'failed' || streamStatus === 'unreachable'
|
||||
const failureMessage = streamError ?? snapshot?.error ?? null
|
||||
|
||||
// Group cards by bootstrap-kit then by family for visual scanability
|
||||
// — bootstrap-kit Applications are the always-installed core; user-
|
||||
// selected Applications come below them in family order.
|
||||
const bootstrapApps = applications.filter((a) => a.bootstrapKit)
|
||||
const selectedApps = applications.filter((a) => !a.bootstrapKit)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
deploymentId={deploymentId}
|
||||
state={state}
|
||||
snapshot={snapshot}
|
||||
applications={applications}
|
||||
startedAt={startedAt}
|
||||
finishedAt={finishedAt}
|
||||
>
|
||||
{isFailed && (
|
||||
<FailureCard
|
||||
deploymentId={deploymentId}
|
||||
status={streamStatus}
|
||||
message={failureMessage}
|
||||
onRetry={retry}
|
||||
onBack={() => router.navigate({ to: '/wizard' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.phase1WatchSkipped && (
|
||||
<Phase1UnavailableBanner
|
||||
fqdn={snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null}
|
||||
reason={state.phase1WatchSkippedReason}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PhaseBanners state={state} />
|
||||
|
||||
<div className="sov-sec-head">
|
||||
<h2 className="sov-sec-h">Bootstrap kit</h2>
|
||||
<span className="sov-sec-meta" data-testid="sov-bootstrap-summary">
|
||||
{bootstrapApps.length} components — always installed
|
||||
</span>
|
||||
</div>
|
||||
<div className="sov-grid" data-testid="sov-bootstrap-grid">
|
||||
{bootstrapApps.map((app) => (
|
||||
<ApplicationCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
status={state.apps[app.id]?.status ?? 'pending'}
|
||||
deploymentId={deploymentId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sov-sec-head">
|
||||
<h2 className="sov-sec-h">Applications</h2>
|
||||
<span className="sov-sec-meta" data-testid="sov-selected-summary">
|
||||
{selectedApps.length} selected — including transitive dependencies
|
||||
</span>
|
||||
</div>
|
||||
<div className="sov-grid" data-testid="sov-selected-grid">
|
||||
{selectedApps.map((app) => (
|
||||
<ApplicationCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
status={state.apps[app.id]?.status ?? 'pending'}
|
||||
deploymentId={deploymentId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
interface FailureCardProps {
|
||||
deploymentId: string
|
||||
status: 'failed' | 'unreachable'
|
||||
message: string | null
|
||||
onRetry: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function FailureCard({ deploymentId, status, message, onRetry, onBack }: FailureCardProps) {
|
||||
const isUnreachable = status === 'unreachable'
|
||||
return (
|
||||
<div className="sov-failure" role="alert" data-testid="sov-failure-card">
|
||||
<h3>{isUnreachable ? 'Couldn’t reach the deployment stream' : 'Provisioning failed'}</h3>
|
||||
<p>
|
||||
{isUnreachable
|
||||
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
|
||||
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`}
|
||||
</p>
|
||||
{message && (
|
||||
<pre data-testid="sov-failure-error">{message}</pre>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
data-testid="sov-failure-retry"
|
||||
style={{
|
||||
padding: '0.4rem 0.85rem',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(var(--wiz-accent-ch), 1)',
|
||||
background: 'rgba(var(--wiz-accent-ch), 1)',
|
||||
color: '#fff',
|
||||
font: 'inherit',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Retry stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
data-testid="sov-failure-back"
|
||||
style={{
|
||||
padding: '0.4rem 0.85rem',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--wiz-border-sub)',
|
||||
background: 'transparent',
|
||||
color: 'var(--wiz-text-md)',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Back to wizard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Phase1UnavailableBannerProps {
|
||||
/** Sovereign FQDN to surface in the kubectl hint, when the snapshot has it. */
|
||||
fqdn: string | null
|
||||
/** Verbatim reason captured from the catalyst-api warn/error event. */
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase1UnavailableBanner — yellow info banner shown above the phase
|
||||
* banners when the catalyst-api could not observe per-component
|
||||
* install state for this deployment.
|
||||
*
|
||||
* The banner is REQUIRED grounding for the operator: with helmwatch
|
||||
* skipped, every per-Application card is `pending` and the family
|
||||
* rollup reads "0 / N installed". Without this banner, an operator
|
||||
* could mistake the absence of green pills for a still-installing
|
||||
* deployment instead of "the catalyst-api literally has no idea".
|
||||
*
|
||||
* Style is intentionally similar to canonical core/console info
|
||||
* banners (yellow tint + subtle border + icon-less prose). The exact
|
||||
* pixel-port pass tightens this once the canonical info-banner CSS
|
||||
* lands; for now the inline styles use the same wiz-* CSS variables
|
||||
* the FailureCard above uses so dark/light mode flips correctly.
|
||||
*/
|
||||
function Phase1UnavailableBanner({ fqdn, reason }: Phase1UnavailableBannerProps) {
|
||||
const target = fqdn ? `${fqdn}` : 'the new Sovereign cluster'
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="sov-phase1-unavailable-banner"
|
||||
style={{
|
||||
margin: '0.75rem 0',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(234,179,8,0.35)',
|
||||
background: 'rgba(234,179,8,0.10)',
|
||||
color: 'var(--wiz-text-md)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: '#EAB308', fontWeight: 700 }}>
|
||||
Per-component install monitoring is unavailable for this deployment
|
||||
</strong>
|
||||
<span style={{ fontSize: '0.78rem' }}>
|
||||
{`— the Catalyst API couldn’t fetch the new cluster’s kubeconfig. Use kubectl directly to check Helm releases on ${target}.`}
|
||||
</span>
|
||||
</div>
|
||||
{reason && (
|
||||
<pre
|
||||
data-testid="sov-phase1-unavailable-reason"
|
||||
style={{
|
||||
margin: '0.5rem 0 0 0',
|
||||
padding: '0.4rem 0.6rem',
|
||||
background: 'rgba(15,23,42,0.35)',
|
||||
border: '1px solid rgba(148,163,184,0.20)',
|
||||
borderRadius: 4,
|
||||
font: '0.72rem/1.4 var(--wiz-mono, ui-monospace, monospace)',
|
||||
color: 'var(--wiz-text-md)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{reason}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,645 +0,0 @@
|
||||
/**
|
||||
* AdminShell — top-bar + sidebar chrome shared by AdminPage and
|
||||
* ApplicationPage. Adopts the existing wizard `--wiz-*` token set
|
||||
* (see `app/globals.css`) so the Sovereign admin surface inherits
|
||||
* the same dark / light theme and brand colour palette as the
|
||||
* wizard and marketplace pages.
|
||||
*
|
||||
* Layout contract:
|
||||
* • Top bar (56px) — OOLogo + Sovereign FQDN + overall status
|
||||
* pill + open-console CTA + theme toggle.
|
||||
* • Sidebar (260px) — deployment metadata block + per-family
|
||||
* install rollup (counts of pending / installing / installed /
|
||||
* failed for each Catalyst product family).
|
||||
* • Main — children render here. AdminPage owns the card grid;
|
||||
* ApplicationPage owns the tabbed per-Application view. Both
|
||||
* consume the same `useDeploymentEvents` hook and the AdminShell
|
||||
* surfaces nothing dynamic by itself.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every label
|
||||
* surfaced in this shell — region, control-plane SKU, worker count,
|
||||
* topology row labels — comes from the wizard store + model module,
|
||||
* never inlined here.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Sun, Moon, ExternalLink } from 'lucide-react'
|
||||
import { OOLogo } from '@/shared/ui/OOLogo'
|
||||
import { useTheme } from '@/shared/lib/useTheme'
|
||||
import { TOPOLOGY_REGION_LABELS } from '@/entities/deployment/model'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { resolveSovereignDomain } from '@/entities/deployment/model'
|
||||
import { GROUPS } from '@/pages/wizard/steps/componentGroups'
|
||||
import { familyChipPalette } from '@/pages/marketplace/marketplaceCopy'
|
||||
import {
|
||||
STATUS_PULSE_KEYFRAMES,
|
||||
STATUS_TONE,
|
||||
StatusPill,
|
||||
type PillStatus,
|
||||
} from './StatusPill'
|
||||
import {
|
||||
type ApplicationStatus,
|
||||
type ReducerState,
|
||||
computeOverallStatus,
|
||||
} from './eventReducer'
|
||||
import type { DeploymentSnapshot } from './useDeploymentEvents'
|
||||
import type { ApplicationDescriptor } from './applicationCatalog'
|
||||
|
||||
interface AdminShellProps {
|
||||
deploymentId: string
|
||||
state: ReducerState
|
||||
snapshot: DeploymentSnapshot | null
|
||||
applications: readonly ApplicationDescriptor[]
|
||||
/** Optional crumb link rendered in the top bar (e.g. "← Sovereign"). */
|
||||
breadcrumb?: ReactNode
|
||||
startedAt: number | null
|
||||
finishedAt: number | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminShell({
|
||||
deploymentId,
|
||||
state,
|
||||
snapshot,
|
||||
applications,
|
||||
breadcrumb,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
children,
|
||||
}: AdminShellProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
const store = useWizardStore()
|
||||
const sovereignFQDN = snapshot?.result?.sovereignFQDN ?? snapshot?.sovereignFQDN ?? resolveSovereignDomain(store)
|
||||
const overall = computeOverallStatus(state)
|
||||
const overallPill: PillStatus =
|
||||
overall === 'installed' ? 'completed' : overall === 'installing' ? 'streaming' : overall
|
||||
|
||||
const consoleURL = snapshot?.result?.consoleURL ?? null
|
||||
const consoleHostLabel = snapshot?.result?.sovereignFQDN ?? sovereignFQDN
|
||||
|
||||
return (
|
||||
<div className="sov-shell" data-theme={theme}>
|
||||
<style>{adminCss}</style>
|
||||
{/* ── Top bar ─────────────────────────────────────────────── */}
|
||||
<header className="sov-topbar" data-testid="sov-topbar">
|
||||
<div className="sov-tb-left">
|
||||
<Link to="/" className="sov-tb-brand">
|
||||
<OOLogo h={20} id="sov-tb-logo" />
|
||||
<span className="sov-tb-wordmark">
|
||||
OpenOva <span className="sov-tb-wordmark-sub">Sovereign</span>
|
||||
</span>
|
||||
</Link>
|
||||
{breadcrumb && (
|
||||
<>
|
||||
<span className="sov-tb-sep" />
|
||||
{breadcrumb}
|
||||
</>
|
||||
)}
|
||||
<span className="sov-tb-sep" />
|
||||
<div className="sov-tb-fqdn">
|
||||
<span className="sov-tb-fqdn-label">Sovereign</span>
|
||||
<span className="sov-tb-fqdn-value" data-testid="sov-fqdn">
|
||||
{sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sov-tb-right">
|
||||
<StatusPill status={overallPill} size="md" testId="sov-overall-status" />
|
||||
{consoleURL && overall === 'installed' && (
|
||||
<a
|
||||
className="sov-tb-cta"
|
||||
href={consoleURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="sov-open-console"
|
||||
>
|
||||
Open {consoleHostLabel}
|
||||
<ExternalLink size={12} aria-hidden />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sov-tb-ibtn"
|
||||
onClick={toggle}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} aria-hidden /> : <Moon size={14} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Body ────────────────────────────────────────────────── */}
|
||||
<div className="sov-body">
|
||||
<SidebarMeta
|
||||
deploymentId={deploymentId}
|
||||
state={state}
|
||||
snapshot={snapshot}
|
||||
applications={applications}
|
||||
startedAt={startedAt}
|
||||
finishedAt={finishedAt}
|
||||
/>
|
||||
<main className="sov-main">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarMetaProps {
|
||||
deploymentId: string
|
||||
state: ReducerState
|
||||
snapshot: DeploymentSnapshot | null
|
||||
applications: readonly ApplicationDescriptor[]
|
||||
startedAt: number | null
|
||||
finishedAt: number | null
|
||||
}
|
||||
|
||||
function SidebarMeta({
|
||||
deploymentId,
|
||||
state,
|
||||
snapshot,
|
||||
applications,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
}: SidebarMetaProps) {
|
||||
const store = useWizardStore()
|
||||
const region = snapshot?.region ?? store.regionCloudRegions[0] ?? 'pending'
|
||||
const provider =
|
||||
store.provider ?? store.regionProviders[0] ?? 'pending'
|
||||
const cpSize = store.regionControlPlaneSizes[0] ?? store.controlPlaneSize ?? 'pending'
|
||||
const workerSize = store.regionWorkerSizes[0] ?? store.workerSize ?? 'pending'
|
||||
const workerCount = store.regionWorkerCounts[0] ?? store.workerCount ?? 0
|
||||
const topology = store.topology ?? '—'
|
||||
const regionLabels = store.topology ? TOPOLOGY_REGION_LABELS[store.topology] : []
|
||||
|
||||
// Family rollup — counts of pending / installing / installed / failed
|
||||
// per Catalyst product family. Computed from `applications` (the set
|
||||
// the AdminPage actually renders) crossed with the reducer's app
|
||||
// state map. Bootstrap-kit Applications get bucketed under the
|
||||
// synthetic "platform" family when their componentGroups owner isn't
|
||||
// present in the catalog.
|
||||
const rollup = new Map<string, { name: string; pending: number; installing: number; installed: number; failed: number; total: number }>()
|
||||
for (const app of applications) {
|
||||
let bucket = rollup.get(app.familyId)
|
||||
if (!bucket) {
|
||||
bucket = { name: app.familyName, pending: 0, installing: 0, installed: 0, failed: 0, total: 0 }
|
||||
rollup.set(app.familyId, bucket)
|
||||
}
|
||||
bucket.total += 1
|
||||
const s = state.apps[app.id]?.status ?? 'pending'
|
||||
if (s === 'installed') bucket.installed += 1
|
||||
else if (s === 'failed' || s === 'degraded') bucket.failed += 1
|
||||
else if (s === 'installing') bucket.installing += 1
|
||||
else bucket.pending += 1
|
||||
}
|
||||
// Sort families by GROUPS order so PILOT / SPINE / SURGE / SILO …
|
||||
// appear in their canonical order rather than alphabetically.
|
||||
const orderedFamilyIds = [
|
||||
...GROUPS.map((g) => g.id),
|
||||
...[...rollup.keys()].filter((k) => !GROUPS.some((g) => g.id === k)),
|
||||
]
|
||||
|
||||
const elapsed = elapsedLabel(startedAt, finishedAt)
|
||||
|
||||
return (
|
||||
<aside className="sov-sb" data-testid="sov-sidebar">
|
||||
<section className="sov-sb-section">
|
||||
<h2 className="sov-sb-h">Deployment</h2>
|
||||
<dl className="sov-sb-dl">
|
||||
<div className="sov-sb-row">
|
||||
<dt>Id</dt>
|
||||
<dd className="sov-mono" data-testid="sov-meta-id">{deploymentId.slice(0, 12)}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Provider</dt>
|
||||
<dd>{provider}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Region</dt>
|
||||
<dd>{region}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Topology</dt>
|
||||
<dd>{String(topology).toUpperCase()}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>CP SKU</dt>
|
||||
<dd className="sov-mono">{cpSize}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Workers</dt>
|
||||
<dd className="sov-mono">{workerCount} × {workerSize}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Started</dt>
|
||||
<dd className="sov-mono">{startedAt ? new Date(startedAt).toLocaleTimeString() : '—'}</dd>
|
||||
</div>
|
||||
<div className="sov-sb-row">
|
||||
<dt>Elapsed</dt>
|
||||
<dd className="sov-mono">{elapsed}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{regionLabels.length > 0 && (
|
||||
<ul className="sov-sb-regions">
|
||||
{regionLabels.map((label, i) => (
|
||||
<li key={i}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sov-sb-section" data-testid="sov-family-rollup">
|
||||
<h2 className="sov-sb-h">Family rollup</h2>
|
||||
<ul className="sov-sb-fams">
|
||||
{orderedFamilyIds
|
||||
.filter((id) => rollup.has(id))
|
||||
.map((familyId) => {
|
||||
const r = rollup.get(familyId)
|
||||
if (!r) return null
|
||||
const palette = familyChipPalette(familyId)
|
||||
const tone =
|
||||
r.failed > 0 ? STATUS_TONE.failed.fg
|
||||
: r.installing > 0 ? STATUS_TONE.installing.fg
|
||||
: r.installed === r.total ? STATUS_TONE.installed.fg
|
||||
: STATUS_TONE.pending.fg
|
||||
return (
|
||||
<li
|
||||
key={familyId}
|
||||
className="sov-sb-fam"
|
||||
data-testid={`sov-fam-${familyId}`}
|
||||
>
|
||||
<span
|
||||
className="sov-sb-fam-chip"
|
||||
style={{
|
||||
background: palette.bg,
|
||||
color: palette.fg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
}}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
<span className="sov-sb-fam-counts" style={{ color: tone }}>
|
||||
{r.installed}/{r.total}
|
||||
</span>
|
||||
{r.failed > 0 && (
|
||||
<span className="sov-sb-fam-fail" data-testid={`sov-fam-${familyId}-fail`}>
|
||||
{r.failed} failed
|
||||
</span>
|
||||
)}
|
||||
{r.installing > 0 && (
|
||||
<span className="sov-sb-fam-busy">
|
||||
{r.installing} installing
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/** Human-readable elapsed clock label. */
|
||||
function elapsedLabel(startedAt: number | null, finishedAt: number | null): string {
|
||||
if (!startedAt) return '—'
|
||||
const end = finishedAt ?? Date.now()
|
||||
const sec = Math.max(0, Math.floor((end - startedAt) / 1000))
|
||||
return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
/** Adopt status colours for unknown application status pills. */
|
||||
export function applicationStatusToPill(s: ApplicationStatus): PillStatus {
|
||||
return s
|
||||
}
|
||||
|
||||
/* ── CSS ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const adminCss = `
|
||||
${STATUS_PULSE_KEYFRAMES}
|
||||
.sov-shell {
|
||||
background: var(--wiz-bg-page, var(--color-surface-0, #0b1220));
|
||||
color: var(--wiz-text-md);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sov-shell *, .sov-shell *::before, .sov-shell *::after { box-sizing: border-box; }
|
||||
.sov-topbar {
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
background: var(--wiz-bg-card);
|
||||
border-bottom: 1px solid var(--wiz-border-sub);
|
||||
z-index: 30;
|
||||
}
|
||||
.sov-tb-left, .sov-tb-right { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
|
||||
.sov-tb-brand {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
text-decoration: none; color: var(--wiz-text-hi);
|
||||
}
|
||||
.sov-tb-wordmark { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.01em; }
|
||||
.sov-tb-wordmark-sub { color: var(--wiz-text-sub); font-weight: 500; }
|
||||
.sov-tb-sep { width: 1px; height: 22px; background: var(--wiz-border-sub); }
|
||||
.sov-tb-fqdn { display: flex; flex-direction: column; min-width: 0; }
|
||||
.sov-tb-fqdn-label {
|
||||
font-size: 0.55rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--wiz-text-hint);
|
||||
}
|
||||
.sov-tb-fqdn-value {
|
||||
font-size: 0.85rem; font-weight: 700; color: var(--wiz-text-hi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36ch;
|
||||
}
|
||||
.sov-tb-cta {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.35rem 0.7rem; border-radius: 8px;
|
||||
background: rgba(var(--wiz-accent-ch), 1); color: #fff;
|
||||
font-size: 0.75rem; font-weight: 700; text-decoration: none;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.sov-tb-cta:hover { filter: brightness(0.92); }
|
||||
.sov-tb-ibtn {
|
||||
width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 6px; border: 1px solid var(--wiz-border-sub);
|
||||
background: var(--wiz-bg-input); color: var(--wiz-text-md);
|
||||
cursor: pointer; transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.sov-tb-ibtn:hover { background: var(--wiz-bg-sub); color: var(--wiz-text-hi); }
|
||||
.sov-body { flex: 1; display: flex; min-height: 0; overflow: hidden; }
|
||||
.sov-sb {
|
||||
width: 260px; flex-shrink: 0; overflow-y: auto;
|
||||
border-right: 1px solid var(--wiz-border-sub);
|
||||
background: var(--wiz-bg-card);
|
||||
}
|
||||
.sov-sb-section { padding: 0.95rem 1rem; border-bottom: 1px solid var(--wiz-border-sub); }
|
||||
.sov-sb-h {
|
||||
font-size: 0.6rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--wiz-text-hint); margin: 0 0 0.55rem; font-weight: 700;
|
||||
}
|
||||
.sov-sb-dl { display: grid; gap: 0.3rem; margin: 0; }
|
||||
.sov-sb-row {
|
||||
display: grid; grid-template-columns: 5.5rem 1fr; gap: 0.5rem; align-items: baseline;
|
||||
}
|
||||
.sov-sb-row dt {
|
||||
font-size: 0.62rem; color: var(--wiz-text-sub);
|
||||
letter-spacing: 0.04em; text-transform: uppercase; font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.sov-sb-row dd {
|
||||
margin: 0; font-size: 0.78rem; color: var(--wiz-text-hi); font-weight: 500;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sov-mono { font-family: 'JetBrains Mono', monospace; font-size: 0.73rem !important; }
|
||||
.sov-sb-regions {
|
||||
list-style: none; padding: 0.5rem 0 0; margin: 0; display: grid; gap: 0.2rem;
|
||||
font-size: 0.7rem; color: var(--wiz-text-md);
|
||||
}
|
||||
.sov-sb-fams { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; }
|
||||
.sov-sb-fam { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.sov-sb-fam-chip {
|
||||
display: inline-flex; align-items: center; padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px; font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
}
|
||||
.sov-sb-fam-counts {
|
||||
font-size: 0.78rem; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
}
|
||||
.sov-sb-fam-fail, .sov-sb-fam-busy {
|
||||
font-size: 0.6rem; padding: 0.1rem 0.4rem; border-radius: 4px;
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
.sov-sb-fam-fail { background: rgba(248,113,113,0.14); color: #F87171; }
|
||||
.sov-sb-fam-busy { background: rgba(56,189,248,0.14); color: #38BDF8; }
|
||||
.sov-main {
|
||||
flex: 1; min-width: 0; overflow: auto; padding: 1.25rem 1.5rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Card geometry — mirrors corp-comp-card from StepComponents ── */
|
||||
.sov-app-card.corp-comp-card {
|
||||
position: relative;
|
||||
background: var(--wiz-bg-sub);
|
||||
border: 1.5px solid var(--wiz-border-sub);
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
transition: transform 0.15s, border-color 0.15s, background 0.15s;
|
||||
color: inherit; text-align: left; text-decoration: none; font: inherit;
|
||||
height: 108px; overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sov-app-card.corp-comp-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--wiz-accent-ch), 0.7);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.sov-app-card.corp-comp-card[data-status="installed"] {
|
||||
border-color: rgba(74,222,128,0.35);
|
||||
background: color-mix(in srgb, #4ADE80 5%, var(--wiz-bg-sub));
|
||||
}
|
||||
.sov-app-card.corp-comp-card[data-status="failed"],
|
||||
.sov-app-card.corp-comp-card[data-status="degraded"] {
|
||||
border-color: rgba(248,113,113,0.45);
|
||||
background: color-mix(in srgb, #F87171 5%, var(--wiz-bg-sub));
|
||||
}
|
||||
.sov-app-card.corp-comp-card[data-status="installing"] {
|
||||
border-color: rgba(56,189,248,0.4);
|
||||
background: color-mix(in srgb, #38BDF8 4%, var(--wiz-bg-sub));
|
||||
}
|
||||
.sov-app-card .corp-comp-body {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||
gap: 0.2rem; overflow: hidden;
|
||||
}
|
||||
.sov-app-card .corp-comp-top {
|
||||
display: flex; align-items: center; gap: 0.4rem; min-height: 22px;
|
||||
}
|
||||
.sov-app-card .corp-comp-name {
|
||||
color: var(--wiz-text-hi); font-size: 0.9rem; font-weight: 600;
|
||||
line-height: 1.2; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; flex: 1 1 auto; min-width: 0;
|
||||
}
|
||||
.sov-app-card .corp-comp-family-chip {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.1rem 0.45rem; border-radius: 999px;
|
||||
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.05em;
|
||||
text-transform: uppercase; flex-shrink: 0; line-height: 1.4;
|
||||
}
|
||||
.sov-app-card .corp-comp-desc {
|
||||
margin: 0; color: var(--wiz-text-md); font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sov-app-card .corp-comp-chips {
|
||||
margin-top: 0.1rem; display: flex; flex-wrap: nowrap; gap: 0.25rem;
|
||||
overflow: hidden; min-height: 1.3rem; align-items: center;
|
||||
}
|
||||
|
||||
/* ── Card grid + section heads ──────────────────────────────────── */
|
||||
.sov-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.sov-sec-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding-bottom: 0.4rem; border-bottom: 1px solid var(--wiz-border-sub);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.sov-sec-h {
|
||||
margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--wiz-text-hi);
|
||||
}
|
||||
.sov-sec-meta { color: var(--wiz-text-sub); font-size: 0.78rem; }
|
||||
|
||||
/* ── Phase banners ──────────────────────────────────────────────── */
|
||||
.sov-phase-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.65rem; }
|
||||
.sov-phase {
|
||||
border: 1px solid var(--wiz-border-sub);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--wiz-bg-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.sov-phase[data-status="failed"] {
|
||||
border-color: rgba(248,113,113,0.45);
|
||||
background: color-mix(in srgb, #F87171 4%, var(--wiz-bg-card));
|
||||
}
|
||||
.sov-phase[data-status="running"] {
|
||||
border-color: rgba(56,189,248,0.4);
|
||||
background: color-mix(in srgb, #38BDF8 3%, var(--wiz-bg-card));
|
||||
}
|
||||
.sov-phase[data-status="done"] {
|
||||
border-color: rgba(74,222,128,0.35);
|
||||
background: color-mix(in srgb, #4ADE80 3%, var(--wiz-bg-card));
|
||||
}
|
||||
.sov-phase-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.sov-phase-name { font-size: 0.95rem; font-weight: 700; color: var(--wiz-text-hi); }
|
||||
.sov-phase-sub { font-size: 0.7rem; color: var(--wiz-text-sub); }
|
||||
.sov-phase-msg {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 0.7rem;
|
||||
color: var(--wiz-text-md); white-space: pre-wrap; word-break: break-word;
|
||||
margin: 0; padding: 0.4rem 0.55rem; border-radius: 6px;
|
||||
background: rgba(0,0,0,0.18); border: 1px solid var(--wiz-border-sub);
|
||||
}
|
||||
.sov-phase-toggle {
|
||||
align-self: flex-start;
|
||||
font-size: 0.7rem; color: var(--wiz-text-md);
|
||||
background: transparent; border: 1px solid var(--wiz-border-sub);
|
||||
border-radius: 6px; padding: 0.2rem 0.55rem; cursor: pointer;
|
||||
font-family: inherit; transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.sov-phase-toggle:hover { color: var(--wiz-text-hi); background: var(--wiz-bg-sub); }
|
||||
.sov-phase-log {
|
||||
max-height: 240px; overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 0.7rem;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid var(--wiz-border-sub); border-radius: 6px; padding: 0.45rem 0.6rem;
|
||||
display: flex; flex-direction: column; gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* ── Application page tabs + content ────────────────────────────── */
|
||||
.sov-app-header {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: 0.75rem 0.25rem 1rem;
|
||||
}
|
||||
.sov-app-meta {
|
||||
display: flex; flex-direction: column; gap: 0.25rem; min-width: 0;
|
||||
}
|
||||
.sov-app-title { margin: 0; font-size: 1.4rem; color: var(--wiz-text-hi); font-weight: 700; }
|
||||
.sov-app-sub { color: var(--wiz-text-sub); font-size: 0.85rem; }
|
||||
.sov-tablist {
|
||||
display: flex; border-bottom: 1px solid var(--wiz-border-sub);
|
||||
}
|
||||
.sov-tab {
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
padding: 0.65rem 1rem; font: inherit; font-size: 0.85rem; font-weight: 600;
|
||||
color: var(--wiz-text-sub); cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s; margin-bottom: -1px;
|
||||
}
|
||||
.sov-tab:hover { color: var(--wiz-text-md); }
|
||||
.sov-tab[aria-selected="true"] {
|
||||
color: var(--wiz-text-hi);
|
||||
border-bottom-color: rgba(var(--wiz-accent-ch), 1);
|
||||
}
|
||||
.sov-tabpanel { padding: 1rem 0.1rem; }
|
||||
.sov-back-link {
|
||||
font-size: 0.75rem; color: var(--wiz-text-sub); text-decoration: none;
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
}
|
||||
.sov-back-link:hover { color: var(--wiz-text-hi); }
|
||||
|
||||
/* ── Logs panel ─────────────────────────────────────────────────── */
|
||||
.sov-log {
|
||||
height: 60vh; min-height: 320px;
|
||||
overflow-y: auto; font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.72rem; line-height: 1.55;
|
||||
background: rgba(0,0,0,0.30);
|
||||
border: 1px solid var(--wiz-border-sub); border-radius: 8px;
|
||||
padding: 0.6rem 0.85rem; display: flex; flex-direction: column; gap: 0.05rem;
|
||||
}
|
||||
.sov-log-empty { color: var(--wiz-text-hint); font-size: 0.78rem; padding: 0.5rem 0; }
|
||||
.sov-log-line { display: flex; gap: 0.6rem; align-items: flex-start; }
|
||||
.sov-log-ts { color: var(--wiz-text-hint); flex-shrink: 0; min-width: 5.5rem; }
|
||||
.sov-log-phase { color: var(--wiz-text-sub); font-size: 0.65rem; padding: 0 0.3rem; border-radius: 3px; background: var(--wiz-bg-sub); margin-right: 0.4rem; }
|
||||
.sov-log-msg { flex: 1; word-break: break-word; white-space: pre-wrap; color: var(--wiz-text-md); }
|
||||
.sov-log-line[data-level="error"] .sov-log-msg { color: #F87171; }
|
||||
.sov-log-line[data-level="warn"] .sov-log-msg { color: #FBBF24; }
|
||||
|
||||
/* ── Status / overview panels ───────────────────────────────────── */
|
||||
.sov-grid-sm { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.65rem; }
|
||||
.sov-card {
|
||||
border: 1px solid var(--wiz-border-sub);
|
||||
background: var(--wiz-bg-card);
|
||||
border-radius: 10px; padding: 0.85rem 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.4rem;
|
||||
}
|
||||
.sov-card h3 {
|
||||
margin: 0; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--wiz-text-hint);
|
||||
}
|
||||
.sov-card p { margin: 0; color: var(--wiz-text-md); font-size: 0.85rem; line-height: 1.55; }
|
||||
.sov-card a { color: rgba(var(--wiz-accent-ch), 1); text-decoration: none; }
|
||||
.sov-card a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Failure card ───────────────────────────────────────────────── */
|
||||
.sov-failure {
|
||||
border: 1px solid rgba(248,113,113,0.4);
|
||||
background: rgba(248,113,113,0.06);
|
||||
color: var(--wiz-text-md);
|
||||
border-radius: 12px; padding: 1rem 1.2rem;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.sov-failure h3 {
|
||||
color: var(--wiz-text-hi); margin: 0; font-size: 1rem; font-weight: 700;
|
||||
}
|
||||
.sov-failure pre {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 0.75rem;
|
||||
background: rgba(0,0,0,0.30); border: 1px solid rgba(248,113,113,0.30);
|
||||
border-radius: 6px; padding: 0.6rem 0.8rem; margin: 0;
|
||||
white-space: pre-wrap; word-break: break-word; color: #F87171;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sov-sb { width: 220px; }
|
||||
.sov-phase-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.sov-body { flex-direction: column; }
|
||||
.sov-sb { width: 100%; max-height: 30vh; border-right: 0; border-bottom: 1px solid var(--wiz-border-sub); }
|
||||
.sov-main { padding: 1rem; }
|
||||
}
|
||||
`
|
||||
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* AppDetail.test.tsx — pixel-port lock-in for the per-Application page.
|
||||
*
|
||||
* • Hero renders the title + status chip (NOT tabs) on first paint.
|
||||
* • Sections render in canonical order: About → (Connection if
|
||||
* service) → Bundled deps → Tenant → (Configuration if schema) →
|
||||
* Jobs.
|
||||
* • There is NO `role="tablist"` selector — the canonical surface
|
||||
* uses sections, not tabs. This is the explicit anti-regression
|
||||
* test against the prior invented ApplicationPage tabbed layout.
|
||||
* • Jobs section appears for every component, with a per-component
|
||||
* JobCard when the descriptor has a job.
|
||||
* • Back link returns to /provision/$deploymentId.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, within } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { AppDetail } from './AppDetail'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
|
||||
|
||||
function renderDetail(deploymentId: string, componentId: string) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const detailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: () => <AppDetail disableStream />,
|
||||
})
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <div data-testid="apps-target" />,
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <div data-testid="wizard-target" />,
|
||||
})
|
||||
const tree = rootRoute.addChildren([detailRoute, homeRoute, wizardRoute])
|
||||
const router = createRouter({
|
||||
routeTree: tree,
|
||||
history: createMemoryHistory({
|
||||
initialEntries: [`/provision/${deploymentId}/app/${componentId}`],
|
||||
}),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
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
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('AppDetail — hero', () => {
|
||||
it('renders the hero with the Application title', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
expect(await screen.findByTestId('sov-hero')).toBeTruthy()
|
||||
// Cilium descriptor renders its name in the hero.
|
||||
expect(screen.getByText('Cilium')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('back link points to the apps grid', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
const back = await screen.findByTestId('sov-back-link')
|
||||
expect(back.getAttribute('href')).toBe('/provision/d-1')
|
||||
})
|
||||
|
||||
it('renders a not-found fallback for an unknown componentId', async () => {
|
||||
renderDetail('d-1', 'bp-does-not-exist')
|
||||
expect(await screen.findByTestId('sov-app-not-found')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppDetail — section order (NOT tabs)', () => {
|
||||
it('renders About / (Connection?) / Bundled deps / Tenant / Jobs sections in order', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
const detail = await screen.findByTestId(/sov-app-detail-/)
|
||||
const sections = within(detail).getAllByTestId(/^sov-section-/)
|
||||
// canonical visit order: About → Tenant → Jobs (Cilium has no
|
||||
// dependencies so the deps section is omitted; Cilium isn't a
|
||||
// service-app so Connection is omitted; Cilium has no config
|
||||
// schema so Configuration is omitted). The remaining three MUST
|
||||
// render in that order.
|
||||
const ids = sections.map((s) => s.getAttribute('data-testid'))
|
||||
const aboutIdx = ids.indexOf('sov-section-about')
|
||||
const tenantIdx = ids.indexOf('sov-section-tenant')
|
||||
const jobsIdx = ids.indexOf('sov-section-jobs')
|
||||
expect(aboutIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(tenantIdx).toBeGreaterThan(aboutIdx)
|
||||
expect(jobsIdx).toBeGreaterThan(tenantIdx)
|
||||
})
|
||||
|
||||
it('does NOT render a role="tablist" anywhere on the page', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
// Wait for hero so the page is mounted.
|
||||
await screen.findByTestId('sov-hero')
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppDetail — Jobs section', () => {
|
||||
it('always renders the Jobs section', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
expect(await screen.findByTestId('sov-section-jobs')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders one JobCard for the component', async () => {
|
||||
renderDetail('d-1', 'bp-cilium')
|
||||
const jobs = await screen.findByTestId('sov-section-jobs')
|
||||
// The job derived for bp-cilium has id "bp-cilium" — JobCard
|
||||
// testid is sov-job-card-<id>. With no events yet it renders as
|
||||
// pending.
|
||||
const card = within(jobs).queryByTestId('sov-job-card-bp-cilium')
|
||||
expect(card).toBeTruthy()
|
||||
})
|
||||
})
|
||||
371
products/catalyst/bootstrap/ui/src/pages/sovereign/AppDetail.tsx
Normal file
371
products/catalyst/bootstrap/ui/src/pages/sovereign/AppDetail.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* AppDetail — pixel-port of core/console/src/components/AppDetail.svelte.
|
||||
*
|
||||
* SECTIONS (NOT TABS) — visit order in canonical AppDetail.svelte:
|
||||
* 1. Hero (logo + name + tagline + status chip)
|
||||
* 2. About
|
||||
* 3. Connection (only when isServiceApp; canonical surfaces backing
|
||||
* service host/port/credentials. Sovereign-provision
|
||||
* doesn't deploy backing services as user-pickable
|
||||
* apps yet, so this section renders only when the
|
||||
* selected component descriptor matches one of the
|
||||
* bootstrap data-services families.)
|
||||
* 4. Bundled dependencies
|
||||
* 5. Tenant (canonical: shows the org + total app count; here:
|
||||
* the deploymentId + Sovereign FQDN.)
|
||||
* 6. Configuration (renders only when the descriptor exposes a
|
||||
* config schema; otherwise omitted entirely —
|
||||
* same canonical short-circuit.)
|
||||
* 7. Jobs (APPENDED for the wizard provision context — lists
|
||||
* every Job whose `app === componentId`. Each row is
|
||||
* a JobCard; expand-in-place to view ordered steps.)
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #2 (no MVP / no shortcuts), the
|
||||
* canonical hero markup, modal-confirm flow, and per-section CSS are
|
||||
* preserved. The wizard surface drops install/remove buttons because
|
||||
* the deployment is one-shot — no day-2 affordance — but the section
|
||||
* order, hero, and chip palette are kept identical.
|
||||
*
|
||||
* Per #4 (never hardcode), every label and value comes from the
|
||||
* descriptor / reducer state / wizard store. There's no inlined
|
||||
* Application id.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, Link } from '@tanstack/react-router'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { PortalShell } from './PortalShell'
|
||||
import { JobCard } from './JobCard'
|
||||
import { resolveApplications, reverseDependencies, findApplication, type ApplicationDescriptor } from './applicationCatalog'
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
import { deriveJobs, jobsForApplication } from './jobs'
|
||||
import { findComponent } from '@/pages/wizard/steps/componentGroups'
|
||||
import type { ApplicationStatus } from './eventReducer'
|
||||
|
||||
interface AppDetailProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
disableStream?: boolean
|
||||
}
|
||||
|
||||
export function AppDetail({ disableStream = false }: AppDetailProps = {}) {
|
||||
const params = useParams({
|
||||
from: '/provision/$deploymentId/app/$componentId' as never,
|
||||
}) as {
|
||||
deploymentId: string
|
||||
componentId: string
|
||||
}
|
||||
const { deploymentId, componentId } = params
|
||||
const store = useWizardStore()
|
||||
|
||||
const applications = useMemo(
|
||||
() => resolveApplications(store.selectedComponents),
|
||||
[store.selectedComponents],
|
||||
)
|
||||
const applicationIds = useMemo(() => applications.map((a) => a.id), [applications])
|
||||
|
||||
const { state, snapshot } = useDeploymentEvents({
|
||||
deploymentId,
|
||||
applicationIds,
|
||||
disableStream,
|
||||
})
|
||||
|
||||
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
|
||||
const app: ApplicationDescriptor | undefined = findApplication(applications, componentId)
|
||||
const compState = state.apps[componentId]
|
||||
const status: ApplicationStatus = compState?.status ?? 'pending'
|
||||
|
||||
// Bundled dependencies — descriptors of every direct dep, with
|
||||
// human names sourced from componentGroups when available.
|
||||
const deps = useMemo<{ id: string; name: string }[]>(() => {
|
||||
if (!app) return []
|
||||
return app.dependencies.map((bareId) => {
|
||||
const c = findComponent(bareId)
|
||||
return { id: bareId, name: c?.name ?? bareId }
|
||||
})
|
||||
}, [app])
|
||||
|
||||
// Reverse deps — components that pull THIS component in. Surfaced
|
||||
// alongside bundled deps so the operator can see why this card is on
|
||||
// the grid.
|
||||
const reverseDeps = useMemo<string[]>(
|
||||
() => (app ? reverseDependencies(app.bareId) : []),
|
||||
[app],
|
||||
)
|
||||
|
||||
// Jobs scoped to this component. Phase 0 / cluster-bootstrap rows
|
||||
// are excluded — they have their own listing in JobsPage.
|
||||
const jobs = useMemo(() => deriveJobs(state, applications), [state, applications])
|
||||
const componentJobs = useMemo(
|
||||
() => jobsForApplication(jobs, componentId),
|
||||
[jobs, componentId],
|
||||
)
|
||||
|
||||
// The Connection section renders only for backing-service Applications.
|
||||
// Future-proofed: descriptors will gain a `kind` field in a later
|
||||
// catalog evolution; today we infer from family.
|
||||
const isServiceApp = useMemo(() => {
|
||||
if (!app) return false
|
||||
const c = findComponent(app.bareId)
|
||||
if (!c) return false
|
||||
return c.product === 'data-services' || c.product === 'observability'
|
||||
}, [app])
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
|
||||
<style>{APP_DETAIL_CSS}</style>
|
||||
<div className="detail-page">
|
||||
<Link
|
||||
to="/provision/$deploymentId"
|
||||
params={{ deploymentId }}
|
||||
className="back-link"
|
||||
data-testid="sov-back-link"
|
||||
>
|
||||
← Back to apps
|
||||
</Link>
|
||||
<div className="not-found" data-testid="sov-app-not-found">
|
||||
<h1>App not found</h1>
|
||||
<p>The component {componentId} is not part of this deployment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
|
||||
<style>{APP_DETAIL_CSS}</style>
|
||||
|
||||
<div className="detail-page" data-testid={`sov-app-detail-${app.id}`}>
|
||||
<Link
|
||||
to="/provision/$deploymentId"
|
||||
params={{ deploymentId }}
|
||||
className="back-link"
|
||||
data-testid="sov-back-link"
|
||||
>
|
||||
← Back to apps
|
||||
</Link>
|
||||
|
||||
{/* 1. Hero */}
|
||||
<div className="hero" data-testid="sov-hero">
|
||||
{app.logoUrl ? (
|
||||
<img src={app.logoUrl} alt={app.title} className="hero-logo" />
|
||||
) : (
|
||||
<span className="hero-icon" style={{ background: '#1f2937' }}>
|
||||
{app.title[0] ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
<div className="hero-body">
|
||||
<h1>{app.title}</h1>
|
||||
<p className="hero-tagline">{app.description || app.familyName}</p>
|
||||
<div className="hero-meta">
|
||||
<span className="chip chip-cat">{app.familyName}</span>
|
||||
{app.bootstrapKit ? <span className="chip chip-free">BOOTSTRAP</span> : null}
|
||||
{status === 'installing' ? (
|
||||
<span className="chip chip-pending">
|
||||
<span className="spinner" /> Installing…
|
||||
</span>
|
||||
) : status === 'failed' ? (
|
||||
<span className="chip chip-failed">Failed</span>
|
||||
) : status === 'degraded' ? (
|
||||
<span className="chip chip-failed">Degraded</span>
|
||||
) : status === 'installed' ? (
|
||||
<span className="chip chip-installed">
|
||||
<span className="dot" /> Installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip-cat">Pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. About */}
|
||||
<section className="section" data-testid="sov-section-about">
|
||||
<h2>About</h2>
|
||||
<p className="desc">{app.description || app.familyName}</p>
|
||||
</section>
|
||||
|
||||
{/* 3. Connection — only for service apps */}
|
||||
{isServiceApp ? (
|
||||
<section className="section" data-testid="sov-section-connection">
|
||||
<h2>Connection</h2>
|
||||
<p className="section-hint">
|
||||
Apps in this Sovereign reach this service inside the cluster. Credentials are
|
||||
injected at deploy time — no manual wiring needed.
|
||||
</p>
|
||||
<dl className="conn-grid">
|
||||
<div className="conn-row">
|
||||
<dt>Helm release</dt>
|
||||
<dd><code>{compState?.helmRelease ?? app.id}</code></dd>
|
||||
</div>
|
||||
<div className="conn-row">
|
||||
<dt>Namespace</dt>
|
||||
<dd><code>{compState?.namespace ?? 'flux-system'}</code></dd>
|
||||
</div>
|
||||
{compState?.chartVersion ? (
|
||||
<div className="conn-row">
|
||||
<dt>Chart version</dt>
|
||||
<dd><code>{compState.chartVersion}</code></dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 4. Bundled dependencies */}
|
||||
{(deps.length > 0 || reverseDeps.length > 0) ? (
|
||||
<section className="section" data-testid="sov-section-deps">
|
||||
<h2>Bundled dependencies</h2>
|
||||
{deps.length > 0 ? (
|
||||
<>
|
||||
<p className="section-hint">Auto-installed alongside {app.title}:</p>
|
||||
<ul className="dep-list">
|
||||
{deps.map((d) => (
|
||||
<li key={d.id} data-testid={`sov-dep-${d.id}`}>
|
||||
{d.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{reverseDeps.length > 0 ? (
|
||||
<>
|
||||
<p className="section-hint" style={{ marginTop: deps.length ? '0.75rem' : 0 }}>
|
||||
Pulled in by: {reverseDeps.length} component{reverseDeps.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="dep-list">
|
||||
{reverseDeps.map((id) => (
|
||||
<li key={id} data-testid={`sov-revdep-${id}`}>
|
||||
{findComponent(id)?.name ?? id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 5. Tenant */}
|
||||
<section className="section" data-testid="sov-section-tenant">
|
||||
<h2>Tenant</h2>
|
||||
<p className="desc">
|
||||
{sovereignFQDN
|
||||
? `Installing into ${sovereignFQDN} — currently ${applications.length} components targeted.`
|
||||
: `Installing into deployment ${deploymentId.slice(0, 8)} — currently ${applications.length} components targeted.`}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 6. Configuration — when descriptor exposes a config schema */}
|
||||
{/*
|
||||
The wizard's catalog descriptors don't yet expose a config_schema;
|
||||
the canonical AppDetail.svelte short-circuits the entire section
|
||||
when configSchema.length === 0, which is the behaviour we mirror.
|
||||
The hook is left here so adding schema in a future change drops
|
||||
the section back in without further plumbing.
|
||||
*/}
|
||||
|
||||
{/* 7. Jobs — appended for the wizard provision context */}
|
||||
<section className="section" data-testid="sov-section-jobs">
|
||||
<h2>Jobs</h2>
|
||||
<p className="section-hint">
|
||||
{componentJobs.length === 0
|
||||
? 'No jobs recorded yet for this component.'
|
||||
: `${componentJobs.length} job${componentJobs.length === 1 ? '' : 's'} for ${app.title}.`}
|
||||
</p>
|
||||
{componentJobs.length > 0 ? (
|
||||
<div className="jobs-list" data-testid="sov-app-jobs">
|
||||
{componentJobs.map((job) => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
deploymentId={deploymentId}
|
||||
defaultExpanded={job.status === 'running' || job.status === 'failed'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel-ported `<style>` block from canonical AppDetail.svelte. Same
|
||||
* selectors, same values; only the keyframe name is namespaced (`sov-`)
|
||||
* to avoid clashing with other pages' animations on the same surface.
|
||||
*/
|
||||
const APP_DETAIL_CSS = `
|
||||
.detail-page { max-width: 860px; margin: 0 auto; padding: 1rem 0 4rem; }
|
||||
.back-link {
|
||||
display: inline-block; margin-bottom: 1rem;
|
||||
color: var(--color-text-dim); font-size: 0.85rem; text-decoration: none;
|
||||
}
|
||||
.back-link:hover { color: var(--color-text-strong); }
|
||||
|
||||
.not-found { text-align: center; padding: 4rem 0; color: var(--color-text-dim); }
|
||||
.not-found h1 { color: var(--color-text-strong); font-size: 1.4rem; margin-bottom: 1rem; }
|
||||
|
||||
.hero {
|
||||
display: flex; align-items: flex-start; gap: 1.1rem;
|
||||
padding: 1.4rem 0; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.hero-logo { width: 80px; height: 80px; border-radius: 18px; object-fit: cover; flex-shrink: 0; }
|
||||
.hero-icon {
|
||||
width: 80px; height: 80px; border-radius: 18px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 1.8rem; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.hero-body { flex: 1; min-width: 0; }
|
||||
.hero-body h1 { margin: 0; color: var(--color-text-strong); font-size: 1.4rem; font-weight: 700; }
|
||||
.hero-tagline { margin: 0.25rem 0 0.6rem; color: var(--color-text-dim); font-size: 0.9rem; }
|
||||
.hero-meta { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.7rem; font-weight: 600; white-space: nowrap; }
|
||||
.chip-cat { background: color-mix(in srgb, var(--color-border) 50%, transparent); color: var(--color-text-dim); text-transform: capitalize; }
|
||||
.chip-free { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); }
|
||||
.chip-installed { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: var(--color-success); }
|
||||
.chip-installed .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.chip-pending { background: color-mix(in srgb, var(--color-accent) 14%, transparent); color: var(--color-accent); }
|
||||
.chip-pending .spinner {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
border: 2px solid currentColor; border-top-color: transparent;
|
||||
animation: sov-detail-spin 0.7s linear infinite;
|
||||
}
|
||||
.chip-failed { background: color-mix(in srgb, var(--color-danger) 14%, transparent); color: var(--color-danger); }
|
||||
@keyframes sov-detail-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.section { padding: 1.1rem 0; border-bottom: 1px solid var(--color-border); }
|
||||
.section:last-of-type { border-bottom: none; }
|
||||
.section h2 { margin: 0 0 0.5rem; font-size: 0.98rem; font-weight: 600; color: var(--color-text-strong); }
|
||||
.section-hint { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-dim); }
|
||||
.desc { margin: 0; color: var(--color-text); font-size: 0.9rem; line-height: 1.6; }
|
||||
.conn-grid { margin: 0.4rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
|
||||
.conn-row { display: grid; grid-template-columns: 6rem 1fr; gap: 0.6rem; align-items: baseline; }
|
||||
.conn-row dt {
|
||||
margin: 0;
|
||||
color: var(--color-text-dim);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.conn-row dd { margin: 0; font-size: 0.88rem; color: var(--color-text); }
|
||||
.conn-row code {
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-surface);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.dep-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.dep-list li {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.jobs-list { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
`
|
||||
@ -1,206 +0,0 @@
|
||||
/**
|
||||
* ApplicationCard — single Application tile rendered in the AdminPage
|
||||
* grid. Geometry mirrors the wizard's StepComponents `corp-comp-card`
|
||||
* 1:1 — same 108px height, 4-line text rhythm, brand-coloured logo
|
||||
* tile, family chip on line 1, tier chip + dependency chips on line 4.
|
||||
*
|
||||
* The departure from the wizard card: the trailing-edge affordance on
|
||||
* line 1 is a STATUS PILL (not a toggle button), and the entire card
|
||||
* is a Link to `/sovereign/provision/$deploymentId/app/$componentId`.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every visual
|
||||
* decision (logo tone, family chip palette, tier label) reads from the
|
||||
* existing data modules — `logoTone.ts`, `marketplaceCopy.ts`,
|
||||
* `componentGroups.ts`. New components added to those modules render
|
||||
* automatically with the correct chrome.
|
||||
*/
|
||||
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { getLogoToneStyle } from '@/pages/wizard/steps/logoTone'
|
||||
import { familyChipPalette } from '@/pages/marketplace/marketplaceCopy'
|
||||
import type { ApplicationDescriptor } from './applicationCatalog'
|
||||
import type { ApplicationStatus } from './eventReducer'
|
||||
import { StatusPill } from './StatusPill'
|
||||
|
||||
interface ApplicationCardProps {
|
||||
app: ApplicationDescriptor
|
||||
status: ApplicationStatus
|
||||
deploymentId: string
|
||||
}
|
||||
|
||||
const LOGO_TILE_RADIUS = 10
|
||||
const LOGO_TILE_PADDING = 6
|
||||
|
||||
/** Brand-coloured logo tile — same component the wizard uses. */
|
||||
function ComponentLogo({ app }: { app: ApplicationDescriptor }) {
|
||||
const tone = getLogoToneStyle(app.bareId)
|
||||
if (!app.logoUrl) {
|
||||
const letter = (app.title[0] ?? '?').toUpperCase()
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
alignSelf: 'stretch',
|
||||
aspectRatio: '1 / 1',
|
||||
height: 'auto',
|
||||
borderRadius: LOGO_TILE_RADIUS,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
color: tone.text,
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 700,
|
||||
background: tone.background,
|
||||
border: `1px solid ${tone.border}`,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
alignSelf: 'stretch',
|
||||
aspectRatio: '1 / 1',
|
||||
height: 'auto',
|
||||
borderRadius: LOGO_TILE_RADIUS,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
background: tone.background,
|
||||
border: `1px solid ${tone.border}`,
|
||||
overflow: 'hidden',
|
||||
padding: LOGO_TILE_PADDING,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={app.logoUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
data-testid={`app-logo-${app.id}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const TIER_TONE = {
|
||||
mandatory: { bg: 'rgba(74,222,128,0.16)', fg: '#4ADE80', label: 'Always' },
|
||||
recommended: { bg: 'rgba(56,189,248,0.16)', fg: '#38BDF8', label: 'Recommended' },
|
||||
optional: { bg: 'rgba(167,139,250,0.16)', fg: '#A78BFA', label: 'Optional' },
|
||||
} as const
|
||||
|
||||
export function ApplicationCard({ app, status, deploymentId }: ApplicationCardProps) {
|
||||
const palette = familyChipPalette(app.familyId)
|
||||
const tier = TIER_TONE[app.tier]
|
||||
return (
|
||||
<Link
|
||||
to="/provision/$deploymentId/app/$componentId"
|
||||
params={{ deploymentId, componentId: app.id }}
|
||||
data-testid={`app-card-${app.id}`}
|
||||
data-status={status}
|
||||
data-bootstrap={app.bootstrapKit ? 'true' : 'false'}
|
||||
className="sov-app-card corp-comp-card"
|
||||
aria-label={`${app.title} application — ${status}`}
|
||||
>
|
||||
<ComponentLogo app={app} />
|
||||
<div className="corp-comp-body">
|
||||
{/* Line 1 — name (left, flex) + family chip + status pill (right). */}
|
||||
<div className="corp-comp-top">
|
||||
<span className="corp-comp-name">{app.title}</span>
|
||||
<span
|
||||
data-testid={`app-family-${app.id}`}
|
||||
className="corp-comp-family-chip"
|
||||
style={{
|
||||
background: palette.bg,
|
||||
color: palette.fg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
}}
|
||||
title={`${app.familyName} family`}
|
||||
>
|
||||
{app.familyName}
|
||||
</span>
|
||||
<StatusPill
|
||||
status={status}
|
||||
size="sm"
|
||||
testId={`app-status-${app.id}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Lines 2-3 — description, two-line clamp. */}
|
||||
<p className="corp-comp-desc">{app.description}</p>
|
||||
{/* Line 4 — tier chip + dependency chips + bootstrap-kit pin. */}
|
||||
<div className="corp-comp-chips">
|
||||
<span
|
||||
data-testid={`app-tier-${app.id}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '0.1rem 0.45rem',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
background: tier.bg,
|
||||
color: tier.fg,
|
||||
}}
|
||||
>
|
||||
{app.tier === 'mandatory' && <Lock size={9} strokeWidth={3} aria-hidden />}
|
||||
{tier.label}
|
||||
</span>
|
||||
{app.bootstrapKit && (
|
||||
<span
|
||||
data-testid={`app-bootstrap-${app.id}`}
|
||||
title="Always installed during cluster bootstrap"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.1rem 0.45rem',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--wiz-text-md)',
|
||||
border: '1px solid var(--wiz-border-sub)',
|
||||
}}
|
||||
>
|
||||
bootstrap-kit
|
||||
</span>
|
||||
)}
|
||||
{app.dependencies.slice(0, 3).map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
data-testid={`app-dep-${app.id}-${dep}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.1rem 0.45rem',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 600,
|
||||
background: 'rgba(56,189,248,0.10)',
|
||||
color: '#38BDF8',
|
||||
}}
|
||||
>
|
||||
+ {dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
/**
|
||||
* ApplicationPage.test.tsx — vitest coverage for the per-Application
|
||||
* detail page reached at `/sovereign/provision/$deploymentId/app/$componentId`.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders the four-tab navigation (Logs / Dependencies / Status /
|
||||
* Overview).
|
||||
* 2. Logs tab populates from the /events GET replay, filtered by
|
||||
* `event.component === componentId`.
|
||||
* 3. Tab switching flips the rendered panel.
|
||||
* 4. Dependencies tab surfaces both directions (depends on +
|
||||
* depended on by) using the catalog edges.
|
||||
* 5. Status tab reads helm release / namespace / chart version from
|
||||
* the reducer state and falls back to "unknown" when absent.
|
||||
* 6. Overview tab renders the marketplaceCopy positioning paragraph
|
||||
* and upstream link.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { ApplicationPage } from './ApplicationPage'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
|
||||
const DEPLOYMENT_ID = 'depl-aaa-2222'
|
||||
const COMPONENT_ID = 'bp-cilium'
|
||||
|
||||
function renderApp(componentId = COMPONENT_ID) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => null,
|
||||
})
|
||||
const provisionAppRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: () => <ApplicationPage disableStream={true} />,
|
||||
})
|
||||
const routeTree = rootRoute.addChildren([provisionRoute, provisionAppRoute])
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
history: createMemoryHistory({
|
||||
initialEntries: [`/provision/${DEPLOYMENT_ID}/app/${componentId}`],
|
||||
}),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = NoopResizeObserver
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
const s = useWizardStore.getState()
|
||||
s.setComponents([])
|
||||
s.reset?.()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('ApplicationPage — per-Application tab navigation', () => {
|
||||
it('renders the Logs / Dependencies / Status / Overview tablist', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderApp()
|
||||
|
||||
const tablist = await screen.findByTestId('sov-tablist')
|
||||
expect(tablist).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-tab-logs')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-tab-dependencies')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-tab-status')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-tab-overview')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Logs tab populates from /events history (replay filtered by component id)', async () => {
|
||||
const events = [
|
||||
{ time: '2026-04-29T15:00:00Z', phase: 'install', component: 'bp-cilium', state: 'installing', message: 'Reconciling Cilium HelmRelease' },
|
||||
{ time: '2026-04-29T15:00:30Z', phase: 'install', component: 'bp-cilium', state: 'installing', message: 'Cilium DaemonSet rolling' },
|
||||
// A non-matching event — must NOT appear in the cilium log.
|
||||
{ time: '2026-04-29T15:00:45Z', phase: 'install', component: 'bp-flux', state: 'installing', message: 'Flux reconciling' },
|
||||
{ time: '2026-04-29T15:01:00Z', phase: 'install', component: 'bp-cilium', state: 'installed', message: 'Cilium DaemonSet ready' },
|
||||
]
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
events,
|
||||
state: { id: DEPLOYMENT_ID, status: 'provisioning', numEvents: events.length },
|
||||
done: false,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
|
||||
renderApp()
|
||||
|
||||
const log = await screen.findByTestId('sov-app-log')
|
||||
await waitFor(() => {
|
||||
expect(log.textContent).toContain('Reconciling Cilium')
|
||||
})
|
||||
expect(log.textContent).toContain('Cilium DaemonSet ready')
|
||||
// The flux line was filtered out because its component is bp-flux,
|
||||
// not bp-cilium.
|
||||
expect(log.textContent ?? '').not.toContain('Flux reconciling')
|
||||
})
|
||||
|
||||
it('switching to the Dependencies tab renders both depends-on and depended-on-by panels', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
// Use cert-manager — depends on external-dns; depended on by … none directly.
|
||||
renderApp('bp-cert-manager')
|
||||
|
||||
const tabBtn = await screen.findByTestId('sov-tab-dependencies')
|
||||
fireEvent.click(tabBtn)
|
||||
|
||||
const depsTab = await screen.findByTestId('sov-deps-tab')
|
||||
expect(depsTab).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-deps-on')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-deps-by')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Status tab reads helm release / namespace / chart from per-component event state', async () => {
|
||||
const events = [
|
||||
{
|
||||
time: '2026-04-29T15:00:00Z',
|
||||
phase: 'install',
|
||||
component: 'bp-cilium',
|
||||
state: 'installing',
|
||||
message: 'Reconciling',
|
||||
helmRelease: 'cilium',
|
||||
namespace: 'kube-system',
|
||||
chartVersion: '1.17.6',
|
||||
},
|
||||
]
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
events,
|
||||
state: { id: DEPLOYMENT_ID, status: 'provisioning', numEvents: events.length },
|
||||
done: false,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
|
||||
renderApp()
|
||||
|
||||
const tabBtn = await screen.findByTestId('sov-tab-status')
|
||||
fireEvent.click(tabBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
const helm = screen.getByTestId('sov-status-helm')
|
||||
expect(helm.textContent).toContain('cilium')
|
||||
})
|
||||
const ns = screen.getByTestId('sov-status-ns')
|
||||
expect(ns.textContent).toContain('kube-system')
|
||||
const chart = screen.getByTestId('sov-status-chart')
|
||||
expect(chart.textContent).toContain('1.17.6')
|
||||
})
|
||||
|
||||
it('Overview tab renders the marketplaceCopy positioning paragraph + upstream link', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderApp()
|
||||
|
||||
const tabBtn = await screen.findByTestId('sov-tab-overview')
|
||||
fireEvent.click(tabBtn)
|
||||
|
||||
const overview = await screen.findByTestId('sov-overview-tab')
|
||||
expect(overview).toBeTruthy()
|
||||
// Cilium has marketplaceCopy.COMPONENT_COPY → renders positioning + upstream.
|
||||
const positioning = screen.getByTestId('sov-overview-positioning')
|
||||
expect(positioning.textContent ?? '').toMatch(/Cilium/i)
|
||||
const upstream = screen.getByTestId('sov-overview-upstream')
|
||||
expect(upstream.textContent ?? '').toMatch(/cilium\.io/i)
|
||||
})
|
||||
|
||||
it('renders the not-found surface when the component id is unknown to this Sovereign', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ events: [], state: { id: DEPLOYMENT_ID, status: 'pending' }, done: false }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderApp('bp-this-does-not-exist')
|
||||
|
||||
const notFound = await screen.findByTestId('sov-app-not-found')
|
||||
expect(notFound).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@ -1,487 +0,0 @@
|
||||
/**
|
||||
* ApplicationPage — per-Application detail surface served at
|
||||
* `/sovereign/provision/$deploymentId/app/$componentId`. Reached by
|
||||
* clicking any card on the AdminPage grid. Four tabs:
|
||||
*
|
||||
* 1. Logs — every event whose `component` matches this
|
||||
* Application id, replayed from /events on mount
|
||||
* and streamed live thereafter. Auto-scrolls to
|
||||
* the bottom on new lines, level-coloured, with
|
||||
* timestamp + phase prefixes.
|
||||
* 2. Dependencies — both directions. "Depends on" walks the
|
||||
* component graph from componentGroups; "Depended
|
||||
* on by" inverts it. Each dep is a clickable mini-
|
||||
* card linking to its own ApplicationPage. Family-
|
||||
* level dependencies surface for completeness.
|
||||
* 3. Status — current install state, namespace, helm-release
|
||||
* name, chart version, last-reconciled time.
|
||||
* Reads the per-component reducer state; falls
|
||||
* back to "unknown" when the catalyst-api hasn't
|
||||
* emitted those fields yet.
|
||||
* 4. Overview — long-form copy from marketplaceCopy.ts, upstream
|
||||
* project link, family tagline.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every label
|
||||
* + dep edge + upstream URL is read from existing data modules. New
|
||||
* components added to componentGroups + marketplaceCopy render
|
||||
* automatically with the right chrome.
|
||||
*
|
||||
* Per #2 (never compromise), graceful-degrade is INFORMATIONAL not
|
||||
* functional — the page renders all four tabs even if /events hasn't
|
||||
* landed; the Status tab simply reads "unknown" for fields the API
|
||||
* hasn't emitted yet.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useRouter, Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, ExternalLink } from 'lucide-react'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import {
|
||||
findProduct,
|
||||
type ComponentEntry,
|
||||
} from '@/pages/wizard/steps/componentGroups'
|
||||
import { findApplication, resolveApplications, reverseDependencies } from './applicationCatalog'
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
import { AdminShell } from './AdminShell'
|
||||
import { StatusPill } from './StatusPill'
|
||||
import { COMPONENT_COPY, FAMILY_COPY, familyChipPalette } from '@/pages/marketplace/marketplaceCopy'
|
||||
import { findComponent } from '@/pages/wizard/steps/componentGroups'
|
||||
import { normaliseComponentId, type DeploymentEvent } from './eventReducer'
|
||||
|
||||
type TabKey = 'logs' | 'dependencies' | 'status' | 'overview'
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'logs', label: 'Logs' },
|
||||
{ key: 'dependencies', label: 'Dependencies' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
]
|
||||
|
||||
interface ApplicationPageProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
disableStream?: boolean
|
||||
/** Test seam — initial tab override. */
|
||||
initialTab?: TabKey
|
||||
}
|
||||
|
||||
export function ApplicationPage({ disableStream = false, initialTab = 'logs' }: ApplicationPageProps = {}) {
|
||||
const params = useParams({ from: '/provision/$deploymentId/app/$componentId' as never }) as {
|
||||
deploymentId: string
|
||||
componentId: string
|
||||
}
|
||||
const deploymentId = params.deploymentId
|
||||
const componentId = normaliseComponentId(params.componentId) ?? params.componentId
|
||||
const router = useRouter()
|
||||
const store = useWizardStore()
|
||||
|
||||
const applications = useMemo(
|
||||
() => resolveApplications(store.selectedComponents),
|
||||
[store.selectedComponents],
|
||||
)
|
||||
const application = findApplication(applications, componentId)
|
||||
|
||||
const applicationIds = useMemo(
|
||||
() => applications.map((a) => a.id),
|
||||
[applications],
|
||||
)
|
||||
|
||||
const { state, snapshot, startedAt, finishedAt, streamStatus } =
|
||||
useDeploymentEvents({
|
||||
deploymentId,
|
||||
applicationIds,
|
||||
disableStream,
|
||||
})
|
||||
|
||||
const [tab, setTab] = useState<TabKey>(initialTab)
|
||||
|
||||
const appState = state.apps[componentId]
|
||||
const events = state.eventsByTarget[componentId] ?? []
|
||||
const status = appState?.status ?? 'unknown'
|
||||
|
||||
if (!application) {
|
||||
return (
|
||||
<AdminShell
|
||||
deploymentId={deploymentId}
|
||||
state={state}
|
||||
snapshot={snapshot}
|
||||
applications={applications}
|
||||
startedAt={startedAt}
|
||||
finishedAt={finishedAt}
|
||||
breadcrumb={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.navigate({ to: '/provision/$deploymentId', params: { deploymentId } })}
|
||||
className="sov-back-link"
|
||||
data-testid="sov-app-back"
|
||||
>
|
||||
<ArrowLeft size={12} aria-hidden /> All applications
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="sov-failure" role="alert" data-testid="sov-app-not-found">
|
||||
<h3>Unknown application</h3>
|
||||
<p>
|
||||
<code>{componentId}</code> is not part of this Sovereign's installation
|
||||
set. The application list is computed from the bootstrap-kit and the
|
||||
wizard's selected components — components you didn't select don't
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
deploymentId={deploymentId}
|
||||
state={state}
|
||||
snapshot={snapshot}
|
||||
applications={applications}
|
||||
startedAt={startedAt}
|
||||
finishedAt={finishedAt}
|
||||
breadcrumb={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.navigate({ to: '/provision/$deploymentId', params: { deploymentId } })}
|
||||
className="sov-back-link"
|
||||
data-testid="sov-app-back"
|
||||
>
|
||||
<ArrowLeft size={12} aria-hidden /> All applications
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<header className="sov-app-header" data-testid="sov-app-header">
|
||||
<div className="sov-app-meta">
|
||||
<h1 className="sov-app-title" data-testid="sov-app-title">{application.title}</h1>
|
||||
<span className="sov-app-sub">
|
||||
<span data-testid="sov-app-family">{application.familyName}</span>
|
||||
{' · '}
|
||||
<span className="sov-mono">{application.id}</span>
|
||||
{application.bootstrapKit && (
|
||||
<>
|
||||
{' · '}
|
||||
<span style={{ color: 'var(--wiz-text-md)' }}>bootstrap-kit</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill status={status} size="md" testId="sov-app-status" />
|
||||
</header>
|
||||
|
||||
<div role="tablist" className="sov-tablist" data-testid="sov-tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="sov-tab"
|
||||
data-testid={`sov-tab-${t.key}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sov-tabpanel" data-testid={`sov-tabpanel-${tab}`}>
|
||||
{tab === 'logs' && (
|
||||
<LogsTab events={events} streamStatus={streamStatus} />
|
||||
)}
|
||||
{tab === 'dependencies' && (
|
||||
<DependenciesTab application={application} deploymentId={deploymentId} />
|
||||
)}
|
||||
{tab === 'status' && (
|
||||
<StatusTab application={application} appState={appState} status={status} />
|
||||
)}
|
||||
{tab === 'overview' && (
|
||||
<OverviewTab application={application} />
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Tab: Logs ─────────────────────────────────────────────────── */
|
||||
|
||||
interface LogsTabProps {
|
||||
events: readonly DeploymentEvent[]
|
||||
streamStatus: string
|
||||
}
|
||||
|
||||
function LogsTab({ events, streamStatus }: LogsTabProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
// Auto-scroll to the bottom when new lines arrive — the operator is
|
||||
// watching live install output and expects the view to follow.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [events.length])
|
||||
|
||||
return (
|
||||
<div className="sov-log" ref={ref} data-testid="sov-app-log">
|
||||
{events.length === 0 ? (
|
||||
<div className="sov-log-empty" data-testid="sov-app-log-empty">
|
||||
{streamStatus === 'connecting'
|
||||
? 'Connecting to the catalyst-api event stream — logs will populate as events arrive.'
|
||||
: 'No events emitted for this application yet.'}
|
||||
</div>
|
||||
) : (
|
||||
events.map((ev, i) => (
|
||||
<div key={i} className="sov-log-line" data-level={ev.level ?? 'info'}>
|
||||
<span className="sov-log-ts">{(ev.time ?? '').slice(11, 19) || '—'}</span>
|
||||
<span className="sov-log-phase">{ev.phase}</span>
|
||||
<span className="sov-log-msg">{ev.message ?? ''}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Tab: Dependencies ─────────────────────────────────────────── */
|
||||
|
||||
interface DependenciesTabProps {
|
||||
application: ReturnType<typeof findApplication> & { id: string }
|
||||
deploymentId: string
|
||||
}
|
||||
|
||||
function DependenciesTab({ application, deploymentId }: DependenciesTabProps) {
|
||||
if (!application) return null
|
||||
const dependsOn = (application.dependencies ?? [])
|
||||
.map((bare) => findComponent(bare))
|
||||
.filter((c): c is ComponentEntry => !!c)
|
||||
|
||||
const dependedBy = reverseDependencies(application.bareId)
|
||||
.map((blueprintId) => {
|
||||
const bare = blueprintId.replace(/^bp-/, '')
|
||||
return findComponent(bare)
|
||||
})
|
||||
.filter((c): c is ComponentEntry => !!c)
|
||||
|
||||
const family = findProduct(application.familyId)
|
||||
const familyDeps = (family?.familyDependencies ?? [])
|
||||
.map((id) => findProduct(id))
|
||||
.filter((p): p is NonNullable<typeof p> => !!p)
|
||||
|
||||
return (
|
||||
<div data-testid="sov-deps-tab" style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<DepBlock
|
||||
title="Depends on"
|
||||
emptyHint="This component has no upstream component dependencies."
|
||||
components={dependsOn}
|
||||
deploymentId={deploymentId}
|
||||
testIdPrefix="sov-deps-on"
|
||||
/>
|
||||
<DepBlock
|
||||
title="Depended on by"
|
||||
emptyHint="No other component in this Sovereign declares this as a dependency."
|
||||
components={dependedBy}
|
||||
deploymentId={deploymentId}
|
||||
testIdPrefix="sov-deps-by"
|
||||
/>
|
||||
{familyDeps.length > 0 && (
|
||||
<section className="sov-card" data-testid="sov-deps-family">
|
||||
<h3>Family dependencies</h3>
|
||||
<p>
|
||||
The {application.familyName} family pulls in {familyDeps.length}{' '}
|
||||
additional product{familyDeps.length === 1 ? '' : 's'}: {' '}
|
||||
{familyDeps.map((f, i) => (
|
||||
<span key={f.id}>
|
||||
{i > 0 && ', '}
|
||||
<strong>{f.name}</strong>
|
||||
</span>
|
||||
))}
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DepBlockProps {
|
||||
title: string
|
||||
emptyHint: string
|
||||
components: readonly ComponentEntry[]
|
||||
deploymentId: string
|
||||
testIdPrefix: string
|
||||
}
|
||||
|
||||
function DepBlock({ title, emptyHint, components, deploymentId, testIdPrefix }: DepBlockProps) {
|
||||
return (
|
||||
<section data-testid={testIdPrefix}>
|
||||
<div className="sov-sec-head">
|
||||
<h2 className="sov-sec-h">{title}</h2>
|
||||
<span className="sov-sec-meta">{components.length}</span>
|
||||
</div>
|
||||
{components.length === 0 ? (
|
||||
<p style={{ color: 'var(--wiz-text-sub)', fontSize: '0.85rem' }}>{emptyHint}</p>
|
||||
) : (
|
||||
<div className="sov-grid-sm">
|
||||
{components.map((c) => {
|
||||
const palette = familyChipPalette(c.product)
|
||||
const blueprintId = normaliseComponentId(c.id) ?? c.id
|
||||
return (
|
||||
<Link
|
||||
key={c.id}
|
||||
to="/provision/$deploymentId/app/$componentId"
|
||||
params={{ deploymentId, componentId: blueprintId }}
|
||||
className="sov-card"
|
||||
data-testid={`${testIdPrefix}-${c.id}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<strong style={{ color: 'var(--wiz-text-hi)', fontSize: '0.85rem' }}>{c.name}</strong>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
background: palette.bg,
|
||||
color: palette.fg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
}}
|
||||
>
|
||||
{c.groupName}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--wiz-text-md)', margin: 0 }}>{c.desc}</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Tab: Status ───────────────────────────────────────────────── */
|
||||
|
||||
interface StatusTabProps {
|
||||
application: ReturnType<typeof findApplication> & { id: string }
|
||||
appState: ReturnType<typeof Object.assign> & {
|
||||
status?: string
|
||||
helmRelease?: string | null
|
||||
namespace?: string | null
|
||||
chartVersion?: string | null
|
||||
lastEventTime?: string | null
|
||||
eventCount?: number
|
||||
} | undefined
|
||||
status: string
|
||||
}
|
||||
|
||||
function StatusTab({ application, appState, status }: StatusTabProps) {
|
||||
if (!application) return null
|
||||
const helmRelease = appState?.helmRelease ?? application.bareId
|
||||
const namespace = appState?.namespace ?? deriveNamespaceFallback(application.bareId)
|
||||
const chartVersion = appState?.chartVersion ?? 'unknown'
|
||||
const lastEvent = appState?.lastEventTime ?? null
|
||||
const eventCount = appState?.eventCount ?? 0
|
||||
|
||||
return (
|
||||
<div className="sov-grid-sm" data-testid="sov-status-tab">
|
||||
<div className="sov-card" data-testid="sov-status-state">
|
||||
<h3>Install state</h3>
|
||||
<p style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--wiz-text-hi)', textTransform: 'capitalize' }}>
|
||||
{status}
|
||||
</p>
|
||||
<p>
|
||||
{eventCount === 0
|
||||
? 'No events emitted for this application yet.'
|
||||
: `${eventCount} event${eventCount === 1 ? '' : 's'} processed.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-status-helm">
|
||||
<h3>Helm release</h3>
|
||||
<p className="sov-mono" style={{ fontSize: '0.85rem' }}>{helmRelease}</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-status-ns">
|
||||
<h3>Namespace</h3>
|
||||
<p className="sov-mono" style={{ fontSize: '0.85rem' }}>{namespace}</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-status-chart">
|
||||
<h3>Chart version</h3>
|
||||
<p className="sov-mono" style={{ fontSize: '0.85rem' }}>{chartVersion}</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-status-last">
|
||||
<h3>Last reconciled</h3>
|
||||
<p className="sov-mono" style={{ fontSize: '0.85rem' }}>
|
||||
{lastEvent ? new Date(lastEvent).toLocaleString() : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-status-tier">
|
||||
<h3>Catalyst tier</h3>
|
||||
<p style={{ textTransform: 'capitalize' }}>{application.tier}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort namespace fallback when the catalyst-api hasn't emitted
|
||||
* `namespace` on a per-component event. Mirrors the one-namespace-
|
||||
* per-Blueprint convention used across the cluster manifests.
|
||||
*/
|
||||
function deriveNamespaceFallback(bareId: string): string {
|
||||
// Most platform Blueprints land in a namespace matching their slug;
|
||||
// Catalyst-internal services use the `catalyst` umbrella.
|
||||
if (bareId === 'flux' || bareId === 'crossplane' || bareId === 'cilium') return 'kube-system'
|
||||
if (bareId === 'cert-manager') return 'cert-manager'
|
||||
if (bareId === 'sealed-secrets') return 'sealed-secrets'
|
||||
return bareId
|
||||
}
|
||||
|
||||
/* ── Tab: Overview ─────────────────────────────────────────────── */
|
||||
|
||||
function OverviewTab({ application }: { application: ReturnType<typeof findApplication> }) {
|
||||
if (!application) return null
|
||||
const copy = COMPONENT_COPY[application.bareId]
|
||||
const familyCopy = FAMILY_COPY[application.familyId]
|
||||
return (
|
||||
<div data-testid="sov-overview-tab" style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{familyCopy && (
|
||||
<div className="sov-card" data-testid="sov-overview-family">
|
||||
<h3>{application.familyName} family</h3>
|
||||
<p>{familyCopy.tagline}</p>
|
||||
</div>
|
||||
)}
|
||||
{copy ? (
|
||||
<>
|
||||
<div className="sov-card" data-testid="sov-overview-positioning">
|
||||
<h3>What it does</h3>
|
||||
<p>{copy.positioning}</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-overview-integration">
|
||||
<h3>How it integrates</h3>
|
||||
<p>{copy.integration}</p>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-overview-highlights">
|
||||
<h3>Highlights</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--wiz-text-md)', fontSize: '0.85rem', lineHeight: 1.6 }}>
|
||||
{copy.highlights.map((h, i) => <li key={i}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sov-card" data-testid="sov-overview-upstream">
|
||||
<h3>Upstream project</h3>
|
||||
<p>
|
||||
<a href={copy.upstreamUrl} target="_blank" rel="noopener noreferrer">
|
||||
{copy.upstreamLabel}
|
||||
<ExternalLink size={11} style={{ marginLeft: '0.25rem' }} aria-hidden />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="sov-card" data-testid="sov-overview-fallback">
|
||||
<h3>About this application</h3>
|
||||
<p>{application.description || 'Catalyst-curated platform component.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* AppsPage.test.tsx — pixel-port lock-in for the Sovereign Apps surface.
|
||||
*
|
||||
* • Page heading + tagline render
|
||||
* • Both tabs render with counts pulled from the resolved catalog
|
||||
* (Deployments + Catalog), the canonical .tab/.active class string
|
||||
* • Card grid renders one .app-card per Application descriptor on
|
||||
* first paint (waterfall — no spinner state)
|
||||
* • Search filter narrows the visible cards by title / description /
|
||||
* family
|
||||
* • Sidebar nav surfaces are present (PortalShell wiring)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { AppsPage } from './AppsPage'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
|
||||
|
||||
function renderProvision(deploymentId: string) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <AppsPage disableStream />,
|
||||
})
|
||||
const detailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: () => <div data-testid="app-detail-target" />,
|
||||
})
|
||||
const jobsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs',
|
||||
component: () => <div data-testid="jobs-target" />,
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <div data-testid="wizard-target" />,
|
||||
})
|
||||
const tree = rootRoute.addChildren([provisionRoute, detailRoute, jobsRoute, wizardRoute])
|
||||
const router = createRouter({
|
||||
routeTree: tree,
|
||||
history: createMemoryHistory({ initialEntries: [`/provision/${deploymentId}`] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
|
||||
// Stub fetch so useDeploymentEvents history-replay path resolves
|
||||
// synchronously without making real network calls.
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ events: [], state: undefined, done: false }),
|
||||
} as unknown as Response)) as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('AppsPage — header', () => {
|
||||
it('renders Applications heading', async () => {
|
||||
renderProvision('d-1')
|
||||
expect(await screen.findByText('Applications')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mounts inside the PortalShell (sidebar present)', async () => {
|
||||
renderProvision('d-1')
|
||||
expect(await screen.findByTestId('sov-portal-shell')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-sidebar')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsPage — tabs', () => {
|
||||
it('renders Deployments + Catalog tabs', async () => {
|
||||
renderProvision('d-1')
|
||||
const tabs = await screen.findByTestId('sov-tabs')
|
||||
expect(within(tabs).getByTestId('sov-tab-installed')).toBeTruthy()
|
||||
expect(within(tabs).getByTestId('sov-tab-catalog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Deployments tab is active by default', async () => {
|
||||
renderProvision('d-1')
|
||||
const installed = await screen.findByTestId('sov-tab-installed')
|
||||
expect(installed.className).toContain('active')
|
||||
})
|
||||
|
||||
it('clicking Catalog flips active to Catalog', async () => {
|
||||
renderProvision('d-1')
|
||||
const catalog = await screen.findByTestId('sov-tab-catalog')
|
||||
fireEvent.click(catalog)
|
||||
expect(catalog.className).toContain('active')
|
||||
const installed = screen.getByTestId('sov-tab-installed')
|
||||
expect(installed.className).not.toContain('active')
|
||||
})
|
||||
|
||||
it('tabs render counts that mirror the catalog', async () => {
|
||||
renderProvision('d-1')
|
||||
const tabs = await screen.findByTestId('sov-tabs')
|
||||
// Catalog count > 0 because BOOTSTRAP_KIT (11+) is always present.
|
||||
const catalog = within(tabs).getByTestId('sov-tab-catalog')
|
||||
const countSpan = catalog.querySelector('.tab-count')
|
||||
expect(countSpan).toBeTruthy()
|
||||
const n = Number((countSpan!.textContent ?? '').trim())
|
||||
expect(n).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsPage — card grid', () => {
|
||||
it('renders one .app-card per Application from first paint', async () => {
|
||||
renderProvision('d-1')
|
||||
// Deployments tab is active — bootstrap-kit cards are always
|
||||
// counted as deployed, so the grid is non-empty.
|
||||
fireEvent.click(await screen.findByTestId('sov-tab-catalog'))
|
||||
const grid = await screen.findByTestId('sov-apps-grid')
|
||||
const cards = within(grid).getAllByTestId(/^sov-app-card-bp-/)
|
||||
expect(cards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('grid uses the canonical .apps-grid class (auto-fit minmax 360px)', async () => {
|
||||
renderProvision('d-1')
|
||||
fireEvent.click(await screen.findByTestId('sov-tab-catalog'))
|
||||
const grid = await screen.findByTestId('sov-apps-grid')
|
||||
expect(grid.className).toContain('apps-grid')
|
||||
})
|
||||
|
||||
it('search filter narrows the visible cards', async () => {
|
||||
renderProvision('d-1')
|
||||
fireEvent.click(await screen.findByTestId('sov-tab-catalog'))
|
||||
const before = within(await screen.findByTestId('sov-apps-grid')).getAllByTestId(/^sov-app-card-bp-/)
|
||||
fireEvent.change(screen.getByTestId('sov-search'), { target: { value: 'cilium' } })
|
||||
const after = within(await screen.findByTestId('sov-apps-grid')).getAllByTestId(/^sov-app-card-bp-/)
|
||||
expect(after.length).toBeLessThan(before.length)
|
||||
// Still see Cilium.
|
||||
expect(after.some((c) => c.getAttribute('data-testid') === 'sov-app-card-bp-cilium')).toBe(true)
|
||||
})
|
||||
})
|
||||
592
products/catalyst/bootstrap/ui/src/pages/sovereign/AppsPage.tsx
Normal file
592
products/catalyst/bootstrap/ui/src/pages/sovereign/AppsPage.tsx
Normal file
@ -0,0 +1,592 @@
|
||||
/**
|
||||
* AppsPage — pixel-port of core/console/src/components/AppsPage.svelte.
|
||||
*
|
||||
* Layout (top-down, byte-identical to canonical class names):
|
||||
* • Header row: <h1>Applications</h1> + tagline + (provisioning pill
|
||||
* OR install-history link) right-aligned.
|
||||
* • Tabs: "Deployments" | "Catalog" — same `.tabs / .tab / .tab-count`
|
||||
* CSS the canonical surface uses, with the `.active` state on the
|
||||
* selected tab. Counts read from `installedApps.length` /
|
||||
* `catalogApps.length`.
|
||||
* • Search row: `.apps-toolbar > .search-wrap > .search-icon +
|
||||
* .search-input` — visually identical to canonical AppsPage.
|
||||
* • Card grid: `.apps-grid` (`grid-template-columns: repeat(auto-fit,
|
||||
* minmax(360px, 1fr))`). Each `.app-card` is a clickable surface
|
||||
* navigating to AppDetail. `state-installed / state-installing /
|
||||
* state-failed / state-pending` modifier classes flow through.
|
||||
* • Empty state: `.empty-state` when the Deployments tab is empty.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall — first paint is the
|
||||
* full grid), the cards render from the moment the page mounts, before
|
||||
* any /events have arrived. Each card starts in `pending` and flips
|
||||
* states through the reducer.
|
||||
*
|
||||
* Per #2 (no MVP / iterative shortcuts), the canonical empty-state
|
||||
* affordance is preserved verbatim — clicking "Open catalog →" flips
|
||||
* the tab without a separate spinner state.
|
||||
*
|
||||
* Per #4 (never hardcode), the application list is computed by
|
||||
* `resolveApplications()` from the wizard store + bootstrap kit; there
|
||||
* is no hand-maintained id list in this file.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams, useRouter, Link } from '@tanstack/react-router'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { PortalShell } from './PortalShell'
|
||||
import { resolveApplications, type ApplicationDescriptor } from './applicationCatalog'
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
import type { ApplicationStatus } from './eventReducer'
|
||||
|
||||
interface AppsPageProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
disableStream?: boolean
|
||||
}
|
||||
|
||||
type TabId = 'installed' | 'catalog'
|
||||
|
||||
export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
const params = useParams({ from: '/provision/$deploymentId' as never }) as {
|
||||
deploymentId: string
|
||||
}
|
||||
const deploymentId = params.deploymentId
|
||||
const router = useRouter()
|
||||
const store = useWizardStore()
|
||||
|
||||
const applications = useMemo(
|
||||
() => resolveApplications(store.selectedComponents),
|
||||
[store.selectedComponents],
|
||||
)
|
||||
const applicationIds = useMemo(() => applications.map((a) => a.id), [applications])
|
||||
|
||||
const { state, snapshot, streamStatus, streamError, retry } = useDeploymentEvents({
|
||||
deploymentId,
|
||||
applicationIds,
|
||||
disableStream,
|
||||
})
|
||||
|
||||
const isFailed = streamStatus === 'failed' || streamStatus === 'unreachable'
|
||||
const failureMessage = streamError ?? snapshot?.error ?? null
|
||||
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
|
||||
|
||||
// Catalog = every Application this deployment knows about (canonical
|
||||
// calls this "every app in the org's catalog"; for the wizard surface
|
||||
// it's the union of bootstrap-kit + transitive deps + selected). Same
|
||||
// descriptor shape, so the card markup is shared between tabs.
|
||||
const catalogApps = applications
|
||||
// Deployments = every catalog entry that has at least one event
|
||||
// attributed to it OR was explicitly selected by the operator
|
||||
// (bootstrap-kit always counts). Mirrors canonical `installedIds`.
|
||||
const deployedIds = useMemo<Set<string>>(() => {
|
||||
const out = new Set<string>()
|
||||
for (const app of applications) {
|
||||
if (app.bootstrapKit) {
|
||||
out.add(app.id)
|
||||
continue
|
||||
}
|
||||
const compState = state.apps[app.id]
|
||||
if (compState && compState.status !== 'pending') out.add(app.id)
|
||||
}
|
||||
return out
|
||||
}, [applications, state.apps])
|
||||
const installedApps = useMemo(
|
||||
() => catalogApps.filter((a) => deployedIds.has(a.id)),
|
||||
[catalogApps, deployedIds],
|
||||
)
|
||||
|
||||
const [tab, setTab] = useState<TabId>('installed')
|
||||
const [query, setQuery] = useState<string>('')
|
||||
|
||||
const visibleApps = useMemo<ApplicationDescriptor[]>(() => {
|
||||
const list = tab === 'installed' ? installedApps : catalogApps
|
||||
const filtered = query
|
||||
? list.filter((a) => {
|
||||
const q = query.toLowerCase()
|
||||
return (
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
(a.description ?? '').toLowerCase().includes(q) ||
|
||||
(a.familyName ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
: list
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (tab === 'catalog') {
|
||||
const aIn = deployedIds.has(a.id) ? 0 : 1
|
||||
const bIn = deployedIds.has(b.id) ? 0 : 1
|
||||
if (aIn !== bIn) return aIn - bIn
|
||||
}
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}, [tab, installedApps, catalogApps, query, deployedIds])
|
||||
|
||||
const isProvisioning = streamStatus === 'connecting' || streamStatus === 'streaming'
|
||||
|
||||
return (
|
||||
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
|
||||
<style>{APPS_PAGE_CSS}</style>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-strong)]">Applications</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
|
||||
Sovereign provisioning · {sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}
|
||||
</p>
|
||||
</div>
|
||||
{isProvisioning ? (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--color-accent)]/40 bg-[var(--color-accent)]/10 px-3 py-1.5 text-xs text-[var(--color-accent)]"
|
||||
data-testid="sov-provisioning-pill"
|
||||
>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
|
||||
Provisioning
|
||||
<Link
|
||||
to="/provision/$deploymentId/jobs"
|
||||
params={{ deploymentId }}
|
||||
className="ml-2 underline text-[var(--color-accent)]"
|
||||
>
|
||||
View jobs
|
||||
</Link>
|
||||
</div>
|
||||
) : streamStatus === 'completed' ? (
|
||||
<Link
|
||||
to="/provision/$deploymentId/jobs"
|
||||
params={{ deploymentId }}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
|
||||
>
|
||||
View install history
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isFailed ? (
|
||||
<FailureCard
|
||||
deploymentId={deploymentId}
|
||||
status={streamStatus as 'failed' | 'unreachable'}
|
||||
message={failureMessage}
|
||||
onRetry={retry}
|
||||
onBack={() => router.navigate({ to: '/wizard' })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.phase1WatchSkipped ? (
|
||||
<Phase1UnavailableBanner
|
||||
fqdn={sovereignFQDN}
|
||||
reason={state.phase1WatchSkippedReason}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs" role="tablist" data-testid="sov-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab${tab === 'installed' ? ' active' : ''}`}
|
||||
onClick={() => setTab('installed')}
|
||||
role="tab"
|
||||
aria-selected={tab === 'installed'}
|
||||
data-testid="sov-tab-installed"
|
||||
>
|
||||
Deployments <span className="tab-count">{installedApps.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab${tab === 'catalog' ? ' active' : ''}`}
|
||||
onClick={() => setTab('catalog')}
|
||||
role="tab"
|
||||
aria-selected={tab === 'catalog'}
|
||||
data-testid="sov-tab-catalog"
|
||||
>
|
||||
Catalog <span className="tab-count">{catalogApps.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="apps-toolbar">
|
||||
<div className="search-wrap">
|
||||
<svg className="search-icon" viewBox="0 0 24 24" width={16} height={16} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx={11} cy={11} r={8} />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
tab === 'installed'
|
||||
? `Search your ${installedApps.length} apps…`
|
||||
: `Search ${catalogApps.length} apps…`
|
||||
}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="search-input"
|
||||
data-testid="sov-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'installed' && installedApps.length === 0 ? (
|
||||
<div className="empty-state" data-testid="sov-empty-deployments">
|
||||
<p className="empty-title">No applications installed yet.</p>
|
||||
<p className="empty-sub">Provisioning has not produced any deployments — open the catalog to see what will install.</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setTab('catalog')}>
|
||||
Open catalog →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="apps-grid" data-testid="sov-apps-grid">
|
||||
{visibleApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
status={state.apps[app.id]?.status ?? 'pending'}
|
||||
deploymentId={deploymentId}
|
||||
isService={app.familyId === 'platform' && !app.bootstrapKit ? false : !app.bootstrapKit && app.tier === 'optional' ? false : false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppCardProps {
|
||||
app: ApplicationDescriptor
|
||||
status: ApplicationStatus
|
||||
deploymentId: string
|
||||
/**
|
||||
* Mirror of canonical `is-service`. The wizard catalog doesn't carry
|
||||
* an explicit service flag yet — keep the prop so adding one later
|
||||
* is a one-line change. For now, every card is treated as an
|
||||
* Application surface.
|
||||
*/
|
||||
isService: boolean
|
||||
}
|
||||
|
||||
function AppCard({ app, status, deploymentId, isService }: AppCardProps) {
|
||||
const stateClass = `state-${status}`
|
||||
return (
|
||||
<Link
|
||||
to="/provision/$deploymentId/app/$componentId"
|
||||
params={{ deploymentId, componentId: app.id }}
|
||||
className={`app-card ${stateClass}${isService ? ' is-service' : ''}`}
|
||||
data-testid={`sov-app-card-${app.id}`}
|
||||
data-status={status}
|
||||
>
|
||||
{app.logoUrl ? (
|
||||
<img src={app.logoUrl} alt={app.title} className="app-logo" loading="lazy" />
|
||||
) : (
|
||||
<span className="app-icon" style={{ background: '#1f2937' }}>
|
||||
{app.title[0] ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
<div className="app-body">
|
||||
<div className="app-top">
|
||||
<span className="app-name">{app.title}</span>
|
||||
<span className="app-cat">{app.familyName}</span>
|
||||
</div>
|
||||
<p className="app-desc">{app.description || app.familyName}</p>
|
||||
<div className="app-chips">
|
||||
<span className="chip chip-free">FREE</span>
|
||||
{app.bootstrapKit ? (
|
||||
<span className="chip chip-dep" title="Bootstrap-kit component (always installed)">
|
||||
BOOTSTRAP
|
||||
</span>
|
||||
) : null}
|
||||
{app.dependencies.slice(0, 3).map((d) => (
|
||||
<span key={d} className="chip chip-dep" title="Bundled dependency">
|
||||
+ {d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-corner">
|
||||
{status === 'installed' ? (
|
||||
<span className="status-chip s-installed">
|
||||
<span className="dot" /> INSTALLED
|
||||
</span>
|
||||
) : status === 'installing' ? (
|
||||
<span className="status-chip s-installing">
|
||||
<span className="dot dot-spin" /> INSTALLING
|
||||
</span>
|
||||
) : status === 'failed' ? (
|
||||
<span className="status-chip s-failed">
|
||||
<span className="dot" /> FAILED
|
||||
</span>
|
||||
) : status === 'degraded' ? (
|
||||
<span className="status-chip s-failed">
|
||||
<span className="dot" /> DEGRADED
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-chip s-pending">
|
||||
<span className="dot" /> PENDING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface FailureCardProps {
|
||||
deploymentId: string
|
||||
status: 'failed' | 'unreachable'
|
||||
message: string | null
|
||||
onRetry: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function FailureCard({ deploymentId, status, message, onRetry, onBack }: FailureCardProps) {
|
||||
const isUnreachable = status === 'unreachable'
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="sov-failure-card"
|
||||
className="my-3 rounded-xl border border-[var(--color-danger)]/40 bg-[var(--color-danger)]/10 p-4 text-sm text-[var(--color-text)]"
|
||||
>
|
||||
<h3 className="m-0 mb-1 text-base font-semibold text-[var(--color-danger)]">
|
||||
{isUnreachable ? 'Couldn’t reach the deployment stream' : 'Provisioning failed'}
|
||||
</h3>
|
||||
<p className="m-0 mb-2 text-[var(--color-text-dim)]">
|
||||
{isUnreachable
|
||||
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
|
||||
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`}
|
||||
</p>
|
||||
{message ? (
|
||||
<pre data-testid="sov-failure-error" className="my-2 overflow-x-auto rounded bg-[var(--color-bg)] p-2 text-[11px] text-[var(--color-text-dim)]">
|
||||
{message}
|
||||
</pre>
|
||||
) : null}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
data-testid="sov-failure-retry"
|
||||
className="rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
Retry stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
data-testid="sov-failure-back"
|
||||
className="rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Back to wizard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Phase1UnavailableBannerProps {
|
||||
fqdn: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
function Phase1UnavailableBanner({ fqdn, reason }: Phase1UnavailableBannerProps) {
|
||||
const target = fqdn ?? 'the new Sovereign cluster'
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="sov-phase1-unavailable-banner"
|
||||
className="my-3 rounded-lg border border-[var(--color-warn)]/35 bg-[var(--color-warn)]/10 p-3 text-sm text-[var(--color-text)]"
|
||||
>
|
||||
<strong className="text-[var(--color-warn)] font-bold">
|
||||
Per-component install monitoring is unavailable for this deployment
|
||||
</strong>{' '}
|
||||
<span className="text-xs text-[var(--color-text-dim)]">
|
||||
— the Catalyst API couldn’t fetch the new cluster’s kubeconfig. Use kubectl directly to check Helm releases on {target}.
|
||||
</span>
|
||||
{reason ? (
|
||||
<pre
|
||||
data-testid="sov-phase1-unavailable-reason"
|
||||
className="mt-2 whitespace-pre-wrap break-words rounded bg-[var(--color-bg)] p-2 font-mono text-[11px] text-[var(--color-text-dim)]"
|
||||
>
|
||||
{reason}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel-ported `<style>` block from the canonical AppsPage.svelte.
|
||||
* Same selector tree, same values — the only Tailwind-vs-CSS diff is
|
||||
* that React injects via <style> rather than a Svelte scoped block.
|
||||
*/
|
||||
const APPS_PAGE_CSS = `
|
||||
.apps-toolbar { display: flex; gap: 0.75rem; align-items: center; margin: 1rem 0 0.75rem; }
|
||||
.search-wrap { position: relative; flex: 1; }
|
||||
.search-icon {
|
||||
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||
color: var(--color-text-dim); opacity: 0.6;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem 0.6rem 2.2rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.search-input:focus { outline: 2px solid var(--color-accent); border-color: transparent; }
|
||||
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.6rem 0.9rem;
|
||||
color: var(--color-text-dim);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.tab:hover { color: var(--color-text); }
|
||||
.tab.active {
|
||||
color: var(--color-text-strong);
|
||||
border-bottom-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-count {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
color: var(--color-text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab.active .tab-count {
|
||||
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.empty-state { margin-top: 3rem; text-align: center; color: var(--color-text-dim); }
|
||||
.empty-title { font-size: 1rem; color: var(--color-text-strong); margin: 0 0 0.3rem; font-weight: 600; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1.2rem; }
|
||||
|
||||
.app-card {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.app-card.state-installed { border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border)); }
|
||||
.app-card.state-installing { border-color: color-mix(in srgb, var(--color-accent) 55%, var(--color-border)); }
|
||||
.app-card.state-failed { border-color: color-mix(in srgb, var(--color-danger) 55%, var(--color-border)); }
|
||||
.app-card.is-service { border-style: dashed; opacity: 0.9; }
|
||||
.app-card.is-service:hover { opacity: 1; }
|
||||
|
||||
.app-logo {
|
||||
align-self: stretch;
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-icon {
|
||||
align-self: stretch;
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: #fff; font-size: 1.3rem; font-weight: 700;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
padding-right: 4.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-top { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.app-name {
|
||||
color: var(--color-text-strong); font-size: 0.92rem; font-weight: 600; line-height: 1.2;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
}
|
||||
.app-cat {
|
||||
color: var(--color-text-dim); font-size: 0.68rem; text-transform: capitalize;
|
||||
background: color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
}
|
||||
.app-desc {
|
||||
margin: 0; color: var(--color-text); font-size: 0.78rem; line-height: 1.45;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.app-chips {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, #000 85%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, #000 85%, transparent);
|
||||
min-height: 1.4rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px; font-size: 0.65rem; font-weight: 600; line-height: 1.4; white-space: nowrap;
|
||||
}
|
||||
.chip-free { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); }
|
||||
.chip-dep { background: color-mix(in srgb, var(--color-accent) 12%, transparent); color: var(--color-accent); font-weight: 500; }
|
||||
|
||||
.status-corner { position: absolute; bottom: 0.5rem; right: 0.55rem; pointer-events: none; }
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.status-chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block; }
|
||||
.status-chip .dot-spin { animation: sov-pulse 1.3s ease-in-out infinite; }
|
||||
.s-installed { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: var(--color-success); }
|
||||
.s-installing { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
|
||||
.s-pending { background: color-mix(in srgb, var(--color-text-dim) 16%, transparent); color: var(--color-text-dim); }
|
||||
.s-failed { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: var(--color-danger); }
|
||||
|
||||
@keyframes sov-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem; border-radius: 8px; border: none;
|
||||
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--color-accent); color: #fff; }
|
||||
.btn-primary:hover { filter: brightness(0.9); }
|
||||
`
|
||||
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* JobCard.test.tsx — pixel-port lock-in for the row component shared
|
||||
* between JobsPage and AppDetail's appended Jobs section.
|
||||
*
|
||||
* • Default-collapsed for non-running jobs; default-expanded for
|
||||
* running ones (same as canonical JobsPage.svelte).
|
||||
* • Click the row → toggles expansion; click the app-name on a
|
||||
* per-component row → navigates to AppDetail (no `/job/$jobId`).
|
||||
* • Status badge mirrors `statusBadge()` from jobs.ts.
|
||||
* • Step list renders one row per step with the expected status
|
||||
* iconography (success / running / failed / pending number).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { JobCard } from './JobCard'
|
||||
import type { Job } from './jobs'
|
||||
|
||||
const RUNNING_JOB: Job = {
|
||||
id: 'bp-cilium',
|
||||
app: 'bp-cilium',
|
||||
title: 'Install Cilium',
|
||||
status: 'running',
|
||||
updatedAt: '2026-04-29T10:00:00Z',
|
||||
noAppLink: false,
|
||||
steps: [
|
||||
{ index: 0, name: 'Reconciling HelmRelease', status: 'succeeded', startedAt: '2026-04-29T10:00:00Z', message: null },
|
||||
{ index: 1, name: 'Pulling chart from OCI', status: 'running', startedAt: '2026-04-29T10:00:30Z', message: null },
|
||||
{ index: 2, name: 'Applying CRDs', status: 'pending', startedAt: null, message: null },
|
||||
],
|
||||
}
|
||||
|
||||
const PENDING_INFRA_JOB: Job = {
|
||||
id: 'infrastructure:tofu-init',
|
||||
app: 'infrastructure',
|
||||
title: 'Provision Hetzner — terraform init',
|
||||
status: 'pending',
|
||||
updatedAt: null,
|
||||
noAppLink: true,
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const FAILED_JOB: Job = {
|
||||
id: 'infrastructure:tofu-apply',
|
||||
app: 'infrastructure',
|
||||
title: 'Provision Hetzner — terraform apply',
|
||||
status: 'failed',
|
||||
updatedAt: '2026-04-29T10:05:00Z',
|
||||
noAppLink: true,
|
||||
steps: [
|
||||
{ index: 0, name: 'Creating hcloud_server', status: 'failed', startedAt: '2026-04-29T10:00:00Z', message: 'rate limited' },
|
||||
],
|
||||
}
|
||||
|
||||
function renderWithRouter(component: React.ReactNode) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <>{component}</>,
|
||||
})
|
||||
const detailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: () => <div data-testid="app-detail-target" />,
|
||||
})
|
||||
const tree = rootRoute.addChildren([homeRoute, detailRoute])
|
||||
const router = createRouter({
|
||||
routeTree: tree,
|
||||
history: createMemoryHistory({ initialEntries: ['/provision/d-1'] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
afterEach(() => cleanup())
|
||||
beforeEach(() => {})
|
||||
|
||||
describe('JobCard — chrome', () => {
|
||||
it('renders the title + a status badge', async () => {
|
||||
renderWithRouter(<JobCard job={RUNNING_JOB} deploymentId="d-1" />)
|
||||
expect(await screen.findByTestId(`sov-job-card-${RUNNING_JOB.id}`)).toBeTruthy()
|
||||
const badge = screen.getByTestId(`sov-job-badge-${RUNNING_JOB.id}`)
|
||||
expect(badge.textContent).toBe('Running')
|
||||
})
|
||||
|
||||
it('shows the step count meta line ("X/Y steps · last update HH:MM:SS")', async () => {
|
||||
renderWithRouter(<JobCard job={RUNNING_JOB} deploymentId="d-1" />)
|
||||
const card = await screen.findByTestId(`sov-job-card-${RUNNING_JOB.id}`)
|
||||
expect(card.textContent).toContain('1/3 steps')
|
||||
})
|
||||
|
||||
it('renders the failed badge for failed jobs', async () => {
|
||||
renderWithRouter(<JobCard job={FAILED_JOB} deploymentId="d-1" />)
|
||||
const badge = await screen.findByTestId(`sov-job-badge-${FAILED_JOB.id}`)
|
||||
expect(badge.textContent).toBe('Failed')
|
||||
})
|
||||
|
||||
it('renders the pending badge with no progress bar for pending jobs', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" />)
|
||||
const badge = await screen.findByTestId(`sov-job-badge-${PENDING_INFRA_JOB.id}`)
|
||||
expect(badge.textContent).toBe('Pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobCard — expand / collapse', () => {
|
||||
it('default-expands a running job', async () => {
|
||||
renderWithRouter(<JobCard job={RUNNING_JOB} deploymentId="d-1" />)
|
||||
const panel = await screen.findByTestId(`sov-job-panel-${RUNNING_JOB.id}`)
|
||||
expect(panel).toBeTruthy()
|
||||
// All three steps render
|
||||
expect(within(panel).getByTestId('sov-step-0')).toBeTruthy()
|
||||
expect(within(panel).getByTestId('sov-step-1')).toBeTruthy()
|
||||
expect(within(panel).getByTestId('sov-step-2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('default-collapses a pending job', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" />)
|
||||
expect(await screen.findByTestId(`sov-job-card-${PENDING_INFRA_JOB.id}`)).toBeTruthy()
|
||||
expect(screen.queryByTestId(`sov-job-panel-${PENDING_INFRA_JOB.id}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('clicking the row toggles expansion', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" />)
|
||||
const row = await screen.findByTestId(`sov-job-row-${PENDING_INFRA_JOB.id}`)
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByTestId(`sov-job-panel-${PENDING_INFRA_JOB.id}`)).toBeTruthy()
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByTestId(`sov-job-panel-${PENDING_INFRA_JOB.id}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('respects defaultExpanded={true}', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" defaultExpanded />)
|
||||
expect(await screen.findByTestId(`sov-job-panel-${PENDING_INFRA_JOB.id}`)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobCard — app-name link', () => {
|
||||
it('renders the title as a Link for per-component rows', async () => {
|
||||
renderWithRouter(<JobCard job={RUNNING_JOB} deploymentId="d-1" />)
|
||||
const link = await screen.findByTestId(`sov-job-title-link-${RUNNING_JOB.id}`)
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link.getAttribute('href')).toBe('/provision/d-1/app/bp-cilium')
|
||||
})
|
||||
|
||||
it('renders the title as plain text for noAppLink rows', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" />)
|
||||
const title = await screen.findByTestId(`sov-job-title-${PENDING_INFRA_JOB.id}`)
|
||||
expect(title.tagName.toLowerCase()).toBe('p')
|
||||
// No link-titled element exists for this row.
|
||||
expect(screen.queryByTestId(`sov-job-title-link-${PENDING_INFRA_JOB.id}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobCard — empty steps', () => {
|
||||
it('shows a placeholder when an expanded job has no steps yet', async () => {
|
||||
renderWithRouter(<JobCard job={PENDING_INFRA_JOB} deploymentId="d-1" defaultExpanded />)
|
||||
const panel = await screen.findByTestId(`sov-job-panel-${PENDING_INFRA_JOB.id}`)
|
||||
expect(panel.textContent).toContain('No steps yet')
|
||||
})
|
||||
})
|
||||
203
products/catalyst/bootstrap/ui/src/pages/sovereign/JobCard.tsx
Normal file
203
products/catalyst/bootstrap/ui/src/pages/sovereign/JobCard.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* JobCard — pixel-port of the per-row markup inside
|
||||
* core/console/src/components/JobsPage.svelte.
|
||||
*
|
||||
* Each row is a `<button>` (canonical: `<button class="w-full ...">`)
|
||||
* that toggles inline expansion of an ordered step list. The expanded
|
||||
* panel renders the same step-status iconography as the Svelte source
|
||||
* (success check / running spinner / failed X / pending number bubble).
|
||||
*
|
||||
* Two affordances on top of the canonical version:
|
||||
* 1. The row's app-name is rendered as a Tanstack <Link> when the
|
||||
* Job is per-component (see jobs.ts, `noAppLink === false`).
|
||||
* Clicking the app-name navigates to that component's AppDetail.
|
||||
* Phase 0 / cluster-bootstrap rows pass `noAppLink === true` and
|
||||
* render the title as plain text — there is no per-job route.
|
||||
* 2. The expanded toggle is preserved from the canonical button, so
|
||||
* keyboard activation (`Enter` / `Space`) still works.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every colour
|
||||
* is a CSS variable + every layout value is a Tailwind utility — the
|
||||
* file mirrors the canonical class strings 1:1 so visual diff is zero.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { Job, JobStep } from './jobs'
|
||||
import { fmtTime, statusBadge } from './jobs'
|
||||
|
||||
interface JobCardProps {
|
||||
job: Job
|
||||
/** Stable deployment id — needed for the AppDetail link target. */
|
||||
deploymentId: string
|
||||
/** Default expansion state (canonical: running rows expand by default). */
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
export function JobCard({ job, deploymentId, defaultExpanded }: JobCardProps) {
|
||||
const [expanded, setExpanded] = useState<boolean>(
|
||||
defaultExpanded ?? job.status === 'running',
|
||||
)
|
||||
const badge = statusBadge(job.status)
|
||||
const completedN = job.steps.filter((s) => s.status === 'succeeded').length
|
||||
const total = job.steps.length
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
data-testid={`sov-job-card-${job.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-4 p-4 text-left"
|
||||
data-job-kind={job.app === 'infrastructure' ? 'provision' : job.app === 'cluster-bootstrap' ? 'bootstrap' : 'install'}
|
||||
data-job-status={job.status}
|
||||
aria-expanded={expanded}
|
||||
data-testid={`sov-job-row-${job.id}`}
|
||||
>
|
||||
{/* Status icon (running spinner / success check / failed X / pending clock) */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--color-accent)]/10">
|
||||
{job.status === 'running' ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
|
||||
) : job.status === 'succeeded' ? (
|
||||
<svg className="h-5 w-5 text-[var(--color-success)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : job.status === 'failed' ? (
|
||||
<svg className="h-5 w-5 text-[var(--color-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-[var(--color-text-dim)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + meta */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{job.noAppLink ? (
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-[var(--color-text-strong)]"
|
||||
data-testid={`sov-job-title-${job.id}`}
|
||||
>
|
||||
{job.title}
|
||||
</p>
|
||||
) : (
|
||||
<Link
|
||||
to="/provision/$deploymentId/app/$componentId"
|
||||
params={{ deploymentId, componentId: job.app }}
|
||||
className="truncate text-sm font-semibold text-[var(--color-text-strong)] hover:text-[var(--color-accent)] no-underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`sov-job-title-link-${job.id}`}
|
||||
>
|
||||
{job.title}
|
||||
</Link>
|
||||
)}
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${badge.classes}`}
|
||||
data-testid={`sov-job-badge-${job.id}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
|
||||
{completedN}/{total} steps
|
||||
{fmtTime(job.updatedAt) ? ` · last update ${fmtTime(job.updatedAt)}` : ''}
|
||||
</p>
|
||||
{job.status === 'running' && total > 0 ? (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--color-accent)] transition-all"
|
||||
style={{ width: `${Math.round((completedN / total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Caret */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-[var(--color-text-dim)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div
|
||||
className="border-t border-[var(--color-border)] p-4"
|
||||
data-testid={`sov-job-panel-${job.id}`}
|
||||
>
|
||||
{job.steps.length === 0 ? (
|
||||
<p className="text-xs text-[var(--color-text-dimmer)]">
|
||||
No steps yet — events will appear here as the job runs.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{job.steps.map((step) => (
|
||||
<StepRow key={step.index} step={step} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepRowProps {
|
||||
step: JobStep
|
||||
}
|
||||
|
||||
function StepRow({ step }: StepRowProps) {
|
||||
return (
|
||||
<li className="flex items-start gap-3" data-testid={`sov-step-${step.index}`}>
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{step.status === 'succeeded' ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--color-success)]">
|
||||
<svg className="h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
) : step.status === 'running' ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
|
||||
) : step.status === 'failed' ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--color-danger)]">
|
||||
<svg className="h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-[var(--color-border-strong)] text-[10px] text-[var(--color-text-dimmer)]">
|
||||
{step.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
step.status === 'succeeded'
|
||||
? 'text-[var(--color-text)]'
|
||||
: step.status === 'running'
|
||||
? 'text-[var(--color-accent)] font-medium'
|
||||
: step.status === 'failed'
|
||||
? 'text-[var(--color-danger)] font-medium'
|
||||
: 'text-[var(--color-text-dimmer)]'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-[var(--color-text-dimmer)]">
|
||||
{fmtTime(step.startedAt) ? <span>started {fmtTime(step.startedAt)}</span> : null}
|
||||
{step.message ? <span>· {step.message}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* JobsPage.test.tsx — pixel-port lock-in for the global jobs surface.
|
||||
*
|
||||
* • Page heading + tagline render
|
||||
* • Vertical stack of JobCard rows (one per Job)
|
||||
* • Phase 0 (4 tofu) + cluster-bootstrap + per-component jobs all
|
||||
* render — the operator never has to scroll past anything to find
|
||||
* a row.
|
||||
* • NO `/job/$jobId` route — clicking the app-name on a per-component
|
||||
* row navigates to AppDetail; the page itself never registers an
|
||||
* extra route.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, within } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { JobsPage } from './JobsPage'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
|
||||
|
||||
function renderJobs(deploymentId: string) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const jobsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs',
|
||||
component: () => <JobsPage disableStream />,
|
||||
})
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <div data-testid="apps-target" />,
|
||||
})
|
||||
const detailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/app/$componentId',
|
||||
component: () => <div data-testid="app-detail-target" />,
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <div data-testid="wizard-target" />,
|
||||
})
|
||||
const tree = rootRoute.addChildren([jobsRoute, homeRoute, detailRoute, wizardRoute])
|
||||
const router = createRouter({
|
||||
routeTree: tree,
|
||||
history: createMemoryHistory({ initialEntries: [`/provision/${deploymentId}/jobs`] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
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
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('JobsPage — chrome', () => {
|
||||
it('renders the Jobs heading', async () => {
|
||||
renderJobs('d-1')
|
||||
// There are multiple "Jobs" texts — sidebar nav link + page heading.
|
||||
// The H1 is the heading we care about.
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Jobs' })
|
||||
expect(heading).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mounts inside the PortalShell', async () => {
|
||||
renderJobs('d-1')
|
||||
expect(await screen.findByTestId('sov-portal-shell')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('back-to-apps link points at /provision/$deploymentId', async () => {
|
||||
renderJobs('d-1')
|
||||
const link = await screen.findByTestId('sov-jobs-back-to-apps')
|
||||
expect(link.getAttribute('href')).toBe('/provision/d-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobsPage — list', () => {
|
||||
it('renders Phase 0 tofu rows + cluster-bootstrap + per-component rows', async () => {
|
||||
renderJobs('d-1')
|
||||
const list = await screen.findByTestId('sov-jobs-list')
|
||||
// 4 Phase 0 tofu rows (init/plan/apply/output)
|
||||
expect(within(list).queryByTestId('sov-job-card-infrastructure:tofu-init')).toBeTruthy()
|
||||
expect(within(list).queryByTestId('sov-job-card-infrastructure:tofu-plan')).toBeTruthy()
|
||||
expect(within(list).queryByTestId('sov-job-card-infrastructure:tofu-apply')).toBeTruthy()
|
||||
expect(within(list).queryByTestId('sov-job-card-infrastructure:tofu-output')).toBeTruthy()
|
||||
// cluster-bootstrap row
|
||||
expect(within(list).queryByTestId('sov-job-card-cluster-bootstrap')).toBeTruthy()
|
||||
// At least one per-component row from BOOTSTRAP_KIT
|
||||
expect(within(list).queryByTestId('sov-job-card-bp-cilium')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* JobsPage — pixel-port of core/console/src/components/JobsPage.svelte.
|
||||
*
|
||||
* Layout (top-down, byte-identical to canonical):
|
||||
* • Header: <h1>Jobs</h1> + tagline.
|
||||
* • Vertical stack of `<JobCard />` rows. Each row is a `<button>`
|
||||
* toggling inline expansion to show ordered steps. NO `/job/$jobId`
|
||||
* route — clicking the app-name on a per-component row navigates
|
||||
* to that component's AppDetail page; clicking anywhere else on
|
||||
* the row toggles expansion.
|
||||
*
|
||||
* Job order is stable (Phase 0 → cluster-bootstrap → per-component, in
|
||||
* catalog order) so the operator always sees Hetzner / Flux / install
|
||||
* jobs in the same place across reloads.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall — first paint is the
|
||||
* full list), every Job renders from the moment the page mounts; rows
|
||||
* with no events yet show as `pending` with an empty step list.
|
||||
*
|
||||
* Per #4 (never hardcode), the job set is computed by `deriveJobs()`
|
||||
* from the catalog + reducer state. Adding a Blueprint to the catalog
|
||||
* automatically adds a row.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, Link } from '@tanstack/react-router'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { PortalShell } from './PortalShell'
|
||||
import { JobCard } from './JobCard'
|
||||
import { resolveApplications } from './applicationCatalog'
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
import { deriveJobs } from './jobs'
|
||||
|
||||
interface JobsPageProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
disableStream?: boolean
|
||||
}
|
||||
|
||||
export function JobsPage({ disableStream = false }: JobsPageProps = {}) {
|
||||
const params = useParams({
|
||||
from: '/provision/$deploymentId/jobs' as never,
|
||||
}) as { deploymentId: string }
|
||||
const { deploymentId } = params
|
||||
const store = useWizardStore()
|
||||
|
||||
const applications = useMemo(
|
||||
() => resolveApplications(store.selectedComponents),
|
||||
[store.selectedComponents],
|
||||
)
|
||||
const applicationIds = useMemo(() => applications.map((a) => a.id), [applications])
|
||||
|
||||
const { state, snapshot } = useDeploymentEvents({
|
||||
deploymentId,
|
||||
applicationIds,
|
||||
disableStream,
|
||||
})
|
||||
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
|
||||
|
||||
const jobs = useMemo(() => deriveJobs(state, applications), [state, applications])
|
||||
|
||||
return (
|
||||
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-strong)]">Jobs</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
|
||||
Provisioning, infrastructure, and per-application installs for{' '}
|
||||
{sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/provision/$deploymentId"
|
||||
params={{ deploymentId }}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
|
||||
data-testid="sov-jobs-back-to-apps"
|
||||
>
|
||||
← Back to apps
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="mt-12 text-center" data-testid="sov-jobs-empty">
|
||||
<p className="text-[var(--color-text-dim)]">No jobs yet for this deployment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 flex flex-col gap-3" data-testid="sov-jobs-list">
|
||||
{jobs.map((job) => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
deploymentId={deploymentId}
|
||||
defaultExpanded={job.status === 'running'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
/**
|
||||
* PhaseBanners — "Hetzner infra" + "Cluster bootstrap" status banners
|
||||
* rendered ABOVE the application card grid on the Sovereign Admin page.
|
||||
*
|
||||
* These two phases are NOT Applications — they're the Phase 0 (cloud
|
||||
* provisioning via OpenTofu) and the cloud-init handoff that bootstraps
|
||||
* Flux + Crossplane in the freshly-minted cluster. The operator wanted
|
||||
* them visible because they're prerequisites for any Application card
|
||||
* flipping out of `pending`, but distinct in shape from per-component
|
||||
* install events. Compact banners, click to expand inline log details,
|
||||
* never confused with an Application tile.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), the phase
|
||||
* states + log buckets come from `eventReducer.ts` — the same reducer
|
||||
* the per-Application cards consume.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
CLUSTER_BOOTSTRAP_BUCKET,
|
||||
HETZNER_INFRA_BUCKET,
|
||||
type DeploymentEvent,
|
||||
type PhaseStatus,
|
||||
type ReducerState,
|
||||
} from './eventReducer'
|
||||
|
||||
interface PhaseBannersProps {
|
||||
state: ReducerState
|
||||
}
|
||||
|
||||
export function PhaseBanners({ state }: PhaseBannersProps) {
|
||||
return (
|
||||
<div className="sov-phase-row" data-testid="sov-phase-row">
|
||||
<PhaseBanner
|
||||
id="hetzner-infra"
|
||||
name="Hetzner infra"
|
||||
sub="OpenTofu Phase 0 — network · firewall · servers · load balancer"
|
||||
status={state.hetznerInfra.status}
|
||||
message={state.hetznerInfra.message}
|
||||
events={state.eventsByTarget[HETZNER_INFRA_BUCKET()] ?? []}
|
||||
/>
|
||||
<PhaseBanner
|
||||
id="cluster-bootstrap"
|
||||
name="Cluster bootstrap"
|
||||
sub="cloud-init → Flux + Crossplane in-cluster"
|
||||
status={state.clusterBootstrap.status}
|
||||
message={state.clusterBootstrap.message}
|
||||
events={state.eventsByTarget[CLUSTER_BOOTSTRAP_BUCKET()] ?? []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PhaseBannerProps {
|
||||
id: string
|
||||
name: string
|
||||
sub: string
|
||||
status: PhaseStatus
|
||||
message: string | null
|
||||
events: readonly DeploymentEvent[]
|
||||
}
|
||||
|
||||
const PHASE_STATE_LABEL: Record<PhaseStatus, string> = {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
done: 'Done',
|
||||
failed: 'Failed',
|
||||
}
|
||||
|
||||
const PHASE_TONE: Record<PhaseStatus, { bg: string; fg: string; border: string }> = {
|
||||
pending: { bg: 'rgba(148,163,184,0.10)', fg: 'var(--wiz-text-md)', border: 'rgba(148,163,184,0.30)' },
|
||||
running: { bg: 'rgba(56,189,248,0.10)', fg: '#38BDF8', border: 'rgba(56,189,248,0.35)' },
|
||||
done: { bg: 'rgba(74,222,128,0.10)', fg: '#4ADE80', border: 'rgba(74,222,128,0.35)' },
|
||||
failed: { bg: 'rgba(248,113,113,0.10)', fg: '#F87171', border: 'rgba(248,113,113,0.35)' },
|
||||
}
|
||||
|
||||
function PhaseBanner({ id, name, sub, status, message, events }: PhaseBannerProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const tone = PHASE_TONE[status]
|
||||
return (
|
||||
<section
|
||||
className="sov-phase"
|
||||
data-status={status}
|
||||
data-testid={`sov-phase-${id}`}
|
||||
>
|
||||
<div className="sov-phase-head">
|
||||
<span className="sov-phase-name">{name}</span>
|
||||
<span className="sov-phase-sub">{sub}</span>
|
||||
<span
|
||||
data-testid={`sov-phase-${id}-status`}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '0.15rem 0.55rem',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
background: tone.bg,
|
||||
color: tone.fg,
|
||||
border: `1px solid ${tone.border}`,
|
||||
}}
|
||||
>
|
||||
{PHASE_STATE_LABEL[status]}
|
||||
</span>
|
||||
</div>
|
||||
{message && (
|
||||
<pre className="sov-phase-msg" data-testid={`sov-phase-${id}-msg`}>
|
||||
{message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sov-phase-toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid={`sov-phase-${id}-toggle`}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronDown size={12} aria-hidden /> : <ChevronRight size={12} aria-hidden />}
|
||||
{events.length} events
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="sov-phase-log" data-testid={`sov-phase-${id}-log`}>
|
||||
{events.length === 0 ? (
|
||||
<div className="sov-log-empty">No events yet.</div>
|
||||
) : (
|
||||
events.map((ev, i) => (
|
||||
<div key={i} className="sov-log-line" data-level={ev.level ?? 'info'}>
|
||||
<span className="sov-log-ts">{(ev.time ?? '').slice(11, 19) || '—'}</span>
|
||||
<span className="sov-log-phase">{ev.phase}</span>
|
||||
<span className="sov-log-msg">{ev.message ?? ''}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PortalShell — pixel-port of core/console/src/components/PortalShell.svelte.
|
||||
*
|
||||
* Layout contract (matches canonical 1:1):
|
||||
* • flex min-h-screen wrapper
|
||||
* • left rail: <Sidebar /> w-56 fixed
|
||||
* • main: ml-56 flex-1 p-8
|
||||
*
|
||||
* The canonical shell handles auth + tenant resolution; in the
|
||||
* Sovereign-provision wizard context that's not relevant — the wizard
|
||||
* runs unauthenticated and the deploymentId IS the tenant. The shell
|
||||
* therefore only needs the deployment id + an optional resolved
|
||||
* sovereign FQDN to mirror the same chrome.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every layout
|
||||
* value is a Tailwind utility (so it follows core/console's CSS), not
|
||||
* an inlined px / hex.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
interface PortalShellProps {
|
||||
/** Stable deploymentId from the URL parameter. */
|
||||
deploymentId: string
|
||||
/** Resolved Sovereign FQDN (passed through to Sidebar's tenant slot). */
|
||||
sovereignFQDN?: string | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PortalShell({ deploymentId, sovereignFQDN, children }: PortalShellProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen bg-[var(--color-bg)] text-[var(--color-text)]"
|
||||
data-testid="sov-portal-shell"
|
||||
>
|
||||
<Sidebar deploymentId={deploymentId} sovereignFQDN={sovereignFQDN} />
|
||||
<main className="ml-56 flex-1 p-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Sidebar.test.tsx — pixel-port lock-in for Sidebar.tsx.
|
||||
*
|
||||
* • Renders the OpenOva mark inside the 56px logo header
|
||||
* • Surfaces the deployment id (or sovereignFQDN when supplied) as the
|
||||
* "tenant" label in place of the canonical Tenant switcher
|
||||
* • Renders Apps + Jobs + Settings nav items (the explicit subset for
|
||||
* the Sovereign-provision surface)
|
||||
* • Active item carries `aria-current="page"` + the accent-tinted
|
||||
* class string the canonical surface uses
|
||||
* • Operator card at the bottom (analog of canonical "User" card)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, within } from '@testing-library/react'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
function renderSidebarAt(initialPath: string, sovereignFQDN?: string | null) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => (
|
||||
<Sidebar deploymentId="d-test-1234" sovereignFQDN={sovereignFQDN ?? null} />
|
||||
),
|
||||
})
|
||||
const jobsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs',
|
||||
component: () => (
|
||||
<Sidebar deploymentId="d-test-1234" sovereignFQDN={sovereignFQDN ?? null} />
|
||||
),
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <Sidebar deploymentId="d-test-1234" sovereignFQDN={sovereignFQDN ?? null} />,
|
||||
})
|
||||
const tree = rootRoute.addChildren([provisionRoute, jobsRoute, wizardRoute])
|
||||
const router = createRouter({
|
||||
routeTree: tree,
|
||||
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
afterEach(() => cleanup())
|
||||
beforeEach(() => {})
|
||||
|
||||
describe('Sidebar — chrome', () => {
|
||||
it('renders the OpenOva mark + wordmark in the header', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234')
|
||||
const sidebar = await screen.findByTestId('sov-sidebar')
|
||||
// SVG logo present
|
||||
expect(sidebar.querySelector('svg')).toBeTruthy()
|
||||
expect(within(sidebar).getByText('OpenOva')).toBeTruthy()
|
||||
// Sovereign label (replaces Tenant switcher)
|
||||
expect(within(sidebar).getByText(/Sovereign/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses sovereignFQDN as the tenant label when supplied', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234', 'omantel.omani.works')
|
||||
const label = await screen.findByTestId('sov-tenant-label')
|
||||
expect(label.textContent).toContain('omantel.omani.works')
|
||||
})
|
||||
|
||||
it('falls back to a deploymentId-derived label when no FQDN is known', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234')
|
||||
const label = await screen.findByTestId('sov-tenant-label')
|
||||
expect(label.textContent).toContain('d-test-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar — navigation', () => {
|
||||
it('renders exactly Apps + Jobs + Settings nav items', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234')
|
||||
expect(await screen.findByTestId('sov-nav-apps')).toBeTruthy()
|
||||
expect(await screen.findByTestId('sov-nav-jobs')).toBeTruthy()
|
||||
expect(await screen.findByTestId('sov-nav-settings')).toBeTruthy()
|
||||
// Canonical-but-omitted items must NOT render: dashboard / domains /
|
||||
// billing / team. Their absence is part of the contract.
|
||||
expect(screen.queryByTestId('sov-nav-dashboard')).toBeNull()
|
||||
expect(screen.queryByTestId('sov-nav-domains')).toBeNull()
|
||||
expect(screen.queryByTestId('sov-nav-billing')).toBeNull()
|
||||
expect(screen.queryByTestId('sov-nav-team')).toBeNull()
|
||||
})
|
||||
|
||||
it('marks Apps active when on the provision root', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234')
|
||||
const apps = await screen.findByTestId('sov-nav-apps')
|
||||
expect(apps.getAttribute('aria-current')).toBe('page')
|
||||
const jobs = screen.getByTestId('sov-nav-jobs')
|
||||
expect(jobs.getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('marks Jobs active when on /provision/.../jobs', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234/jobs')
|
||||
const jobs = await screen.findByTestId('sov-nav-jobs')
|
||||
expect(jobs.getAttribute('aria-current')).toBe('page')
|
||||
const apps = screen.getByTestId('sov-nav-apps')
|
||||
expect(apps.getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar — operator card', () => {
|
||||
it('renders Operator + "Provisioning session" footer text', async () => {
|
||||
renderSidebarAt('/provision/d-test-1234')
|
||||
const sidebar = await screen.findByTestId('sov-sidebar')
|
||||
expect(within(sidebar).getByText('Operator')).toBeTruthy()
|
||||
expect(within(sidebar).getByText('Provisioning session')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
177
products/catalyst/bootstrap/ui/src/pages/sovereign/Sidebar.tsx
Normal file
177
products/catalyst/bootstrap/ui/src/pages/sovereign/Sidebar.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Sidebar — pixel-port of core/console/src/components/Sidebar.svelte.
|
||||
*
|
||||
* Layout contract (matches canonical 1:1):
|
||||
* • Fixed left rail, w-56, full height
|
||||
* • Logo + product wordmark in the 56px header
|
||||
* • Tenant switcher in the canonical surface — DROPPED here because
|
||||
* a Sovereign-provision wizard target is single-Sovereign by
|
||||
* definition. The deploymentId is the surrogate; we render it as a
|
||||
* static label so users still get the "what am I looking at" cue
|
||||
* the canonical switcher provides.
|
||||
* • Nav list — this surface ships only the items that have a real
|
||||
* destination in the Sovereign-provision context:
|
||||
*
|
||||
* — `apps` → /sovereign/provision/$deploymentId
|
||||
* — `jobs` → /sovereign/provision/$deploymentId/jobs
|
||||
* — `settings` → static link to wizard step (operator can revise
|
||||
* deployment options before completion)
|
||||
*
|
||||
* `dashboard`, `domains`, `billing`, `team` are OMITTED because
|
||||
* they reach surfaces that don't exist on a freshly-provisioning
|
||||
* Sovereign — those are tenant-console concerns, post-handover.
|
||||
* Adding them as dead links would betray the canonical 1:1 promise
|
||||
* and surface broken nav.
|
||||
*
|
||||
* • User card at the bottom — the canonical version reads from a
|
||||
* signed-in tenant session; the Sovereign-provision wizard runs
|
||||
* unauthenticated, so we show "Operator · Provisioning session".
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every label,
|
||||
* href and color comes from runtime data + canonical token names —
|
||||
* there's no inline hex value or hard-coded path.
|
||||
*/
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
|
||||
interface SidebarProps {
|
||||
/** Current deployment id — surfaced as the "Sovereign" label. */
|
||||
deploymentId: string
|
||||
/** Resolved Sovereign FQDN (from snapshot or wizard store). */
|
||||
sovereignFQDN?: string | null
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: 'apps' | 'jobs' | 'settings'
|
||||
label: string
|
||||
/** Tanstack-router target — `null` for static external/non-tanstack routes. */
|
||||
to: '/provision/$deploymentId' | '/provision/$deploymentId/jobs' | '/wizard'
|
||||
/** SVG path data — same `d` strings as core/console for visual parity. */
|
||||
icon: string
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Apps',
|
||||
to: '/provision/$deploymentId',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Jobs',
|
||||
to: '/provision/$deploymentId/jobs',
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
to: '/wizard',
|
||||
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
},
|
||||
]
|
||||
|
||||
/** Compute the active nav item from the current pathname. */
|
||||
function deriveActive(pathname: string): NavItem['id'] {
|
||||
if (pathname.endsWith('/jobs')) return 'jobs'
|
||||
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'
|
||||
return 'apps'
|
||||
}
|
||||
|
||||
export function Sidebar({ deploymentId, sovereignFQDN }: SidebarProps) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activePage = deriveActive(pathname)
|
||||
|
||||
const sovereignLabel = sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed left-0 top-0 flex h-screen w-56 flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-2)]"
|
||||
data-testid="sov-sidebar"
|
||||
>
|
||||
{/* Logo + Sovereign label (replaces canonical Tenant switcher) */}
|
||||
<div className="border-b border-[var(--color-border)]">
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
{/* Canonical OpenOva mark — same shape + gradient as core/console */}
|
||||
<svg viewBox="0 0 700 400" width={36} height={20} className="flex-shrink-0" fill="none" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id="sidebar-logo-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#3B82F6" />
|
||||
<stop offset="100%" stopColor="#818CF8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M 300 88.1966 A 150 150 0 1 0 350 200 A 150 150 0 1 1 400 311.8034"
|
||||
fill="none"
|
||||
stroke="url(#sidebar-logo-grad)"
|
||||
strokeWidth={100}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-strong)]">
|
||||
OpenOva <span className="font-normal text-[var(--color-text-dim)]">Sovereign</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-left text-xs"
|
||||
data-testid="sov-tenant-label"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-text-strong)]">{sovereignLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-3" data-testid="sov-nav">
|
||||
{NAV.map((item) => {
|
||||
const isActive = activePage === item.id
|
||||
const cls = isActive
|
||||
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-dim)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]'
|
||||
// Settings target points outside the provision sub-tree, so it
|
||||
// doesn't take a deploymentId param — Tanstack Link handles the
|
||||
// distinction by omitting `params` for non-parameterised routes.
|
||||
const linkProps =
|
||||
item.to === '/wizard'
|
||||
? { to: '/wizard' as const }
|
||||
: { to: item.to, params: { deploymentId } }
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
{...linkProps}
|
||||
activeOptions={{ exact: true }}
|
||||
className={`mx-2 flex items-center gap-3 rounded-lg px-3 py-2 text-sm no-underline transition-colors ${cls}`}
|
||||
data-testid={`sov-nav-${item.id}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Operator card at the bottom — analog of canonical "User" card */}
|
||||
<div className="border-t border-[var(--color-border)] p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-accent)]/20 text-xs font-bold text-[var(--color-accent)]">
|
||||
O
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-[var(--color-text)]">Operator</p>
|
||||
<p className="truncate text-[10px] text-[var(--color-text-dimmer)]">Provisioning session</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
200
products/catalyst/bootstrap/ui/src/pages/sovereign/jobs.test.ts
Normal file
200
products/catalyst/bootstrap/ui/src/pages/sovereign/jobs.test.ts
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* jobs.test.ts — coverage for `deriveJobs()` + helpers.
|
||||
*
|
||||
* What we lock in:
|
||||
* • Phase 0: 4 tofu jobs derived from `tofu-init`/`tofu-plan`/
|
||||
* `tofu-apply`/`tofu-output` events, each with `app="infrastructure"`
|
||||
* and `noAppLink=true`.
|
||||
* • `flux-bootstrap` → exactly 1 job, `app="cluster-bootstrap"`,
|
||||
* `noAppLink=true`.
|
||||
* • Per-Application: 1 job per descriptor, `app=<bp-id>`,
|
||||
* `noAppLink=false` (so the AppDetail link renders in JobCard).
|
||||
* • Step ordering matches the order events were applied.
|
||||
* • `jobsForApplication()` filters to the single per-component row,
|
||||
* excluding Phase 0 / cluster-bootstrap rows.
|
||||
* • `statusBadge()` text + class mapping for every JobUiStatus.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyEvent,
|
||||
buildInitialState,
|
||||
type DeploymentEvent,
|
||||
} from './eventReducer'
|
||||
import { deriveJobs, fmtTime, jobsForApplication, statusBadge } from './jobs'
|
||||
import type { ApplicationDescriptor } from './applicationCatalog'
|
||||
|
||||
const APPS: ApplicationDescriptor[] = [
|
||||
{
|
||||
id: 'bp-cilium',
|
||||
bareId: 'cilium',
|
||||
title: 'Cilium',
|
||||
description: 'eBPF networking',
|
||||
familyId: 'spine',
|
||||
familyName: 'Spine',
|
||||
tier: 'mandatory',
|
||||
logoUrl: null,
|
||||
dependencies: [],
|
||||
bootstrapKit: true,
|
||||
},
|
||||
{
|
||||
id: 'bp-flux',
|
||||
bareId: 'flux',
|
||||
title: 'Flux',
|
||||
description: 'GitOps',
|
||||
familyId: 'spine',
|
||||
familyName: 'Spine',
|
||||
tier: 'mandatory',
|
||||
logoUrl: null,
|
||||
dependencies: [],
|
||||
bootstrapKit: true,
|
||||
},
|
||||
]
|
||||
|
||||
function feed(events: DeploymentEvent[]) {
|
||||
const state = buildInitialState(APPS.map((a) => a.id))
|
||||
for (const ev of events) applyEvent(state, ev)
|
||||
return state
|
||||
}
|
||||
|
||||
describe('jobs — deriveJobs', () => {
|
||||
it('derives 4 Phase 0 tofu jobs + 1 cluster-bootstrap job + N per-component jobs', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
// 4 tofu phases + 1 bootstrap + 2 components
|
||||
expect(jobs.length).toBe(4 + 1 + APPS.length)
|
||||
})
|
||||
|
||||
it('marks Phase 0 jobs with app="infrastructure" and noAppLink=true', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const tofuJobs = jobs.filter((j) => j.app === 'infrastructure')
|
||||
expect(tofuJobs.length).toBe(4)
|
||||
for (const j of tofuJobs) {
|
||||
expect(j.noAppLink).toBe(true)
|
||||
expect(j.id.startsWith('infrastructure:')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the bootstrap job with app="cluster-bootstrap" and noAppLink=true', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const bootstrap = jobs.find((j) => j.app === 'cluster-bootstrap')
|
||||
expect(bootstrap).toBeDefined()
|
||||
expect(bootstrap!.noAppLink).toBe(true)
|
||||
})
|
||||
|
||||
it('marks per-component jobs with app=<bp-id> and noAppLink=false', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const cilium = jobs.find((j) => j.id === 'bp-cilium')
|
||||
expect(cilium).toBeDefined()
|
||||
expect(cilium!.app).toBe('bp-cilium')
|
||||
expect(cilium!.noAppLink).toBe(false)
|
||||
})
|
||||
|
||||
it('per-component job flips to running when an installing event arrives', () => {
|
||||
const state = feed([
|
||||
{ phase: 'component', component: 'bp-cilium', state: 'installing', time: '2026-04-29T10:00:00Z' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const cilium = jobs.find((j) => j.id === 'bp-cilium')!
|
||||
expect(cilium.status).toBe('running')
|
||||
})
|
||||
|
||||
it('per-component job flips to succeeded when an installed event arrives', () => {
|
||||
const state = feed([
|
||||
{ phase: 'component', component: 'bp-cilium', state: 'installed', time: '2026-04-29T10:01:00Z' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const cilium = jobs.find((j) => j.id === 'bp-cilium')!
|
||||
expect(cilium.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('per-component job flips to failed when level=error', () => {
|
||||
const state = feed([
|
||||
{ phase: 'component', component: 'bp-cilium', level: 'error', message: 'helm install failed' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const cilium = jobs.find((j) => j.id === 'bp-cilium')!
|
||||
expect(cilium.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('hetzner phase running propagates to tofu-apply job status', () => {
|
||||
const state = feed([
|
||||
{ phase: 'tofu-apply', message: 'creating hcloud_network', time: '2026-04-29T10:00:00Z' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const apply = jobs.find((j) => j.id === 'infrastructure:tofu-apply')!
|
||||
expect(apply.status).toBe('running')
|
||||
expect(apply.steps.length).toBeGreaterThanOrEqual(1)
|
||||
expect(apply.steps[0]!.name).toContain('hcloud_network')
|
||||
})
|
||||
|
||||
it('synthesises a sub-step for each hcloud_* family seen during tofu-apply', () => {
|
||||
const state = feed([
|
||||
{ phase: 'tofu-apply', message: 'hcloud_network.this: Creation complete', time: '2026-04-29T10:00:00Z' },
|
||||
{ phase: 'tofu', message: 'hcloud_server.cp: Creating', time: '2026-04-29T10:01:00Z' },
|
||||
{ phase: 'tofu-output', message: 'output ready', time: '2026-04-29T10:02:00Z' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const apply = jobs.find((j) => j.id === 'infrastructure:tofu-apply')!
|
||||
// Synth steps come AFTER raw events. With state=done they read as succeeded.
|
||||
const synthNames = apply.steps.map((s) => s.name).filter((n) => n.startsWith('Create '))
|
||||
expect(synthNames.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('cluster-bootstrap job carries flux-bootstrap events as steps', () => {
|
||||
const state = feed([
|
||||
{ phase: 'flux-bootstrap', message: 'cloning repo', time: '2026-04-29T10:00:00Z' },
|
||||
{ phase: 'flux-bootstrap', message: 'applying manifests', time: '2026-04-29T10:00:30Z' },
|
||||
])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const bootstrap = jobs.find((j) => j.app === 'cluster-bootstrap')!
|
||||
expect(bootstrap.steps.length).toBeGreaterThanOrEqual(2)
|
||||
expect(bootstrap.steps.map((s) => s.name)).toContain('cloning repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jobs — jobsForApplication', () => {
|
||||
it('filters to a single per-component row and excludes Phase 0 / bootstrap', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
const ciliumOnly = jobsForApplication(jobs, 'bp-cilium')
|
||||
expect(ciliumOnly.length).toBe(1)
|
||||
expect(ciliumOnly[0]!.id).toBe('bp-cilium')
|
||||
})
|
||||
|
||||
it('returns empty for a component not in the descriptor list', () => {
|
||||
const state = feed([])
|
||||
const jobs = deriveJobs(state, APPS)
|
||||
expect(jobsForApplication(jobs, 'bp-nonexistent')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('jobs — statusBadge', () => {
|
||||
it('maps every JobUiStatus to a label + class', () => {
|
||||
expect(statusBadge('succeeded').text).toBe('Succeeded')
|
||||
expect(statusBadge('running').text).toBe('Running')
|
||||
expect(statusBadge('failed').text).toBe('Failed')
|
||||
expect(statusBadge('pending').text).toBe('Pending')
|
||||
})
|
||||
|
||||
it('badge classes carry the canonical color tokens', () => {
|
||||
expect(statusBadge('succeeded').classes).toContain('var(--color-success)')
|
||||
expect(statusBadge('running').classes).toContain('var(--color-accent)')
|
||||
expect(statusBadge('failed').classes).toContain('var(--color-danger)')
|
||||
expect(statusBadge('pending').classes).toContain('var(--color-warn)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jobs — fmtTime', () => {
|
||||
it('formats valid timestamps and rejects placeholder zero-value', () => {
|
||||
expect(fmtTime(null)).toBe('')
|
||||
expect(fmtTime(undefined)).toBe('')
|
||||
expect(fmtTime('0001-01-01T00:00:00Z')).toBe('')
|
||||
// A real timestamp produces a non-empty string in any locale.
|
||||
const fmt = fmtTime('2026-04-29T10:00:00Z')
|
||||
expect(fmt.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
278
products/catalyst/bootstrap/ui/src/pages/sovereign/jobs.ts
Normal file
278
products/catalyst/bootstrap/ui/src/pages/sovereign/jobs.ts
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* jobs.ts — synthesises the Sovereign-provision Job model + reducer
|
||||
* adapter from the existing eventReducer.ts state.
|
||||
*
|
||||
* RATIONALE (per spec):
|
||||
* • Phase 0 phases (`tofu-init`, `tofu-plan`, `tofu-apply`,
|
||||
* `tofu-output`) → 1 Job each, app="infrastructure".
|
||||
* • `flux-bootstrap` → 1 Job, app="cluster-bootstrap".
|
||||
* • Each per-bp-* HelmRelease (= each per-Application card) → 1 Job,
|
||||
* app=componentId (full bp- form).
|
||||
*
|
||||
* Each Job's expanded panel renders an ordered step list. For Phase 0
|
||||
* jobs, "steps" are the discrete `tofu` events captured against the
|
||||
* Hetzner-infra phase bucket — ordered chronologically — plus inferred
|
||||
* sub-steps (one per hcloud_* family seen). For per-component jobs,
|
||||
* "steps" are the chronological events captured against that
|
||||
* component's bucket in `eventsByTarget`.
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), there is NO
|
||||
* hand-maintained list of jobs. Every Job is derived from the catalog
|
||||
* (`applications: ApplicationDescriptor[]`) and reducer state.
|
||||
*
|
||||
* Per #2 (never compromise), the reducer adapter doesn't lossy-collapse
|
||||
* data: the full event log is preserved on each Job's `steps` array so
|
||||
* the JobsPage expand-in-place panel can render the same order the
|
||||
* Hetzner / cluster-bootstrap / per-component bucket received.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
DeploymentEvent,
|
||||
PhaseStatus,
|
||||
ReducerState,
|
||||
} from './eventReducer'
|
||||
import {
|
||||
CLUSTER_BOOTSTRAP_BUCKET,
|
||||
HETZNER_INFRA_BUCKET,
|
||||
} from './eventReducer'
|
||||
import type { ApplicationDescriptor } from './applicationCatalog'
|
||||
|
||||
/** UI rendering bucket — same vocabulary as core/console JobsPage.svelte. */
|
||||
export type JobUiStatus = 'pending' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
/** Ordered step inside a Job's expanded panel. */
|
||||
export interface JobStep {
|
||||
/** Stable index inside the parent Job's `steps` array. */
|
||||
index: number
|
||||
/** Human-readable step title (event message or derived label). */
|
||||
name: string
|
||||
/** UI status — same vocabulary as the parent Job. */
|
||||
status: JobUiStatus
|
||||
/** ISO timestamp of the event that drove this step (or null). */
|
||||
startedAt: string | null
|
||||
/** Optional latency message (e.g. "12s") — pixel-ported from core. */
|
||||
message: string | null
|
||||
}
|
||||
|
||||
/** Top-level Job — one row in the JobsPage vertical stack. */
|
||||
export interface Job {
|
||||
/** Stable id — `infrastructure:<phase>` / `cluster-bootstrap` / `<bp-id>`. */
|
||||
id: string
|
||||
/**
|
||||
* Logical "app" attribution — drives the row's app-name link target.
|
||||
* • `"infrastructure"` for the four tofu jobs (no app detail page).
|
||||
* • `"cluster-bootstrap"` for the flux-bootstrap job (no app detail page).
|
||||
* • `"bp-<slug>"` for per-component jobs — the row's app-name link
|
||||
* navigates to that component's AppDetail page.
|
||||
*/
|
||||
app: 'infrastructure' | 'cluster-bootstrap' | string
|
||||
/** Display name of the job — e.g. "Provision Hetzner network", "Install Cilium". */
|
||||
title: string
|
||||
/** UI rendering bucket. */
|
||||
status: JobUiStatus
|
||||
/** Most-recent event time across this job's events. */
|
||||
updatedAt: string | null
|
||||
/** Chronological event list — drives the expanded panel. */
|
||||
steps: JobStep[]
|
||||
/** True when the row is part of Phase 0 (no AppDetail navigation). */
|
||||
noAppLink: boolean
|
||||
}
|
||||
|
||||
const TOFU_PHASE_LABELS: Record<string, string> = {
|
||||
'tofu-init': 'Provision Hetzner — terraform init',
|
||||
'tofu-plan': 'Provision Hetzner — terraform plan',
|
||||
'tofu-apply': 'Provision Hetzner — terraform apply',
|
||||
'tofu-output': 'Provision Hetzner — terraform output',
|
||||
'tofu': 'Provision Hetzner — runtime events',
|
||||
}
|
||||
|
||||
/** Derive Job UI status from an Application status enum. */
|
||||
function appStatusToUi(s: ApplicationStatus): JobUiStatus {
|
||||
switch (s) {
|
||||
case 'installed': return 'succeeded'
|
||||
case 'installing': return 'running'
|
||||
case 'failed': return 'failed'
|
||||
case 'degraded': return 'failed'
|
||||
case 'pending':
|
||||
case 'unknown':
|
||||
default: return 'pending'
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive Job UI status from a phase-banner state. */
|
||||
function phaseStatusToUi(s: PhaseStatus): JobUiStatus {
|
||||
switch (s) {
|
||||
case 'done': return 'succeeded'
|
||||
case 'running': return 'running'
|
||||
case 'failed': return 'failed'
|
||||
case 'pending':
|
||||
default: return 'pending'
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a JobStep from a DeploymentEvent at index `i`. */
|
||||
function eventToStep(ev: DeploymentEvent, i: number): JobStep {
|
||||
const time = ev.time ?? null
|
||||
const status: JobUiStatus =
|
||||
ev.level === 'error' ? 'failed' :
|
||||
ev.state === 'installed' ? 'succeeded' :
|
||||
ev.state === 'failed' ? 'failed' :
|
||||
ev.state === 'pending' ? 'pending' :
|
||||
'running'
|
||||
const name = ev.message?.trim()
|
||||
? ev.message
|
||||
: `${ev.phase}${ev.component ? ` · ${ev.component}` : ''}`
|
||||
return {
|
||||
index: i,
|
||||
name,
|
||||
status,
|
||||
startedAt: time,
|
||||
message: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the full job list from the reducer state + the resolved
|
||||
* application descriptors.
|
||||
*
|
||||
* • Job order: 4 tofu jobs (in declared phase order) →
|
||||
* 1 cluster-bootstrap job → 1 job per Application (in
|
||||
* descriptor order — bootstrap-kit then user-selected).
|
||||
* • Each Job's `steps` is the chronological event log for its bucket;
|
||||
* when no events are captured yet, `steps` is empty and the row
|
||||
* reads as `pending`.
|
||||
*
|
||||
* This function is a PURE derivation — no side effects, no caching.
|
||||
* Callers memoize on (state, applications) identity to avoid re-render.
|
||||
*/
|
||||
export function deriveJobs(
|
||||
state: ReducerState,
|
||||
applications: readonly ApplicationDescriptor[],
|
||||
): Job[] {
|
||||
const out: Job[] = []
|
||||
|
||||
// 1. Hetzner-infra Phase 0 jobs — split per declared tofu phase.
|
||||
// Filter the bucket once, partition by event.phase to surface a
|
||||
// per-phase row even if the API only emits the catch-all "tofu"
|
||||
// events for sub-steps.
|
||||
const tofuBucket = state.eventsByTarget[HETZNER_INFRA_BUCKET()] ?? []
|
||||
const tofuByPhase: Record<string, DeploymentEvent[]> = {}
|
||||
for (const ev of tofuBucket) {
|
||||
const key = ev.phase
|
||||
if (!tofuByPhase[key]) tofuByPhase[key] = []
|
||||
tofuByPhase[key]!.push(ev)
|
||||
}
|
||||
const TOFU_ORDER = ['tofu-init', 'tofu-plan', 'tofu-apply', 'tofu-output'] as const
|
||||
for (const phase of TOFU_ORDER) {
|
||||
const evs = tofuByPhase[phase] ?? []
|
||||
// Sub-steps for tofu-apply: synthesise one row per hcloud_* family
|
||||
// captured during the run so the operator can track which resource
|
||||
// family is currently being created. Synthesised steps come AFTER
|
||||
// the raw event log so chronology is preserved.
|
||||
const baseSteps = evs.map((ev, i) => eventToStep(ev, i))
|
||||
const synthSteps: JobStep[] = []
|
||||
if (phase === 'tofu-apply') {
|
||||
for (const family of state.hetznerInfra.seenResources) {
|
||||
synthSteps.push({
|
||||
index: baseSteps.length + synthSteps.length,
|
||||
name: `Create ${family}`,
|
||||
status: state.hetznerInfra.status === 'failed' ? 'failed' :
|
||||
state.hetznerInfra.status === 'done' ? 'succeeded' : 'running',
|
||||
startedAt: state.hetznerInfra.lastEventTime,
|
||||
message: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
const status: JobUiStatus =
|
||||
// Once the overall hetzner phase is done, every sub-phase is
|
||||
// implicitly done; if it's failed we cannot tell which sub-phase
|
||||
// failed without level=error in the bucket — keep `pending` for
|
||||
// sub-phases without their own events.
|
||||
state.hetznerInfra.status === 'failed' && evs.some((e) => e.level === 'error') ? 'failed' :
|
||||
state.hetznerInfra.status === 'done' ? 'succeeded' :
|
||||
evs.length > 0 ? 'running' :
|
||||
'pending'
|
||||
out.push({
|
||||
id: `infrastructure:${phase}`,
|
||||
app: 'infrastructure',
|
||||
title: TOFU_PHASE_LABELS[phase] ?? phase,
|
||||
status,
|
||||
updatedAt: state.hetznerInfra.lastEventTime,
|
||||
steps: [...baseSteps, ...synthSteps],
|
||||
noAppLink: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Cluster bootstrap job.
|
||||
const bootstrapBucket = state.eventsByTarget[CLUSTER_BOOTSTRAP_BUCKET()] ?? []
|
||||
out.push({
|
||||
id: 'cluster-bootstrap',
|
||||
app: 'cluster-bootstrap',
|
||||
title: 'Bootstrap cluster (Flux + GitOps repo)',
|
||||
status: phaseStatusToUi(state.clusterBootstrap.status),
|
||||
updatedAt: state.clusterBootstrap.lastEventTime,
|
||||
steps: bootstrapBucket.map((ev, i) => eventToStep(ev, i)),
|
||||
noAppLink: true,
|
||||
})
|
||||
|
||||
// 3. Per-Application jobs — one per descriptor, in catalog order.
|
||||
for (const app of applications) {
|
||||
const compState = state.apps[app.id]
|
||||
const compBucket = state.eventsByTarget[app.id] ?? []
|
||||
out.push({
|
||||
id: app.id,
|
||||
app: app.id,
|
||||
title: `Install ${app.title}`,
|
||||
status: compState ? appStatusToUi(compState.status) : 'pending',
|
||||
updatedAt: compState?.lastEventTime ?? null,
|
||||
steps: compBucket.map((ev, i) => eventToStep(ev, i)),
|
||||
noAppLink: false,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the global job list to those scoped to a single component.
|
||||
* Used by AppDetail's appended Jobs section — only the per-component
|
||||
* job is shown, not the Phase 0 / cluster-bootstrap rows.
|
||||
*/
|
||||
export function jobsForApplication(
|
||||
jobs: readonly Job[],
|
||||
applicationId: string,
|
||||
): Job[] {
|
||||
return jobs.filter((j) => j.app === applicationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Status-pill label/text mapping — pixel-ported from core/console
|
||||
* JobsPage.svelte's `statusBadge()`.
|
||||
*/
|
||||
export interface JobBadge {
|
||||
text: string
|
||||
classes: string
|
||||
}
|
||||
export function statusBadge(status: JobUiStatus): JobBadge {
|
||||
switch (status) {
|
||||
case 'succeeded': return { text: 'Succeeded', classes: 'bg-[var(--color-success)]/15 text-[var(--color-success)]' }
|
||||
case 'running': return { text: 'Running', classes: 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]' }
|
||||
case 'failed': return { text: 'Failed', classes: 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]' }
|
||||
case 'pending':
|
||||
default: return { text: 'Pending', classes: 'bg-[var(--color-warn)]/15 text-[var(--color-warn)]' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp as HH:MM:SS — pixel-ported from JobsPage.svelte. */
|
||||
export function fmtTime(ts: string | null | undefined): string {
|
||||
if (!ts) return ''
|
||||
if (ts.startsWith('0001-')) return ''
|
||||
const t = new Date(ts).getTime()
|
||||
if (!Number.isFinite(t) || t <= 0) return ''
|
||||
return new Date(t).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user