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:
hatiyildiz 2026-04-29 19:37:37 +02:00
parent c2732b7242
commit b6488b2b54
30 changed files with 2713 additions and 2275 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View File

@ -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 ────────────────────────────────────────────── */

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? 'Couldnt 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 couldnt fetch the new clusters 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>
)
}

View File

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

View File

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

View 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"
>
&larr; 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"
>
&larr; 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; }
`

View File

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

View File

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

View File

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

View File

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

View 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 ? 'Couldnt 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 couldnt fetch the new clusters 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); }
`

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View 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)
})
})

View 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',
})
}