feat(sovereign-console): kill duplicate /console/* pages, redirect to canonical /provision/$id/* (Iteration 1) (#972)

* feat(sovereign-console): kill duplicate /console/* pages, redirect to canonical /provision/$id/* (Iteration 1)

Founder-reported on otech116/117: the /console/dashboard, /console/apps,
/console/jobs, /console/cloud, /console/users, /console/settings pages
are STUBS that look completely different from the canonical Sovereign
Console operators see at console.openova.io/sovereign/provision/$id/*.

Investigation: 6 duplicate Console*Page React components were shipped in
PR #937 — separate stub implementations of pages that already exist as
the canonical Dashboard / AppsPage / JobsPage / CloudPage /
UserAccessListPage / SettingsPage components used by the
/provision/$deploymentId/* route tree (the same the wizard renders).

Fix (Iteration 1):
  - DELETE the 6 duplicate Console*Page components.
  - Replace the /console/* router routes with SovereignConsoleRedirect:
    a tiny component that fetches /api/v1/sovereign/self for the
    Sovereign's own deployment id, then router-navigates to the
    canonical /provision/<self-id>/<page>. Same components, same data,
    pixel-byte-byte-identical UI to the mothership view.
  - Add catalyst-api endpoint GET /api/v1/sovereign/self that returns
    the deployment id from CATALYST_SELF_DEPLOYMENT_ID env. Mothership
    (env unset) → 404. Sovereign with stamped id → 200. Sovereign
    pre-handover → 503 deployment-id-not-yet-stamped.
  - Wire env via the existing sovereign-fqdn ConfigMap (B1 PR #912):
    new key `selfDeploymentId`, sourced from
    .Values.global.sovereignSelfDeploymentId. Empty until the
    orchestrator's per-Sovereign overlay writer stamps it.
  - Add useResolvedDeploymentId React hook (URL params first, then
    /sovereign/self fallback) — wires Iteration 2 (clean URLs) below.

Iteration 2 (next PR — out of scope here):
  - Drop the /sovereign/provision/<id>/ URL prefix on Sovereign by
    refactoring 6 canonical components to use useResolvedDeploymentId
    instead of strict useParams. Then /console/dashboard renders the
    canonical Dashboard at the clean URL with deployment id resolved
    from /sovereign/self.

Iteration 3 (next PR after — also out of scope):
  - Handover history transfer: contabo's catalyst-api at handover POSTs
    the full deployment record (events, jobs, HRs, cloud topology) to
    the Sovereign's catalyst-api so /provision/<id>/* on the Sovereign
    answers with byte-byte-identical data.

Bumped: bp-catalyst-platform 1.4.26 → 1.4.27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sovereign-console): clean URLs — /console/* mounts canonical components directly

Removes the SovereignConsoleRedirect indirection. The 6 canonical
operator components (Dashboard, AppsPage, JobsPage, JobDetail,
CloudPage, AppDetail, UserAccessListPage, UserAccessEditPage,
SettingsPage) now render at clean /console/<page> URLs on Sovereign,
NOT under /sovereign/provision/<id>/<page>.

Pages that previously hard-coupled to the URL via
  useParams({ from: '/provision/$deploymentId/...' })
now use useResolvedDeploymentId() which:
  1. reads URL params (when on the legacy /provision/$id/* tree on
     contabo's mothership wizard)
  2. falls back to GET /api/v1/sovereign/self (Sovereign self-discovery)

Refactored: Dashboard, AppsPage, JobsPage, SettingsPage, UserAccessListPage.
CloudPage already used strict:false — no change needed.

Wires the /console/* router subtree to the canonical components +
adds the missing children routes (/jobs/$jobId, /users/new,
/users/$name, /app/$componentId) so the canonical UI's deep-links
work on the clean URL surface too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-05 20:17:36 +04:00 committed by GitHub
parent 608db53a25
commit 6ec7851bc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 321 additions and 1583 deletions

View File

@ -226,7 +226,7 @@ spec:
# every trigger call returned 502 "token-review-failed" on
# otech113 (chart 0.1.18 fixed the readiness loop but exposed
# this missing-RBAC bug as the next failure). 2026-05-05.
version: 1.4.26
version: 1.4.27
sourceRef:
kind: HelmRepository
name: bp-catalyst-platform

View File

@ -235,6 +235,21 @@ func main() {
// calls this.
r.Get("/api/v1/tenant/discover", h.HandleTenantDiscover)
// /api/v1/sovereign/self — Sovereign self-discovery (deployment id +
// FQDN) used by the catalyst-ui SovereignConsoleRedirect React
// component to map clean `/console/<page>` URLs on a Sovereign to the
// canonical deployment-scoped `/provision/<self-id>/<page>` pages
// that render the same byte-byte data the mothership serves at
// console.openova.io/sovereign/provision/<id>/<page>.
//
// Outside RequireSession: the response carries only public
// identifiers (deployment id + FQDN — both visible in URLs anyway).
// Bypassing the session gate keeps the redirect helper usable on
// the very first browser hit before the operator has logged in.
// Mothership returns 404 (CATALYST_OTECH_FQDN unset) — the UI hook
// silently falls back to URL params there. See sovereign_self.go.
r.Get("/api/v1/sovereign/self", h.HandleSovereignSelf)
// Wire the tenant registry — flat-file store at
// CATALYST_DEPLOYMENTS_DIR/-tenant-registry.json. Per ADR-0001 §6
// the catalyst-api is the host process for the unified-rbac slice

View File

@ -0,0 +1,77 @@
// sovereign_self.go — GET /api/v1/sovereign/self
//
// Sovereign self-discovery: returns the deployment id and FQDN of the
// Sovereign cluster the catalyst-api Pod is running on. Used by the
// Sovereign-mode catalyst-ui to resolve the deployment id for clean URLs
// (`/console/dashboard` → `/provision/<self-id>/dashboard`) and to wire
// the deployment-scoped data fetches the canonical UI components expect.
//
// Resolution order:
//
// 1. CATALYST_SELF_DEPLOYMENT_ID env (populated at handover from the
// contabo orchestrator via the bp-catalyst-platform chart's
// sovereign-fqdn ConfigMap). This is the canonical source of truth
// post-handover.
// 2. CATALYST_OTECH_FQDN env (already wired via the same ConfigMap, see
// B1 PR #912). Used to populate the FQDN field. If
// CATALYST_SELF_DEPLOYMENT_ID is empty AND CATALYST_OTECH_FQDN is
// populated, returns 503 deployment-id-not-yet-stamped to make the
// UI fall back gracefully (instead of looping on empty).
// 3. Both empty → 404 not-a-sovereign — the mothership (contabo) hits
// this and the UI silently treats it as "not on a Sovereign", which
// is the correct default for `console.openova.io`.
//
// No authentication required: the response carries no secrets, only
// public identifiers (deployment id + FQDN are both visible in URLs).
// Bypassing the session gate keeps the SovereignConsoleRedirect helper
// usable on the very first browser hit before login.
package handler
import (
"net/http"
"os"
"strings"
)
// SovereignSelfResponse — wire shape of GET /api/v1/sovereign/self.
//
// Field names mirror the Sovereign deployment record so the UI can use
// the same schema across mothership-side `/api/v1/deployments/{id}` and
// Sovereign-side `/api/v1/sovereign/self`.
type SovereignSelfResponse struct {
DeploymentID string `json:"deploymentId"`
SovereignFQDN string `json:"sovereignFQDN"`
}
// HandleSovereignSelf returns the active Sovereign's deployment id + FQDN.
// Returns 404 on the mothership, 503 on Sovereigns that haven't received
// the post-handover deployment-id stamp yet.
func (h *Handler) HandleSovereignSelf(w http.ResponseWriter, _ *http.Request) {
deploymentID := strings.TrimSpace(os.Getenv("CATALYST_SELF_DEPLOYMENT_ID"))
fqdn := strings.TrimSpace(os.Getenv("CATALYST_OTECH_FQDN"))
// Mothership: neither env is set — surface 404 so the UI hook treats
// this as "not on a Sovereign" and uses URL params instead.
if deploymentID == "" && fqdn == "" {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "not-a-sovereign",
"detail": "this catalyst-api Pod is not running on a Sovereign cluster (no CATALYST_OTECH_FQDN/SELF_DEPLOYMENT_ID env)",
})
return
}
// Sovereign without stamped deployment id — the handover step is
// behind. UI sees 503 and shows a "waiting for handover" pill rather
// than looping.
if deploymentID == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "deployment-id-not-yet-stamped",
"detail": "Sovereign FQDN is known but the contabo orchestrator has not yet POSTed the deployment id to this cluster. The handover step is behind — retry shortly.",
"fqdn": fqdn,
})
return
}
writeJSON(w, http.StatusOK, SovereignSelfResponse{
DeploymentID: deploymentID,
SovereignFQDN: fqdn,
})
}

View File

@ -24,16 +24,19 @@ import { consumeProvisionFlashBanner } from '@/shared/lib/flashBanner'
* count. SKU vocabulary is per-provider (cx32
* Standard_D4s_v5 m6i.xlarge), so sizing must live
* INSIDE this step, not in topology.
* 4. Credentials once each region has a provider, collect the API
* token each chosen provider needs, plus the SSH key.
* 5. Components platform building-block selection.
* 6. Marketplace opt into Marketplace mode (issue #710 wave 3a). The
* 4. Components platform building-block selection.
* 5. Marketplace opt into Marketplace mode (issue #710 wave 3a). The
* toggle decides whether the Sovereign exposes a
* per-tenant SaaS storefront at
* marketplace.<sovereign-fqdn>.
* 7. Domain pool subdomain or BYO domain. Admin email was
* 6. Domain pool subdomain or BYO domain. Admin email was
* decommissioned in #762; orgEmail is now stamped
* from session.email server-side (PR #759).
* 7. Credentials API token per chosen provider + SSH key. Final
* input before Review (#973): operators shape the
* deployment first, hand over keys last. Provider
* (step 3) precedes this so per-region tokens know
* which providers to ask for.
* 8. Review POST body preview + launch.
*
* Topology BEFORE Provider is the dependency-correct order: a provider is
@ -56,10 +59,10 @@ export const WIZARD_STEPS = [
{ id: 1, label: 'Organisation', desc: 'Industry, size, HQ, compliance' },
{ id: 2, label: 'Topology', desc: 'Regions, HA, air-gap' },
{ id: 3, label: 'Provider', desc: 'Cloud + region + sizing per slot' },
{ id: 4, label: 'Credentials', desc: 'API tokens + SSH key' },
{ id: 5, label: 'Components', desc: 'Platform building blocks' },
{ id: 6, label: 'Marketplace', desc: 'Multi-tenant SaaS storefront' },
{ id: 7, label: 'Domain', desc: 'Pool subdomain or BYO domain' },
{ id: 4, label: 'Components', desc: 'Platform building blocks' },
{ id: 5, label: 'Marketplace', desc: 'Multi-tenant SaaS storefront' },
{ id: 6, label: 'Domain', desc: 'Pool subdomain or BYO domain' },
{ id: 7, label: 'Credentials', desc: 'API tokens + SSH key' },
{ id: 8, label: 'Review', desc: 'Confirm and provision' },
]

View File

@ -69,12 +69,13 @@ import { UserAccessEditPage } from '@/pages/admin/user-access/UserAccessEditPage
import { ParentDomainsPage } from '@/pages/admin/parent-domains/ParentDomainsPage'
import { SettingsPage } from '@/pages/sovereign/SettingsPage'
import { NotificationsPage } from '@/pages/sovereign/NotificationsPage'
import { ConsoleDashboardPage } from '@/pages/sovereign/console/ConsoleDashboardPage'
import { ConsoleAppsPage } from '@/pages/sovereign/console/ConsoleAppsPage'
import { ConsoleJobsPage } from '@/pages/sovereign/console/ConsoleJobsPage'
import { ConsoleCloudPage } from '@/pages/sovereign/console/ConsoleCloudPage'
import { ConsoleUsersPage } from '@/pages/sovereign/console/ConsoleUsersPage'
import { ConsoleSettingsPage } from '@/pages/sovereign/console/ConsoleSettingsPage'
// Sovereign-mode /console/* routes use the same canonical components as
// /provision/$deploymentId/* — see the SovereignConsoleRedirect helper
// near the bottom of this file. The duplicate ConsoleDashboardPage /
// ConsoleAppsPage / ConsoleJobsPage / ConsoleCloudPage / ConsoleUsersPage
// / ConsoleSettingsPage stubs have been DELETED (issue: pixel-byte-byte
// identical UI between mothership-side /provision/$id/dashboard and
// Sovereign-side post-handover console).
import { MarketplaceSettings } from '@/pages/sovereign/settings/MarketplaceSettings'
import { CatalogAdminPage } from '@/pages/sovereign/CatalogAdminPage'
import { DeploymentsList } from '@/pages/sovereign/DeploymentsList'
@ -82,6 +83,7 @@ import { UsersPage as SMEUsersPage } from '@/pages/sme/UsersPage'
import { RolesPage as SMERolesPage } from '@/pages/sme/RolesPage'
import { CreateTenantPage as SMECreateTenantPage } from '@/pages/sme/CreateTenantPage'
import { SovereigntyPreviewPage } from '@/pages/sovereignty/SovereigntyPreviewPage'
import { SovereignConsoleRedirect } from '@/pages/sovereign/SovereignConsoleRedirect'
// Root
const rootRoute = createRootRoute({ component: RootLayout })
@ -606,13 +608,36 @@ const marketplaceProductRoute = createRoute({
* /auth/handover redirect to /console/dashboard (safety net)
*/
/**
* Sovereign-mode /console/* REDIRECT-ONLY shell.
*
* The duplicate ConsoleDashboardPage / ConsoleAppsPage / ConsoleJobsPage /
* ConsoleCloudPage / ConsoleUsersPage / ConsoleSettingsPage stubs from
* PR #937 have been DELETED. There is now exactly ONE canonical implementation
* of every operator surface Dashboard, AppsPage, JobsPage, CloudPage,
* UserAccessListPage, SettingsPage under the `/provision/$deploymentId/*`
* route tree (the same the wizard renders at console.openova.io).
*
* For Sovereign-mode operators landing on `console.<sov-fqdn>/console/*`
* (the URL the SovereignSidebar links to), the routes below redirect to
* the canonical `/provision/$selfDeploymentId/*` after fetching the self
* deployment id from `/api/v1/sovereign/self`. Same components, same
* styling, same data pixel-byte-byte identical to the mothership view.
*
* Iteration 2 will drop the `/sovereign/provision/$id/` URL prefix on
* Sovereigns by refactoring the canonical components to read deploymentId
* from a route-aware hook (useResolvedDeploymentId, already added).
*/
const consoleLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/console',
component: SovereignConsoleLayout,
})
// /console → redirect to /console/dashboard
// /console/* mounts the SAME canonical components as /provision/$deploymentId/*
// — the components resolve deploymentId via useResolvedDeploymentId() which
// falls back from URL params to GET /api/v1/sovereign/self when running on
// a Sovereign cluster. Pixel-byte-byte-identical UI, clean URLs.
const consoleIndexRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/',
@ -621,41 +646,55 @@ const consoleIndexRoute = createRoute({
},
component: () => null,
})
const consoleDashboardRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/dashboard',
component: ConsoleDashboardPage,
component: Dashboard,
})
const consoleAppsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/apps',
component: ConsoleAppsPage,
component: AppsPage,
})
const consoleJobsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/jobs',
component: ConsoleJobsPage,
component: JobsPage,
})
const consoleJobDetailRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/jobs/$jobId',
component: JobDetail,
})
const consoleCloudRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/cloud',
component: ConsoleCloudPage,
component: CloudPage,
})
const consoleUsersRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/users',
component: ConsoleUsersPage,
component: UserAccessListPage,
})
const consoleUsersNewRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/users/new',
component: UserAccessEditPage,
})
const consoleUsersEditRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/users/$name',
component: UserAccessEditPage,
})
const consoleSettingsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/settings',
component: ConsoleSettingsPage,
component: SettingsPage,
})
const consoleAppDetailRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/app/$componentId',
component: AppDetail,
})
// /console/settings/marketplace — operator toggles marketplace mode on a
@ -778,9 +817,13 @@ const routeTree = rootRoute.addChildren([
consoleIndexRoute,
consoleDashboardRoute,
consoleAppsRoute,
consoleAppDetailRoute,
consoleJobsRoute,
consoleJobDetailRoute,
consoleCloudRoute,
consoleUsersRoute,
consoleUsersNewRoute,
consoleUsersEditRoute,
consoleCatalogRoute,
consoleSettingsRoute,
consoleSettingsMarketplaceRoute,

View File

@ -31,7 +31,8 @@
*/
import { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter, Link } from '@tanstack/react-router'
import { useRouter, Link } from '@tanstack/react-router'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { useWizardStore } from '@/entities/deployment/store'
import { PortalShell } from './PortalShell'
import { resolveApplications, type ApplicationDescriptor } from './applicationCatalog'
@ -49,8 +50,8 @@ interface AppsPageProps {
type TabId = 'installed' | 'catalog'
export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
const params = useParams({ from: '/provision/$deploymentId' as never }) as {
deploymentId: string
const params = useResolvedDeploymentId() as {
deploymentId: string | null
}
// The route param is a `string` from TanStack — validate it against
// the branded `DeploymentID` shape at the boundary so a 15-char
@ -58,7 +59,7 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
// down. We use `isDeploymentID` (rather than throwing parse) so the
// page can render its own malformed-id banner without crashing the
// route. Closes issues #749 + #754.
const rawDeploymentId = params.deploymentId
const rawDeploymentId = params.deploymentId ?? ''
const deploymentIdValid = isDeploymentID(rawDeploymentId)
const deploymentId = rawDeploymentId
const router = useRouter()

View File

@ -44,7 +44,8 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useRouter, Link } from '@tanstack/react-router'
import { useRouter, Link } from '@tanstack/react-router'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { useQuery } from '@tanstack/react-query'
import { ResponsiveContainer, Treemap } from 'recharts'
@ -129,10 +130,8 @@ export function Dashboard({
initialColorBy,
initialSizeBy,
}: DashboardProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/dashboard' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const { deploymentId: resolved, isLoading: idLoading } = useResolvedDeploymentId()
const deploymentId = resolved ?? ''
const router = useRouter()
const { snapshot } = useDeploymentEvents({

View File

@ -17,7 +17,8 @@
*/
import { useMemo } from 'react'
import { useParams, Link } from '@tanstack/react-router'
import { Link } from '@tanstack/react-router'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { useWizardStore } from '@/entities/deployment/store'
import { PortalShell } from './PortalShell'
import { JobsTable } from './JobsTable'
@ -38,10 +39,8 @@ export function JobsPage({
disableStream = false,
disableJobsBackfill = false,
}: JobsPageProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/jobs' as never,
}) as { deploymentId: string }
const { deploymentId } = params
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const store = useWizardStore()
const applications = useMemo(

View File

@ -0,0 +1,52 @@
/**
* SovereignConsoleRedirect runs ON Sovereign clusters and redirects
* `/console/<page>` `/provision/<self-deployment-id>/<page>` so the
* canonical mothership-side React components render with this Sovereign's
* own deployment record.
*
* The self deployment id is fetched from `GET /api/v1/sovereign/self`
* which the catalyst-api Pod on a Sovereign returns with the deployment id
* stamped at handover (env var `CATALYST_SELF_DEPLOYMENT_ID`).
*
* On the mothership (console.openova.io) `/api/v1/sovereign/self` returns
* 404 these routes are not expected to be hit there. Defensive fallback:
* redirect to `/wizard`.
*
* Pixel-byte-byte identical UI contract: the same Dashboard / AppsPage /
* JobsPage / CloudPage / UserAccessListPage / SettingsPage components
* render on Sovereign and mothership. Only the host part of the URL
* differs and ONLY because `iteration 2` has not yet dropped the
* `/sovereign/provision/$id/` URL prefix on Sovereign installs.
*/
import { useEffect } from 'react'
import { useRouter } from '@tanstack/react-router'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
interface Props {
/** Sub-path under /provision/$id/ to land on. Empty = the AppsPage index. */
to?: '' | 'dashboard' | 'jobs' | 'cloud' | 'users' | 'settings' | 'notifications'
}
export function SovereignConsoleRedirect({ to = 'dashboard' }: Props = {}) {
const router = useRouter()
const { deploymentId, isLoading } = useResolvedDeploymentId()
useEffect(() => {
if (isLoading) return
if (!deploymentId) {
// Not on a Sovereign (or self-discovery failed) — fall back to wizard.
router.navigate({ to: '/wizard', replace: true } as never)
return
}
const path = to === ''
? `/provision/${deploymentId}`
: `/provision/${deploymentId}/${to}`
router.navigate({ to: path as never, replace: true })
}, [deploymentId, isLoading, router, to])
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
)
}

View File

@ -1,121 +0,0 @@
/**
* ConsoleAppsPage.test.tsx issue #933.
*
* Locks in the Sovereign Console Apps surface against
* /api/v1/sovereign/apps:
*
* Loaded state renders one card per app with status badge
* Bootstrap-kit cards render with the "bootstrap" badge
* Available cards render an "Install" button affordance
* Search filter narrows the visible cards
* Status filter chips narrow the visible cards
* Error state surfaces the API error message
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'
import { ConsoleAppsPage } from './ConsoleAppsPage'
const ORIGINAL_FETCH = globalThis.fetch
afterEach(() => {
cleanup()
globalThis.fetch = ORIGINAL_FETCH
})
function mockFetchOnce(body: unknown, ok = true) {
globalThis.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
json: async () => body,
}) as unknown as typeof fetch
}
const SAMPLE_APPS = {
generatedAt: '2026-05-05T10:00:00Z',
bootstrapKit: ['bp-cilium'],
apps: [
{
id: 'bp-cilium',
slug: 'cilium',
title: 'Cilium',
summary: 'eBPF networking',
tags: [],
status: 'bootstrap',
bootstrapKit: true,
},
{
id: 'bp-keycloak',
slug: 'keycloak',
title: 'Keycloak',
summary: 'Identity provider',
category: 'identity',
tags: ['auth'],
status: 'installed',
bootstrapKit: false,
},
{
id: 'bp-harbor',
slug: 'harbor',
title: 'Harbor Registry',
summary: 'OCI registry',
category: 'platform',
tags: ['registry'],
status: 'available',
bootstrapKit: false,
},
],
}
describe('ConsoleAppsPage', () => {
it('renders one card per app with status badge', async () => {
mockFetchOnce(SAMPLE_APPS)
render(<ConsoleAppsPage />)
await waitFor(() => {
expect(screen.getByTestId('apps-grid')).toBeTruthy()
})
expect(screen.getByTestId('app-card-bp-cilium')).toBeTruthy()
expect(screen.getByTestId('app-card-bp-keycloak')).toBeTruthy()
expect(screen.getByTestId('app-card-bp-harbor')).toBeTruthy()
// Available card carries an Install button.
expect(screen.getByTestId('app-install-bp-harbor')).toBeTruthy()
})
it('search narrows results by title / id / category', async () => {
mockFetchOnce(SAMPLE_APPS)
render(<ConsoleAppsPage />)
await waitFor(() => screen.getByTestId('apps-grid'))
const search = screen.getByTestId('apps-search') as HTMLInputElement
fireEvent.change(search, { target: { value: 'harbor' } })
expect(screen.getByTestId('app-card-bp-harbor')).toBeTruthy()
expect(screen.queryByTestId('app-card-bp-cilium')).toBeNull()
expect(screen.queryByTestId('app-card-bp-keycloak')).toBeNull()
})
it('filter chips narrow by status', async () => {
mockFetchOnce(SAMPLE_APPS)
render(<ConsoleAppsPage />)
await waitFor(() => screen.getByTestId('apps-grid'))
fireEvent.click(screen.getByTestId('apps-filter-available'))
expect(screen.getByTestId('app-card-bp-harbor')).toBeTruthy()
expect(screen.queryByTestId('app-card-bp-cilium')).toBeNull()
expect(screen.queryByTestId('app-card-bp-keycloak')).toBeNull()
})
it('renders the error state when the API fails', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({ error: 'oops' }),
}) as unknown as typeof fetch
render(<ConsoleAppsPage />)
await waitFor(() => {
expect(screen.getByTestId('apps-error')).toBeTruthy()
})
})
})

View File

@ -1,250 +0,0 @@
/**
* ConsoleAppsPage Sovereign Console /console/apps.
*
* Reads /api/v1/sovereign/apps (issue #933). The endpoint joins the
* embedded Blueprint catalog (same data the wizard's StepComponents
* renders) with the local cluster's HelmRelease state, so:
*
* - "installed" bp-* HR present + Ready=True
* - "installing" bp-* HR present, Ready=False or pending
* - "bootstrap" bootstrap-kit member (always installed; cannot
* be uninstalled via UI)
* - "available" listed in catalog, no matching HR yet
*
* The page renders the FULL marketplace card grid an operator expects
* after handover no need to reach the mothership, no waiting for
* the gitea-mirror cutover step. This is the "Apps" surface that
* makes a fresh Sovereign immediately useful.
*/
import { useEffect, useMemo, useState } from 'react'
import { Package, Search, Filter, RefreshCw, AlertCircle, CheckCircle2 } from 'lucide-react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
interface AppRow {
id: string
slug: string
title: string
summary: string
category?: string
tagline?: string
tags?: string[]
version?: string
section?: string
depends?: string[]
status: 'installed' | 'installing' | 'available' | 'bootstrap'
bootstrapKit: boolean
}
interface ApiResponse {
apps: AppRow[]
generatedAt?: string
bootstrapKit?: string[]
}
type PageState =
| { status: 'loading' }
| { status: 'loaded'; apps: AppRow[]; generatedAt?: string }
| { status: 'error'; message: string }
async function fetchApps(): Promise<ApiResponse> {
const tokens = loadTokens()
const resp = await fetch(`${API_BASE}/v1/sovereign/apps`, {
headers: {
Accept: 'application/json',
...(tokens ? { Authorization: `Bearer ${tokens.accessToken}` } : {}),
},
})
if (!resp.ok) throw new Error(`status ${resp.status}`)
return (await resp.json()) as ApiResponse
}
const STATUS_LABEL: Record<AppRow['status'], { color: string; text: string }> = {
installed: { color: 'text-green-400', text: 'Installed' },
installing: { color: 'text-blue-400', text: 'Installing' },
available: { color: 'text-[var(--color-text-dim)]', text: 'Available' },
bootstrap: { color: 'text-cyan-400', text: 'Bootstrap' },
}
export function ConsoleAppsPage() {
const [state, setState] = useState<PageState>({ status: 'loading' })
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<'all' | AppRow['status']>('all')
const reload = () => {
setState({ status: 'loading' })
fetchApps()
.then((body) => setState({ status: 'loaded', apps: body.apps ?? [], generatedAt: body.generatedAt }))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
setState({ status: 'error', message: msg })
})
}
useEffect(() => {
reload()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filteredApps = useMemo(() => {
if (state.status !== 'loaded') return []
const q = search.trim().toLowerCase()
return state.apps.filter((a) => {
if (filter !== 'all' && a.status !== filter) return false
if (!q) return true
return (
a.title.toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q) ||
(a.category ?? '').toLowerCase().includes(q) ||
(a.tags ?? []).some((t) => t.toLowerCase().includes(q))
)
})
}, [state, search, filter])
return (
<div data-testid="console-apps-page">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Applications</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Installable Blueprints + currently-installed apps on this Sovereign cluster.
</p>
</div>
<button
type="button"
onClick={reload}
className="flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 text-sm text-[var(--color-text)] hover:bg-[var(--color-bg-3)]"
data-testid="apps-refresh"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{/* toolbar */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-dim)]" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search apps…"
className="h-9 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] pl-9 pr-3 text-sm text-[var(--color-text)] placeholder-[var(--color-text-dim)]"
data-testid="apps-search"
/>
</div>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-[var(--color-text-dim)]" />
{(['all', 'installed', 'installing', 'available', 'bootstrap'] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setFilter(k)}
className={`h-9 rounded-lg border px-3 text-xs uppercase transition-colors ${
filter === k
? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'border-[var(--color-border)] bg-[var(--color-bg-2)] text-[var(--color-text-dim)] hover:bg-[var(--color-bg-3)]'
}`}
data-testid={`apps-filter-${k}`}
>
{k}
</button>
))}
</div>
</div>
{state.status === 'loading' ? (
<div className="flex items-center gap-2 text-sm text-[var(--color-text-dim)]" data-testid="apps-loading">
<RefreshCw className="h-4 w-4 animate-spin" />
Loading apps
</div>
) : state.status === 'error' ? (
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-8 text-center"
data-testid="apps-error"
>
<AlertCircle className="mx-auto mb-3 h-10 w-10 text-red-400" />
<p className="text-sm font-medium text-[var(--color-text)]">Couldnt load apps</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">{state.message}</p>
</div>
) : filteredApps.length === 0 ? (
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-8 text-center"
data-testid="apps-empty"
>
<Package className="mx-auto mb-3 h-10 w-10 text-[var(--color-text-dim)]" />
<p className="text-sm font-medium text-[var(--color-text)]">No matching apps</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">
{search ? 'Try a different search term.' : 'No apps in this filter.'}
</p>
</div>
) : (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
data-testid="apps-grid"
>
{filteredApps.map((app) => {
const badge = STATUS_LABEL[app.status]
return (
<div
key={app.id}
className="flex h-full flex-col rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
data-testid={`app-card-${app.id}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--color-text-strong)]">
{app.title}
</p>
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
{app.id}
{app.version ? ` · v${app.version}` : ''}
</p>
</div>
<span
className={`shrink-0 text-xs font-semibold uppercase ${badge.color}`}
data-testid={`app-status-${app.id}`}
>
<span className="inline-flex items-center gap-1">
{(app.status === 'installed' || app.status === 'bootstrap') && (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{badge.text}
</span>
</span>
</div>
{app.summary ? (
<p className="mt-2 line-clamp-3 text-xs text-[var(--color-text-dim)]">{app.summary}</p>
) : null}
<div className="mt-auto pt-3 flex flex-wrap items-center gap-2">
{app.category ? (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-text-dim)]">
{app.category}
</span>
) : null}
{app.status === 'available' ? (
<button
type="button"
className="ml-auto h-7 rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)]/10 px-3 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent)]/20"
data-testid={`app-install-${app.id}`}
>
Install
</button>
) : null}
</div>
</div>
)
})}
</div>
)}
{state.status === 'loaded' && state.generatedAt ? (
<p className="mt-6 text-xs text-[var(--color-text-dim)]" data-testid="apps-catalog-meta">
Catalog snapshot generated {new Date(state.generatedAt).toLocaleString()}.
</p>
) : null}
</div>
)
}

View File

@ -1,150 +0,0 @@
/**
* ConsoleCloudPage.test.tsx issue #933.
*
* Locks in the Sovereign Console Cloud surface against
* /api/v1/sovereign/cloud:
*
* Loaded state renders all 7 sections (Nodes, Namespaces,
* Ingresses, HTTPRoutes, LBs, Storage Classes, PVCs)
* Each list item renders with name + namespace + URL when
* applicable
* Empty cluster surface still renders all 7 sections (zero rows)
* plus a "cluster appears empty" hint
* Error state surfaces the API error
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import { ConsoleCloudPage } from './ConsoleCloudPage'
const ORIGINAL_FETCH = globalThis.fetch
afterEach(() => {
cleanup()
globalThis.fetch = ORIGINAL_FETCH
})
function mockFetchOnce(body: unknown, ok = true) {
globalThis.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
json: async () => body,
}) as unknown as typeof fetch
}
const POPULATED = {
nodes: [
{
name: 'cp-1',
status: 'Ready',
roles: ['control-plane'],
kubeletVersion: 'v1.31.4',
os: 'linux',
arch: 'amd64',
internalIP: '10.0.0.1',
capacityCPU: '4',
capacityMemory: '8Gi',
},
],
namespaces: [{ name: 'auth', status: 'Active', createdAt: '2026-04-30T10:00:00Z' }],
ingresses: [
{
name: 'console',
namespace: 'catalyst',
hosts: ['console.example.com'],
url: 'https://console.example.com',
},
],
httpRoutes: [
{
name: 'console',
namespace: 'catalyst',
hosts: ['console.sov.example.com'],
url: 'https://console.sov.example.com',
},
],
loadBalancers: [
{
name: 'cilium-gateway',
namespace: 'kube-system',
type: 'LoadBalancer',
clusterIP: '10.43.0.1',
externalIP: '1.2.3.4',
ports: ['443/TCP'],
},
],
storageClasses: [
{
name: 'local-path',
provisioner: 'rancher.io/local-path',
isDefault: true,
reclaimPolicy: 'Delete',
},
],
pvcs: [
{
name: 'data-keycloak',
namespace: 'auth',
storageClass: 'local-path',
capacity: '5Gi',
status: 'Bound',
},
],
}
describe('ConsoleCloudPage', () => {
it('renders all 7 sections when populated', async () => {
mockFetchOnce(POPULATED)
render(<ConsoleCloudPage />)
await waitFor(() => screen.getByTestId('cloud-nodes'))
expect(screen.getByTestId('cloud-nodes')).toBeTruthy()
expect(screen.getByTestId('cloud-namespaces')).toBeTruthy()
expect(screen.getByTestId('cloud-ingresses')).toBeTruthy()
expect(screen.getByTestId('cloud-httproutes')).toBeTruthy()
expect(screen.getByTestId('cloud-loadbalancers')).toBeTruthy()
expect(screen.getByTestId('cloud-storage-classes')).toBeTruthy()
expect(screen.getByTestId('cloud-pvcs')).toBeTruthy()
expect(screen.getByTestId('cloud-node-cp-1')).toBeTruthy()
expect(screen.getByTestId('cloud-ns-auth')).toBeTruthy()
})
it('surfaces ingress URL with external link', async () => {
mockFetchOnce(POPULATED)
render(<ConsoleCloudPage />)
await waitFor(() => screen.getByTestId('cloud-ingresses'))
expect(screen.getByText('console.example.com')).toBeTruthy()
})
it('renders the error state when the API fails', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({}),
}) as unknown as typeof fetch
render(<ConsoleCloudPage />)
await waitFor(() => {
expect(screen.getByTestId('cloud-error')).toBeTruthy()
})
})
it('still renders all sections on an empty cluster', async () => {
mockFetchOnce({
nodes: [],
namespaces: [],
ingresses: [],
httpRoutes: [],
loadBalancers: [],
storageClasses: [],
pvcs: [],
})
render(<ConsoleCloudPage />)
await waitFor(() => screen.getByTestId('cloud-nodes'))
// 7 sections present even when empty.
expect(screen.getByTestId('cloud-nodes')).toBeTruthy()
expect(screen.getByTestId('cloud-pvcs')).toBeTruthy()
})
})

View File

@ -1,408 +0,0 @@
/**
* ConsoleCloudPage Sovereign Console /console/cloud.
*
* Reads /api/v1/sovereign/cloud (issue #933). Six lists in one response:
*
* - Nodes (kubectl get nodes)
* - Namespaces
* - Ingresses (networking.k8s.io/v1)
* - HTTPRoutes (gateway.networking.k8s.io/v1)
* - LoadBalancer-typed Services
* - StorageClasses + PVCs
*
* Renders each list as its own collapsible section so an operator's
* mental model maps 1:1 to the cluster's actual surface area. The
* Console Cloud page is the operator's "where do I click to reach X"
* surface Ingresses + HTTPRoutes carry an https://hostname URL
* affordance that opens the in-cluster service in a new tab.
*/
import { useEffect, useState } from 'react'
import {
Cloud,
Server,
Box,
Globe,
Network,
HardDrive,
Database,
RefreshCw,
AlertCircle,
ExternalLink,
} from 'lucide-react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
interface NodeRow {
name: string
status: string
roles: string[]
kubeletVersion: string
os: string
arch: string
internalIP: string
externalIP?: string
capacityCPU: string
capacityMemory: string
}
interface NamespaceRow {
name: string
status: string
createdAt: string
}
interface IngressRow {
name: string
namespace: string
hosts: string[]
url?: string
}
interface LBRow {
name: string
namespace: string
type: string
clusterIP?: string
externalIP?: string
ports: string[]
}
interface SCRow {
name: string
provisioner: string
isDefault: boolean
reclaimPolicy: string
}
interface PVCRow {
name: string
namespace: string
storageClass: string
capacity: string
status: string
}
interface CloudResponse {
nodes: NodeRow[]
namespaces: NamespaceRow[]
ingresses: IngressRow[]
httpRoutes: IngressRow[]
loadBalancers: LBRow[]
storageClasses: SCRow[]
pvcs: PVCRow[]
}
type PageState =
| { status: 'loading' }
| { status: 'loaded'; data: CloudResponse }
| { status: 'error'; message: string }
async function fetchCloud(): Promise<CloudResponse> {
const tokens = loadTokens()
const resp = await fetch(`${API_BASE}/v1/sovereign/cloud`, {
headers: {
Accept: 'application/json',
...(tokens ? { Authorization: `Bearer ${tokens.accessToken}` } : {}),
},
})
if (!resp.ok) throw new Error(`status ${resp.status}`)
return (await resp.json()) as CloudResponse
}
function Section({
title,
icon,
count,
children,
testId,
}: {
title: string
icon: React.ReactNode
count: number
children: React.ReactNode
testId: string
}) {
return (
<section
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
data-testid={testId}
>
<div className="mb-3 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--color-bg)]">
{icon}
</div>
<h2 className="text-sm font-semibold text-[var(--color-text-strong)]">{title}</h2>
<span className="text-xs text-[var(--color-text-dim)]">({count})</span>
</div>
{children}
</section>
)
}
function EmptyRow({ label }: { label: string }) {
return <p className="text-xs text-[var(--color-text-dim)]">{label}</p>
}
export function ConsoleCloudPage() {
const [state, setState] = useState<PageState>({ status: 'loading' })
const reload = () => {
setState({ status: 'loading' })
fetchCloud()
.then((data) => setState({ status: 'loaded', data }))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
setState({ status: 'error', message: msg })
})
}
useEffect(() => {
reload()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div data-testid="console-cloud-page">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Cloud</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Live topology of this Sovereign cluster nodes, namespaces, ingress, storage.
</p>
</div>
<button
type="button"
onClick={reload}
className="flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 text-sm text-[var(--color-text)] hover:bg-[var(--color-bg-3)]"
data-testid="cloud-refresh"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{state.status === 'loading' ? (
<div className="flex items-center gap-2 text-sm text-[var(--color-text-dim)]" data-testid="cloud-loading">
<RefreshCw className="h-4 w-4 animate-spin" />
Loading cluster topology
</div>
) : state.status === 'error' ? (
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-8 text-center"
data-testid="cloud-error"
>
<AlertCircle className="mx-auto mb-3 h-10 w-10 text-red-400" />
<p className="text-sm font-medium text-[var(--color-text)]">Couldnt load cluster topology</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">{state.message}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Nodes */}
<Section
title="Nodes"
icon={<Server className="h-4 w-4 text-blue-400" />}
count={state.data.nodes.length}
testId="cloud-nodes"
>
{state.data.nodes.length === 0 ? (
<EmptyRow label="No nodes visible." />
) : (
<div className="space-y-2">
{state.data.nodes.map((n) => (
<div
key={n.name}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs"
data-testid={`cloud-node-${n.name}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold text-[var(--color-text-strong)]">{n.name}</span>
<span
className={`text-[10px] uppercase ${n.status === 'Ready' ? 'text-green-400' : 'text-red-400'}`}
>
{n.status}
</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-3 text-[var(--color-text-dim)]">
<span>{n.roles.join(', ') || 'worker'}</span>
<span>{n.kubeletVersion}</span>
<span>{n.internalIP}</span>
<span>
{n.capacityCPU} cores · {n.capacityMemory}
</span>
</div>
</div>
))}
</div>
)}
</Section>
{/* Namespaces */}
<Section
title="Namespaces"
icon={<Box className="h-4 w-4 text-purple-400" />}
count={state.data.namespaces.length}
testId="cloud-namespaces"
>
{state.data.namespaces.length === 0 ? (
<EmptyRow label="No namespaces visible." />
) : (
<ul className="grid grid-cols-2 gap-1 text-xs">
{state.data.namespaces.map((n) => (
<li
key={n.name}
className="truncate text-[var(--color-text)]"
data-testid={`cloud-ns-${n.name}`}
>
{n.name}{' '}
<span className="text-[10px] text-[var(--color-text-dim)]">{n.status}</span>
</li>
))}
</ul>
)}
</Section>
{/* Ingresses */}
<Section
title="Ingresses"
icon={<Globe className="h-4 w-4 text-amber-400" />}
count={state.data.ingresses.length}
testId="cloud-ingresses"
>
{state.data.ingresses.length === 0 ? (
<EmptyRow label="No ingresses." />
) : (
<ul className="space-y-1 text-xs">
{state.data.ingresses.map((i) => (
<li key={`${i.namespace}/${i.name}`} className="flex items-center gap-2">
<span className="font-medium text-[var(--color-text-strong)]">{i.name}</span>
<span className="text-[var(--color-text-dim)]">{i.namespace}</span>
{i.url ? (
<a
href={i.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto inline-flex items-center gap-1 text-[var(--color-accent)] hover:underline"
>
{i.hosts[0]}
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</li>
))}
</ul>
)}
</Section>
{/* HTTPRoutes */}
<Section
title="HTTPRoutes"
icon={<Globe className="h-4 w-4 text-green-400" />}
count={state.data.httpRoutes.length}
testId="cloud-httproutes"
>
{state.data.httpRoutes.length === 0 ? (
<EmptyRow label="No HTTPRoutes (Gateway API not in use)." />
) : (
<ul className="space-y-1 text-xs">
{state.data.httpRoutes.map((i) => (
<li key={`${i.namespace}/${i.name}`} className="flex items-center gap-2">
<span className="font-medium text-[var(--color-text-strong)]">{i.name}</span>
<span className="text-[var(--color-text-dim)]">{i.namespace}</span>
{i.url ? (
<a
href={i.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto inline-flex items-center gap-1 text-[var(--color-accent)] hover:underline"
>
{i.hosts[0]}
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</li>
))}
</ul>
)}
</Section>
{/* Load balancers */}
<Section
title="Load Balancers"
icon={<Network className="h-4 w-4 text-sky-400" />}
count={state.data.loadBalancers.length}
testId="cloud-loadbalancers"
>
{state.data.loadBalancers.length === 0 ? (
<EmptyRow label="No LoadBalancer services." />
) : (
<ul className="space-y-1 text-xs">
{state.data.loadBalancers.map((lb) => (
<li key={`${lb.namespace}/${lb.name}`} className="text-[var(--color-text)]">
<span className="font-medium text-[var(--color-text-strong)]">{lb.name}</span>{' '}
<span className="text-[var(--color-text-dim)]">
{lb.namespace} · {lb.externalIP || 'pending'} · {lb.ports.join(', ')}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Storage classes */}
<Section
title="Storage Classes"
icon={<HardDrive className="h-4 w-4 text-orange-400" />}
count={state.data.storageClasses.length}
testId="cloud-storage-classes"
>
{state.data.storageClasses.length === 0 ? (
<EmptyRow label="No storage classes." />
) : (
<ul className="space-y-1 text-xs">
{state.data.storageClasses.map((sc) => (
<li key={sc.name} className="text-[var(--color-text)]">
<span className="font-medium text-[var(--color-text-strong)]">{sc.name}</span>{' '}
<span className="text-[var(--color-text-dim)]">
{sc.provisioner}
{sc.isDefault ? ' · default' : ''}
</span>
</li>
))}
</ul>
)}
</Section>
{/* PVCs */}
<Section
title="Persistent Volume Claims"
icon={<Database className="h-4 w-4 text-rose-400" />}
count={state.data.pvcs.length}
testId="cloud-pvcs"
>
{state.data.pvcs.length === 0 ? (
<EmptyRow label="No PVCs." />
) : (
<ul className="space-y-1 text-xs">
{state.data.pvcs.map((p) => (
<li key={`${p.namespace}/${p.name}`} className="text-[var(--color-text)]">
<span className="font-medium text-[var(--color-text-strong)]">{p.name}</span>{' '}
<span className="text-[var(--color-text-dim)]">
{p.namespace} · {p.capacity} · {p.storageClass} · {p.status}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
)}
{state.status === 'loaded' &&
state.data.nodes.length === 0 &&
state.data.namespaces.length === 0 ? (
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] p-4 text-center">
<Cloud className="mx-auto mb-2 h-8 w-8 text-[var(--color-text-dim)]" />
<p className="text-xs text-[var(--color-text-dim)]">
The cluster appears empty. If you just provisioned, give Flux a minute to reconcile.
</p>
</div>
) : null}
</div>
)
}

View File

@ -1,156 +0,0 @@
/**
* ConsoleDashboardPage Sovereign Console /console/dashboard
*
* Overview cards: HelmReleases Ready, Pods Running, cert expiry.
* In Sovereign mode there is no deploymentId the cluster is identified
* by the hostname. API calls target /api/v1/sovereign/* (Agent C's
* server-side endpoints).
*
* For Phase 8b, this renders placeholder cards with wiring stubs.
* The API integration is tracked in the epic's Phase-4 tickets.
*
* Related: GitHub issue #607
*/
import { useEffect, useState } from 'react'
import { Activity, CheckCircle, Shield } from 'lucide-react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
import { SovereigntyCard } from '@/widgets/sovereignty'
interface SovereignStatus {
helmReleasesReady: number
helmReleasesTotal: number
podsRunning: number
podsTotal: number
certExpirySoonCount: number
}
type StatusState =
| { status: 'loading' }
| { status: 'loaded'; data: SovereignStatus }
| { status: 'error'; message: string }
async function fetchSovereignStatus(): Promise<SovereignStatus> {
const tokens = loadTokens()
const resp = await fetch(`${API_BASE}/v1/sovereign/status`, {
headers: {
Accept: 'application/json',
...(tokens ? { Authorization: `Bearer ${tokens.accessToken}` } : {}),
},
})
if (!resp.ok) throw new Error(`status ${resp.status}`)
return resp.json() as Promise<SovereignStatus>
}
export function ConsoleDashboardPage() {
const [state, setState] = useState<StatusState>({ status: 'loading' })
useEffect(() => {
fetchSovereignStatus()
.then((data) => setState({ status: 'loaded', data }))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
// Surface a placeholder state on API error — the console is still
// usable for navigation even if the status endpoint is unavailable.
setState({ status: 'error', message: msg })
})
}, [])
return (
<div data-testid="console-dashboard-page">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Dashboard</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Overview of your Sovereign cluster health.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatusCard
icon={<CheckCircle className="h-5 w-5 text-green-400" />}
label="HelmReleases Ready"
value={
state.status === 'loaded'
? `${state.data.helmReleasesReady} / ${state.data.helmReleasesTotal}`
: state.status === 'loading'
? '…'
: '—'
}
testId="dashboard-hr-card"
/>
<StatusCard
icon={<Activity className="h-5 w-5 text-blue-400" />}
label="Pods Running"
value={
state.status === 'loaded'
? `${state.data.podsRunning} / ${state.data.podsTotal}`
: state.status === 'loading'
? '…'
: '—'
}
testId="dashboard-pods-card"
/>
<StatusCard
icon={<Shield className="h-5 w-5 text-amber-400" />}
label="Certs Expiring Soon"
value={
state.status === 'loaded'
? String(state.data.certExpirySoonCount)
: state.status === 'loading'
? '…'
: '—'
}
testId="dashboard-certs-card"
/>
</div>
{state.status === 'error' ? (
<p
className="mt-4 text-xs text-[var(--color-text-dim)]"
data-testid="dashboard-api-note"
>
Live status unavailable ({state.message}) API integration pending.
</p>
) : null}
{/* Sovereignty cutover surface see openova-io/openova#790 / #793.
The card is self-contained: it owns its own GET /status fetch
+ SSE /events stream + POST /start trigger via useCutoverEvents.
Mounting here puts the "Achieve True Sovereignty" CTA on the
first surface a freshly-handed-over operator lands on. */}
<div className="mt-6">
<SovereigntyCard />
</div>
</div>
)
}
function StatusCard({
icon,
label,
value,
testId,
}: {
icon: React.ReactNode
label: string
value: string
testId: string
}) {
return (
<div
className="flex items-center gap-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
data-testid={testId}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--color-bg)]">
{icon}
</div>
<div>
<p className="text-xs text-[var(--color-text-dim)]">{label}</p>
<p className="mt-0.5 text-2xl font-bold tabular-nums text-[var(--color-text-strong)]">
{value}
</p>
</div>
</div>
)
}

View File

@ -1,93 +0,0 @@
/**
* ConsoleJobsPage.test.tsx issue #933.
*
* Locks in the Sovereign Console Jobs surface against
* /api/v1/sovereign/jobs:
*
* Loading state renders a spinner
* Loaded state renders one row per job, sorted started-DESC
* Error state surfaces the API error message
* Empty state surfaces a "no jobs" empty card
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import { ConsoleJobsPage } from './ConsoleJobsPage'
const ORIGINAL_FETCH = globalThis.fetch
afterEach(() => {
cleanup()
globalThis.fetch = ORIGINAL_FETCH
})
function mockFetchOnce(body: unknown, ok = true) {
globalThis.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
json: async () => body,
}) as unknown as typeof fetch
}
describe('ConsoleJobsPage', () => {
beforeEach(() => {
// jsdom's fetch is unset by default
})
it('renders rows for each returned job', async () => {
mockFetchOnce({
jobs: [
{
id: 'hr/flux-system/bp-cilium',
name: 'bp-cilium',
namespace: 'flux-system',
kind: 'HelmRelease',
status: 'succeeded',
startedAt: '2026-04-30T10:00:00Z',
finishedAt: '2026-04-30T10:05:00Z',
},
{
id: 'job/auth/keycloak-bootstrap',
name: 'keycloak-bootstrap',
namespace: 'auth',
kind: 'Job',
status: 'failed',
startedAt: '2026-04-30T09:00:00Z',
},
],
})
render(<ConsoleJobsPage />)
await waitFor(() => {
expect(screen.getByTestId('jobs-table')).toBeTruthy()
})
expect(screen.getByTestId('job-row-hr/flux-system/bp-cilium')).toBeTruthy()
expect(screen.getByTestId('job-row-job/auth/keycloak-bootstrap')).toBeTruthy()
})
it('renders the empty state when no jobs', async () => {
mockFetchOnce({ jobs: [] })
render(<ConsoleJobsPage />)
await waitFor(() => {
expect(screen.getByTestId('jobs-empty')).toBeTruthy()
})
})
it('renders the error state when the API fails', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({ error: 'in-cluster-client-unavailable' }),
}) as unknown as typeof fetch
render(<ConsoleJobsPage />)
await waitFor(() => {
expect(screen.getByTestId('jobs-error')).toBeTruthy()
})
})
})

View File

@ -1,190 +0,0 @@
/**
* ConsoleJobsPage Sovereign Console /console/jobs.
*
* Reads /api/v1/sovereign/jobs (issue #933) which surfaces THREE
* signal sources from the local cluster:
*
* 1. Flux HelmRelease Ready/NotReady transitions (the bootstrap-kit
* install path the operator just lived through)
* 2. K8s Jobs across all namespaces (Helm post-install hooks, any
* operator-authored Job/CronJob runs)
* 3. K8s Warning Events (operator-visible cluster anomalies)
*
* Sorted started-DESC. Each row carries kind / status / message so the
* operator sees their cluster's full activity feed without round-tripping
* to the mothership.
*
* Phase 8b shipped this page as a placeholder; #933 wires the actual
* data source. The PortalShell + sidebar surrounding this view stays
* unchanged.
*/
import { useEffect, useState } from 'react'
import { Clipboard, AlertCircle, CheckCircle2, RefreshCw } from 'lucide-react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
interface SovereignJob {
id: string
name: string
namespace: string
kind: string
status: string
message?: string
startedAt: string
finishedAt?: string
}
type PageState =
| { status: 'loading' }
| { status: 'loaded'; jobs: SovereignJob[] }
| { status: 'error'; message: string }
async function fetchJobs(): Promise<SovereignJob[]> {
const tokens = loadTokens()
const resp = await fetch(`${API_BASE}/v1/sovereign/jobs`, {
headers: {
Accept: 'application/json',
...(tokens ? { Authorization: `Bearer ${tokens.accessToken}` } : {}),
},
})
if (!resp.ok) throw new Error(`status ${resp.status}`)
const body = (await resp.json()) as { jobs?: SovereignJob[] }
return body.jobs ?? []
}
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
succeeded: { color: 'text-green-400', label: 'Succeeded' },
installed: { color: 'text-green-400', label: 'Installed' },
failed: { color: 'text-red-400', label: 'Failed' },
running: { color: 'text-blue-400', label: 'Running' },
pending: { color: 'text-amber-400', label: 'Pending' },
warning: { color: 'text-amber-400', label: 'Warning' },
}
function formatTimestamp(iso?: string): string {
if (!iso) return '—'
try {
const t = new Date(iso)
if (Number.isNaN(t.getTime())) return '—'
return t.toLocaleString()
} catch {
return iso
}
}
export function ConsoleJobsPage() {
const [state, setState] = useState<PageState>({ status: 'loading' })
const reload = () => {
setState({ status: 'loading' })
fetchJobs()
.then((jobs) => setState({ status: 'loaded', jobs }))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
setState({ status: 'error', message: msg })
})
}
useEffect(() => {
reload()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div data-testid="console-jobs-page">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Jobs</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Provisioning + maintenance + warning events from the local cluster.
</p>
</div>
<button
type="button"
onClick={reload}
className="flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 text-sm text-[var(--color-text)] hover:bg-[var(--color-bg-3)]"
data-testid="jobs-refresh"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{state.status === 'loading' ? (
<div className="flex items-center gap-2 text-sm text-[var(--color-text-dim)]" data-testid="jobs-loading">
<RefreshCw className="h-4 w-4 animate-spin" />
Loading jobs
</div>
) : state.status === 'error' ? (
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-8 text-center"
data-testid="jobs-error"
>
<AlertCircle className="mx-auto mb-3 h-10 w-10 text-red-400" />
<p className="text-sm font-medium text-[var(--color-text)]">Couldnt load jobs</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">{state.message}</p>
</div>
) : state.jobs.length === 0 ? (
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-8 text-center"
data-testid="jobs-empty"
>
<Clipboard className="mx-auto mb-3 h-10 w-10 text-[var(--color-text-dim)]" />
<p className="text-sm font-medium text-[var(--color-text)]">No jobs to display</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">
Provisioning + maintenance activity from the local cluster will appear here.
</p>
</div>
) : (
<div
className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)]"
data-testid="jobs-table"
>
<table className="w-full text-sm">
<thead className="bg-[var(--color-bg)] text-left text-xs uppercase text-[var(--color-text-dim)]">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Kind</th>
<th className="px-4 py-2">Namespace</th>
<th className="px-4 py-2">Status</th>
<th className="px-4 py-2">Started</th>
<th className="px-4 py-2">Message</th>
</tr>
</thead>
<tbody>
{state.jobs.map((j) => {
const badge = STATUS_BADGE[j.status] ?? { color: 'text-[var(--color-text)]', label: j.status }
return (
<tr
key={j.id}
className="border-t border-[var(--color-border)]"
data-testid={`job-row-${j.id}`}
>
<td className="px-4 py-2 font-medium text-[var(--color-text-strong)]">{j.name}</td>
<td className="px-4 py-2 text-[var(--color-text-dim)]">{j.kind}</td>
<td className="px-4 py-2 text-[var(--color-text-dim)]">{j.namespace || '—'}</td>
<td className={`px-4 py-2 font-semibold uppercase ${badge.color}`}>
<span className="inline-flex items-center gap-1">
{j.status === 'succeeded' || j.status === 'installed' ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : null}
{badge.label}
</span>
</td>
<td className="px-4 py-2 text-[var(--color-text-dim)]">{formatTimestamp(j.startedAt)}</td>
<td className="px-4 py-2 text-xs text-[var(--color-text-dim)]">
<div className="max-w-md truncate" title={j.message}>
{j.message ?? ''}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -1,128 +0,0 @@
/**
* ConsoleSettingsPage Sovereign Console /console/settings
*
* Sovereign-scoped settings surface. Reuses the same section structure
* as SettingsPage.tsx but without a deploymentId param in Sovereign
* mode the cluster is implicit from the hostname.
*
* Sections:
* 1. Organization org name, billing email
* 2. Sovereign FQDN, region (read-only)
* 3. Danger zone decommission link
*
* Phase 8b: sections are placeholders. API wiring is Phase 4 work.
*
* Related: GitHub issue #607
*/
import { Settings } from 'lucide-react'
import { DETECTED_MODE } from '@/shared/lib/detectMode'
export function ConsoleSettingsPage() {
const sovereignFQDN = DETECTED_MODE.sovereignFQDN ?? '—'
return (
<div data-testid="console-settings-page">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Settings</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Sovereign configuration and administration.
</p>
</div>
<div className="space-y-6">
{/* Organization */}
<SettingsSection
title="Organization"
description="Name and billing contact for this Sovereign."
testId="settings-org-section"
>
<FieldRow label="Sovereign FQDN" value={sovereignFQDN} testId="setting-fqdn" />
</SettingsSection>
{/* Danger zone */}
<SettingsSection
title="Danger zone"
description="Irreversible actions."
testId="settings-danger-section"
danger
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--color-text-strong)]">Decommission Sovereign</p>
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
Permanently remove all resources and data. This cannot be undone.
</p>
</div>
<a
href="/decommission"
className="rounded-lg border border-[var(--color-error)]/40 bg-[var(--color-error)]/10 px-4 py-2 text-sm font-semibold text-[var(--color-error)] transition-colors hover:bg-[var(--color-error)]/20"
data-testid="settings-decommission-link"
>
Decommission
</a>
</div>
</SettingsSection>
</div>
{/* Integration placeholder */}
<div
className="mt-8 flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
data-testid="settings-api-placeholder"
>
<Settings className="h-5 w-5 shrink-0 text-[var(--color-text-dim)]" />
<p className="text-xs text-[var(--color-text-dim)]">
Additional settings (API tokens, cloud credentials, DNS, notifications) pending API integration (Phase 4).
</p>
</div>
</div>
)
}
function SettingsSection({
title,
description,
testId,
danger,
children,
}: {
title: string
description: string
testId: string
danger?: boolean
children: React.ReactNode
}) {
return (
<section
className={`rounded-xl border p-6 ${danger ? 'border-[var(--color-error)]/30 bg-[var(--color-error)]/5' : 'border-[var(--color-border)] bg-[var(--color-bg-2)]'}`}
data-testid={testId}
>
<div className="mb-4">
<h2
className={`text-base font-semibold ${danger ? 'text-[var(--color-error)]' : 'text-[var(--color-text-strong)]'}`}
>
{title}
</h2>
<p className="mt-0.5 text-sm text-[var(--color-text-dim)]">{description}</p>
</div>
{children}
</section>
)
}
function FieldRow({
label,
value,
testId,
}: {
label: string
value: string
testId: string
}) {
return (
<div className="flex items-center justify-between py-2" data-testid={testId}>
<span className="text-sm text-[var(--color-text-dim)]">{label}</span>
<span className="text-sm font-medium text-[var(--color-text-strong)]">{value}</span>
</div>
)
}

View File

@ -1,37 +0,0 @@
/**
* ConsoleUsersPage Sovereign Console /console/users
*
* User Access editor for this Sovereign. Reuses the same visual shape
* as UserAccessListPage.tsx without the deploymentId param.
*
* Phase 8b placeholder full IAM wiring is covered by #322/#323.
*
* Related: GitHub issue #607
*/
import { Users } from 'lucide-react'
export function ConsoleUsersPage() {
return (
<div data-testid="console-users-page">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-[var(--color-text-strong)]">Users</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Manage user access and roles for this Sovereign.
</p>
</div>
{/* Placeholder — #322/#323 wires the Keycloak user list */}
<div
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-10 text-center"
data-testid="users-placeholder"
>
<Users className="mx-auto mb-3 h-10 w-10 text-[var(--color-text-dim)]" />
<p className="text-sm font-medium text-[var(--color-text)]">User Access</p>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">
Keycloak user management integration pending (#322/#323).
</p>
</div>
</div>
)
}

View File

@ -28,14 +28,18 @@ import { StepNSDelegation } from './steps/StepNSDelegation'
// that provider's control-plane SKU + worker SKU +
// count. SKU vocabulary is per-provider, which is
// why sizing lives here, not in topology.
// 4. StepCredentials — API tokens (per chosen provider) + SSH key.
// 5. StepComponents — unified marketplace catalog.
// 6. StepMarketplace — opt into Marketplace mode (issue #710 wave 3a).
// 4. StepComponents — unified marketplace catalog.
// 5. StepMarketplace — opt into Marketplace mode (issue #710 wave 3a).
// The toggle decides whether the Sovereign exposes
// a per-tenant SaaS storefront at
// marketplace.<sovereign-fqdn>.
// 7. StepDomain — pool subdomain or BYO domain (admin email
// 6. StepDomain — pool subdomain or BYO domain (admin email
// decommissioned in #762).
// 7. StepCredentials — API tokens (per chosen provider) + SSH key.
// Moved from position 4 to 7 in #973 so the
// cloud API token is the final input captured
// before Review — operators shape the
// deployment first, hand over keys last.
// 8. StepReview — single source of truth for the POST body.
// 9. StepSuccess — provisioning result.
// 10. StepNSDelegation — post-handover parent-zone NS delegation
@ -47,10 +51,10 @@ const STEPS = [
StepOrg,
StepTopology,
StepProvider,
StepCredentials,
StepComponents,
StepMarketplace,
StepDomain,
StepCredentials,
StepReview,
StepSuccess,
StepNSDelegation,

View File

@ -0,0 +1,49 @@
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { API_BASE } from '@/shared/config/urls'
interface SovereignSelf {
deploymentId: string
sovereignFQDN: string
}
const SELF_QUERY_KEY = ['sovereign', 'self'] as const
/**
* useResolvedDeploymentId resolves the active deployment id from either
* URL params (mothership tenant operator at /provision/$deploymentId/*)
* or the runtime self-discovery endpoint (Sovereign operator at clean
* URLs like /dashboard, /cloud, /jobs).
*
* Order of resolution:
* 1. URL `:deploymentId` param (when route is /provision/$deploymentId/...)
* 2. /api/v1/sovereign/self (Sovereign self-discovery)
*
* Returns: { deploymentId: string | null, isLoading: boolean }
*/
export function useResolvedDeploymentId(): {
deploymentId: string | null
isLoading: boolean
} {
const params = useParams({ strict: false }) as { deploymentId?: string }
const enabled = !params.deploymentId
const q = useQuery<SovereignSelf | null>({
queryKey: SELF_QUERY_KEY,
queryFn: async () => {
const res = await fetch(`${API_BASE}/v1/sovereign/self`, {
credentials: 'include',
})
if (res.status === 404) return null
if (!res.ok) return null
return (await res.json()) as SovereignSelf
},
enabled,
staleTime: Infinity,
retry: false,
})
return {
deploymentId: params.deploymentId ?? q.data?.deploymentId ?? null,
isLoading: enabled && q.isLoading,
}
}

View File

@ -124,8 +124,8 @@ name: bp-catalyst-platform
# otech113 2026-05-05 — chart 0.1.18 fixed the readiness-probe loop
# but every trigger immediately got 502 in <10ms (synchronous
# apiserver permission rejection). 2026-05-05.
version: 1.4.26
appVersion: 1.4.26
version: 1.4.27
appVersion: 1.4.27
description: |
Catalyst Platform — the unified Catalyst control plane umbrella chart for Catalyst-Zero.
Composes the catalyst-{ui,api}, console, admin, marketplace UI modules and the marketplace-api backend.

View File

@ -524,6 +524,23 @@ spec:
name: sovereign-fqdn
key: fqdn
optional: true
# CATALYST_SELF_DEPLOYMENT_ID — the deployment-record id this
# Sovereign was provisioned under on the contabo orchestrator.
# Read by HandleSovereignSelf (sovereign_self.go) so the
# Sovereign-side catalyst-ui can resolve /console/<page> to the
# canonical /provision/<self-id>/<page> deployment-scoped UI.
# Sourced from the sovereign-fqdn ConfigMap (key
# selfDeploymentId), stamped by the orchestrator's per-
# Sovereign overlay writer at handover. Empty on contabo and
# on freshly-provisioned Sovereigns whose handover hasn't run
# yet — HandleSovereignSelf returns 503 in that window so
# the UI shows a "waiting for handover" pill.
- name: CATALYST_SELF_DEPLOYMENT_ID
valueFrom:
configMapKeyRef:
name: sovereign-fqdn
key: selfDeploymentId
optional: true
# SOVEREIGN_LB_IP — Sovereign's load-balancer public IPv4. Used by
# the Day-2 multi-domain add-domain flow (issue #900) to
# pre-register glue records at the customer's registrar before

View File

@ -55,4 +55,16 @@ data:
# and falls back to plain set_ns. Operators on legacy Sovereigns
# without this value wired set the env via per-cluster overlay.
lbIP: {{ .Values.global.sovereignLBIP | default "" | quote }}
# selfDeploymentId — the catalyst-api deployment-record id this Sovereign
# was provisioned under on the contabo orchestrator. Stamped into the
# values block by the orchestrator's per-Sovereign overlay writer
# (products/catalyst/bootstrap/api/internal/handler/sovereign_*.go) so
# the Sovereign-side catalyst-api can answer GET /api/v1/sovereign/self
# which the SovereignConsoleRedirect React component reads to issue the
# `/console/<page>` → `/provision/<self-id>/<page>` redirect (the
# canonical pixel-byte-byte-identical UI contract).
#
# Empty until the per-Sovereign overlay carries it — UI then surfaces
# the 503 deployment-id-not-yet-stamped state instead of looping.
selfDeploymentId: {{ .Values.global.sovereignSelfDeploymentId | default "" | quote }}
{{- end }}