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:
parent
608db53a25
commit
6ec7851bc2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)]">Couldn’t 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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)]">Couldn’t 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)]">Couldn’t 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user