merge: reorder wizard steps (domain after components), revamp review
# Conflicts: # products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx
This commit is contained in:
commit
c78041c518
@ -12,15 +12,24 @@ import { OOLogo } from '@/shared/ui/OOLogo'
|
||||
* navigate. StepSuccess is the terminal destination after StepReview launches
|
||||
* provisioning; it is not part of the visible progress, so it is not in this
|
||||
* list. Closes #174.
|
||||
*
|
||||
* Order matches WizardPage.tsx:
|
||||
* 1. Organisation (profile only — no domain/email here anymore)
|
||||
* 2. Provider (Hetzner credentials per region)
|
||||
* 3. Credentials (SSH key)
|
||||
* 4. Topology (sizing — control-plane SKU, worker SKU+count, HA)
|
||||
* 5. Components (unified marketplace catalog)
|
||||
* 6. Domain (pool/BYO + admin email)
|
||||
* 7. Review (POST body preview + launch)
|
||||
*/
|
||||
export const WIZARD_STEPS = [
|
||||
{ id: 1, label: 'Organisation', desc: 'Name, domain, contact' },
|
||||
{ id: 2, label: 'Domain', desc: 'Pool or BYO + delegation' },
|
||||
{ id: 3, label: 'Topology', desc: 'Regions and clusters' },
|
||||
{ id: 4, label: 'Provider', desc: 'Cloud provider per region' },
|
||||
{ id: 5, label: 'Credentials', desc: 'API access tokens' },
|
||||
{ id: 6, label: 'Components', desc: 'Platform building blocks' },
|
||||
{ id: 7, label: 'Review', desc: 'Confirm and provision' },
|
||||
{ id: 1, label: 'Organisation', desc: 'Industry, size, HQ, compliance' },
|
||||
{ id: 2, label: 'Provider', desc: 'Cloud provider per region' },
|
||||
{ id: 3, label: 'Credentials', desc: 'API token + SSH key' },
|
||||
{ id: 4, label: 'Topology', desc: 'Sizing and HA' },
|
||||
{ id: 5, label: 'Components', desc: 'Platform building blocks' },
|
||||
{ id: 6, label: 'Domain', desc: 'Pool or BYO + admin email' },
|
||||
{ id: 7, label: 'Review', desc: 'Confirm and provision' },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@ -10,10 +10,22 @@ import { StepComponents } from './steps/StepComponents'
|
||||
import { StepReview } from './steps/StepReview'
|
||||
import { StepSuccess } from './steps/StepSuccess'
|
||||
|
||||
// StepDomain promoted into its own step for #169 — three-mode (pool /
|
||||
// byo-manual / byo-api) UX needs more vertical space than fits inside the
|
||||
// org-profile step.
|
||||
const STEPS = [StepOrg, StepDomain, StepTopology, StepProvider, StepCredentials, StepComponents, StepReview, StepSuccess]
|
||||
// StepDomain was promoted into its own step for #169. The order has since
|
||||
// been reworked so the operator picks the platform first (provider creds,
|
||||
// SSH key, sizing, marketplace) and only then names the Sovereign in DNS:
|
||||
//
|
||||
// 1. StepOrg — org profile (name / industry / size / HQ /
|
||||
// compliance). NO email or domain capture here —
|
||||
// the admin email moved to StepDomain (it pairs
|
||||
// naturally with the Sovereign's external surface).
|
||||
// 2. StepProvider — Hetzner credentials.
|
||||
// 3. StepCredentials — SSH key (auto-generate or paste).
|
||||
// 4. StepTopology — sizing (control-plane + worker SKUs, HA flag).
|
||||
// 5. StepComponents — unified marketplace catalog.
|
||||
// 6. StepDomain — pool subdomain or BYO domain + admin email.
|
||||
// 7. StepReview — single source of truth for the POST body.
|
||||
// 8. StepSuccess — provisioning result.
|
||||
const STEPS = [StepOrg, StepProvider, StepCredentials, StepTopology, StepComponents, StepDomain, StepReview, StepSuccess]
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 32 : -32, opacity: 0 }),
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
/**
|
||||
* StepDomain — sovereign-domain capture, three-mode (pool / byo-manual /
|
||||
* byo-api). Closes #169 ([I] wizard: StepDomain — Bring Your Own Domain).
|
||||
* byo-api), plus the admin-contact email. Closes #169 ([I] wizard:
|
||||
* StepDomain — Bring Your Own Domain).
|
||||
*
|
||||
* The wizard's previous "domain" UX lived as a section inside StepOrg. With
|
||||
* BYO bringing two delegation flows (manual NS edit, registrar-API NS flip)
|
||||
* the section grew past what fits beneath the org-profile fields, so #169
|
||||
* promotes it to its own step.
|
||||
*
|
||||
* The admin-contact email also lives on this step. It used to live on
|
||||
* StepOrg next to the org name, which made the opening screen feel like a
|
||||
* sign-up form and asked for personal contact data before the operator
|
||||
* had any idea what they were configuring. Pairing the email with the
|
||||
* Sovereign FQDN matches the way it's actually used downstream — Let's
|
||||
* Encrypt registration, deployment-completion notifications, and the
|
||||
* console's "platform owner" badge are all keyed off this address.
|
||||
*
|
||||
* All three modes end at the SAME outcome: a per-Sovereign zone exists in
|
||||
* OpenOva PowerDNS so cert-manager DNS-01 + the sovereign LB can resolve.
|
||||
*
|
||||
@ -119,6 +128,8 @@ export function StepDomain() {
|
||||
{store.sovereignDomainMode === 'pool' && <PoolModeBody availability={availability} />}
|
||||
{store.sovereignDomainMode === 'byo-manual' && <ByoManualBody />}
|
||||
{store.sovereignDomainMode === 'byo-api' && <ByoApiBody />}
|
||||
|
||||
<AdminEmailField />
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
@ -127,6 +138,10 @@ function computeNextDisabled(
|
||||
s: ReturnType<typeof useWizardStore.getState>,
|
||||
availabilityStatus: import('@/shared/lib/useSubdomainAvailability').AvailabilityStatus,
|
||||
): boolean {
|
||||
// Admin email is required regardless of which domain mode the operator
|
||||
// picked. cert-manager registers it as the Let's Encrypt account email,
|
||||
// and the catalyst-api uses it for the deployment-completion notification.
|
||||
if (!isValidAdminEmail(s.orgEmail)) return true
|
||||
if (s.sovereignDomainMode === 'pool') {
|
||||
if (!s.sovereignSubdomain) return true
|
||||
return availabilityStatus !== 'available'
|
||||
@ -143,6 +158,57 @@ function computeNextDisabled(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal RFC-5321-ish email validator. Accepts the common case
|
||||
* (local@domain.tld) without trying to chase the full RFC. Empty / blank
|
||||
* strings fail; the wizard's "default" placeholder ('platform@acme.io')
|
||||
* passes on purpose so the operator can proceed without retyping when the
|
||||
* pre-filled value matches their setup.
|
||||
*/
|
||||
function isValidAdminEmail(value: string): boolean {
|
||||
const v = value.trim()
|
||||
if (!v) return false
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
|
||||
}
|
||||
|
||||
function AdminEmailField() {
|
||||
const store = useWizardStore()
|
||||
const valid = !store.orgEmail || isValidAdminEmail(store.orgEmail)
|
||||
return (
|
||||
<fieldset
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<legend style={{ fontSize: 12, fontWeight: 500, color: 'var(--wiz-text-lo)', marginBottom: 4 }}>
|
||||
Admin contact email <span style={{ fontSize: 11, color: 'var(--wiz-text-hint)' }}>required</span>
|
||||
</legend>
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-email-input"
|
||||
placeholder="platform@acme.io"
|
||||
value={store.orgEmail}
|
||||
onChange={e => store.setOrgEmail(e.target.value)}
|
||||
aria-invalid={!valid}
|
||||
autoComplete="email"
|
||||
spellCheck={false}
|
||||
style={inputStyle(valid ? 'idle' : 'error')}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--wiz-text-hint)', lineHeight: 1.5 }}>
|
||||
Used as the Let's Encrypt account email for TLS issuance, and as the
|
||||
deployment-completion notification address. We do not send marketing
|
||||
from this address.
|
||||
</span>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
id, label, sub, active, onSelect,
|
||||
}: { id: string; label: string; sub: string; active: boolean; onSelect: () => void }) {
|
||||
|
||||
@ -7,9 +7,26 @@ import { StepShell, useStepNav } from './_shared'
|
||||
/**
|
||||
* StepOrg — captures the organisation profile.
|
||||
*
|
||||
* The Sovereign-domain capture used to live as a section inside this step;
|
||||
* #169 promoted it to a dedicated StepDomain (next step) so the three-mode
|
||||
* (pool / byo-manual / byo-api) UX can render at full width.
|
||||
* Scope on this step is intentionally narrow: name, industry, size,
|
||||
* headquarters, and compliance frameworks. These five inputs are the
|
||||
* profile signal the rest of the wizard reads when proposing topology
|
||||
* sizing and component defaults.
|
||||
*
|
||||
* What is NOT captured here:
|
||||
* - Sovereign DNS surface (pool / BYO) — lives in StepDomain.
|
||||
* - Admin contact email — also moved to StepDomain. The admin email
|
||||
* pairs naturally with the deployment's external surface (cert
|
||||
* issuance, registration notifications) and asking for it on the
|
||||
* opening screen made the org profile feel like a sign-up form.
|
||||
* - "Org domain" free-text field — removed entirely. The Sovereign FQDN
|
||||
* captured in StepDomain replaces it; the orgDomain store slot is
|
||||
* preserved for backwards-compat with persisted state but is no
|
||||
* longer rendered.
|
||||
*
|
||||
* #169 promoted the Sovereign-domain capture into a dedicated step so
|
||||
* the three-mode (pool / byo-manual / byo-api) UX can render at full
|
||||
* width; this revision goes the rest of the way and strips the residual
|
||||
* domain + email inputs from the org page.
|
||||
*/
|
||||
|
||||
const INDUSTRIES = [
|
||||
@ -118,13 +135,8 @@ export function StepOrg() {
|
||||
onNext={next}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: bp === 'mobile' ? col1 : col2, gap: 14 }}>
|
||||
<SmartField required label="Organisation name" defaultValue={ORG_DEFAULTS.name} value={store.orgName} onChange={store.setOrgName} />
|
||||
<SmartField label="Domain" defaultValue={ORG_DEFAULTS.domain} value={store.orgDomain} onChange={store.setOrgDomain} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: bp === 'mobile' ? col1 : col2, gap: 14 }}>
|
||||
<SmartField label="Platform team email" defaultValue={ORG_DEFAULTS.email} value={store.orgEmail} onChange={store.setOrgEmail} type="email" />
|
||||
<SmartField label="Headquarters" defaultValue={ORG_DEFAULTS.headquarters} value={store.orgHeadquarters} onChange={store.setOrgHeadquarters} />
|
||||
<SmartField required label="Organisation name" defaultValue={ORG_DEFAULTS.name} value={store.orgName} onChange={store.setOrgName} />
|
||||
<SmartField label="Headquarters" defaultValue={ORG_DEFAULTS.headquarters} value={store.orgHeadquarters} onChange={store.setOrgHeadquarters} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: bp === 'mobile' ? col1 : col2, gap: 14 }}>
|
||||
|
||||
@ -1,9 +1,35 @@
|
||||
/**
|
||||
* StepReview — single source of truth for the POST body.
|
||||
*
|
||||
* Every section on this page corresponds 1:1 to a field that the
|
||||
* `provision()` callback below sends to `POST /v1/deployments`. If a
|
||||
* field is in the request body, it appears here; if a field is not in
|
||||
* the request body, it is NOT shown here. That contract is what makes
|
||||
* this step trustworthy as a "what am I about to launch" surface.
|
||||
*
|
||||
* Section order (matches the wizard's step order):
|
||||
* 1. Organisation — name / industry / size / HQ / compliance
|
||||
* 2. Provider — Hetzner project ID + masked token + region
|
||||
* 3. SSH — fingerprint + how the key was sourced
|
||||
* 4. Topology — control-plane SKU, worker SKU + count, HA flag
|
||||
* 5. Components — product-family summary (M / R / O counts)
|
||||
* 6. Domain — pool subdomain + FQDN OR BYO + admin email
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #10 (credential hygiene) the Hetzner
|
||||
* token and any registrar token are rendered as a fixed-length mask plus
|
||||
* the character count — never the plaintext. Same posture as the SSH
|
||||
* private key.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import {
|
||||
PROVIDER_REGIONS,
|
||||
resolveSovereignDomain,
|
||||
SOVEREIGN_POOL_DOMAINS,
|
||||
} from '@/entities/deployment/model'
|
||||
import type { CloudProvider } from '@/entities/deployment/model'
|
||||
import { TOPOLOGY_REGION_LABELS, PROVIDER_REGIONS, resolveSovereignDomain, SOVEREIGN_POOL_DOMAINS } from '@/entities/deployment/model'
|
||||
import { HETZNER_NODE_SIZES } from '@/shared/constants/hetzner'
|
||||
import { useBreakpoint } from '@/shared/lib/useBreakpoint'
|
||||
import { API_BASE, path } from '@/shared/config/urls'
|
||||
import { StepShell, useStepNav } from './_shared'
|
||||
@ -11,11 +37,11 @@ import { GROUPS } from './componentGroups'
|
||||
|
||||
/* ── Provider logos ──────────────────────────────────────────────── */
|
||||
const PROVIDER_LOGOS: Record<CloudProvider, React.ReactNode> = {
|
||||
hetzner: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#D50C2D"/><path d="M5 6h5v12H5zM14 6h5v12h-5z" fill="#fff"/></svg>,
|
||||
huawei: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#CF0A2C"/><path d="M12 5L14 9.5L19 9.5L15 12.5L17 17L12 14L7 17L9 12.5L5 9.5L10 9.5Z" fill="#fff"/></svg>,
|
||||
oci: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#F80000"/><ellipse cx={12} cy={12} rx={7} ry={4.5} fill="none" stroke="#fff" strokeWidth={1.5}/></svg>,
|
||||
aws: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#232F3E"/><path d="M7 15c2.5 1.8 7.5 1.8 10 0" stroke="#FF9900" strokeWidth={1.5} fill="none" strokeLinecap="round"/><path d="M12 8v5" stroke="#FF9900" strokeWidth={1.5} strokeLinecap="round"/><path d="M10 11l2-3 2 3" stroke="#FF9900" strokeWidth={1.2} fill="none" strokeLinecap="round"/></svg>,
|
||||
azure: <svg viewBox="0 0 24 24" width={14} height={14} style={{flexShrink:0}}><rect width={24} height={24} rx={3} fill="#0078D4"/><path d="M11 7L7 17h4l2-4 2 4h4L15 7z" fill="#fff" opacity={0.9}/></svg>,
|
||||
hetzner: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#D50C2D" /><path d="M5 6h5v12H5zM14 6h5v12h-5z" fill="#fff" /></svg>,
|
||||
huawei: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#CF0A2C" /><path d="M12 5L14 9.5L19 9.5L15 12.5L17 17L12 14L7 17L9 12.5L5 9.5L10 9.5Z" fill="#fff" /></svg>,
|
||||
oci: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#F80000" /><ellipse cx={12} cy={12} rx={7} ry={4.5} fill="none" stroke="#fff" strokeWidth={1.5} /></svg>,
|
||||
aws: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#232F3E" /><path d="M7 15c2.5 1.8 7.5 1.8 10 0" stroke="#FF9900" strokeWidth={1.5} fill="none" strokeLinecap="round" /><path d="M12 8v5" stroke="#FF9900" strokeWidth={1.5} strokeLinecap="round" /><path d="M10 11l2-3 2 3" stroke="#FF9900" strokeWidth={1.2} fill="none" strokeLinecap="round" /></svg>,
|
||||
azure: <svg viewBox="0 0 24 24" width={14} height={14} style={{ flexShrink: 0 }}><rect width={24} height={24} rx={3} fill="#0078D4" /><path d="M11 7L7 17h4l2-4 2 4h4L15 7z" fill="#fff" opacity={0.9} /></svg>,
|
||||
}
|
||||
|
||||
const PROVIDER_NAMES: Record<CloudProvider, string> = {
|
||||
@ -26,20 +52,46 @@ const PROVIDER_NAMES: Record<CloudProvider, string> = {
|
||||
azure: 'Microsoft Azure',
|
||||
}
|
||||
|
||||
const TOPOLOGY_NAMES: Record<string, string> = {
|
||||
citadel: 'CITADEL — 4 regions, 6 clusters, 6 vClusters',
|
||||
dual: 'DUAL — 2 regions, 6 clusters, 6 vClusters',
|
||||
zoned: 'ZONED — 2 regions, 4 clusters, 6 vClusters',
|
||||
compact: 'COMPACT — 2 regions, 2 clusters, 6 vClusters',
|
||||
solo: 'SOLO — 1 region, 1 cluster, 3 vClusters',
|
||||
const DOMAIN_MODE_LABELS: Record<'pool' | 'byo-manual' | 'byo-api', string> = {
|
||||
'pool': 'OpenOva pool domain',
|
||||
'byo-manual': 'Bring Your Own — manual NS',
|
||||
'byo-api': 'Bring Your Own — registrar API',
|
||||
}
|
||||
|
||||
/* ── Section shell ───────────────────────────────────────────────── */
|
||||
function Section({ title, children, style }: { title: React.ReactNode; children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
children: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<div style={{ borderRadius: 10, border: '1px solid var(--wiz-border-sub)', background: 'var(--wiz-bg-xs)', overflow: 'hidden', display: 'flex', flexDirection: 'column', ...style }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--wiz-border-sub)',
|
||||
background: 'var(--wiz-bg-xs)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '6px 14px', borderBottom: '1px solid var(--wiz-border-sub)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--wiz-text-sub)' }}>{title}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--wiz-text-sub)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>{children}</div>
|
||||
</div>
|
||||
@ -49,9 +101,37 @@ function Section({ title, children, style }: { title: React.ReactNode; children:
|
||||
/* ── Compact label/value row ─────────────────────────────────────── */
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', padding: '5px 14px', borderBottom: '1px solid var(--wiz-border-sub)' }}>
|
||||
<span style={{ width: 90, flexShrink: 0, fontSize: 10, fontWeight: 500, color: 'var(--wiz-text-sub)', lineHeight: 1.45 }}>{label}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.45, wordBreak: 'break-all' }}>{value}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '5px 14px',
|
||||
borderBottom: '1px solid var(--wiz-border-sub)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 110,
|
||||
flexShrink: 0,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--wiz-text-sub)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--wiz-text-md)',
|
||||
lineHeight: 1.45,
|
||||
wordBreak: 'break-all',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -72,65 +152,122 @@ function GroupMiniCard({ gid }: { gid: string }) {
|
||||
const hasAny = total > 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 8, padding: '8px 10px',
|
||||
border: `1px solid ${hasAny ? 'var(--wiz-border-sub)' : 'rgba(255,255,255,0.04)'}`,
|
||||
background: hasAny ? 'var(--wiz-bg-xs)' : 'transparent',
|
||||
opacity: hasAny ? 1 : 0.38,
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
{/* Line 1: product name (left) + color-coded counts (right) */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
border: `1px solid ${hasAny ? 'var(--wiz-border-sub)' : 'rgba(255,255,255,0.04)'}`,
|
||||
background: hasAny ? 'var(--wiz-bg-xs)' : 'transparent',
|
||||
opacity: hasAny ? 1 : 0.38,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: hasAny ? 'var(--wiz-text-hi)' : 'var(--wiz-text-hint)' }}>{group.productName}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
color: hasAny ? 'var(--wiz-text-hi)' : 'var(--wiz-text-hint)',
|
||||
}}
|
||||
>
|
||||
{group.productName}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
{counts.mandatory > 0 && (
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#4ADE80', background: 'rgba(74,222,128,0.1)', borderRadius: 3, padding: '1px 5px' }}>M {counts.mandatory}</span>
|
||||
<span
|
||||
title="mandatory (incl. transitive-mandatory)"
|
||||
style={{ fontSize: 9, fontWeight: 700, color: '#4ADE80', background: 'rgba(74,222,128,0.1)', borderRadius: 3, padding: '1px 5px' }}
|
||||
>
|
||||
M {counts.mandatory}
|
||||
</span>
|
||||
)}
|
||||
{counts.recommended > 0 && (
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#38BDF8', background: 'rgba(56,189,248,0.1)', borderRadius: 3, padding: '1px 5px' }}>R {counts.recommended}</span>
|
||||
<span
|
||||
title="recommended"
|
||||
style={{ fontSize: 9, fontWeight: 700, color: '#38BDF8', background: 'rgba(56,189,248,0.1)', borderRadius: 3, padding: '1px 5px' }}
|
||||
>
|
||||
R {counts.recommended}
|
||||
</span>
|
||||
)}
|
||||
{counts.optional > 0 && (
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#A78BFA', background: 'rgba(167,139,250,0.1)', borderRadius: 3, padding: '1px 5px' }}>O {counts.optional}</span>
|
||||
)}
|
||||
{total === 0 && (
|
||||
<span style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}>—</span>
|
||||
<span
|
||||
title="user-selected optional"
|
||||
style={{ fontSize: 9, fontWeight: 700, color: '#A78BFA', background: 'rgba(167,139,250,0.1)', borderRadius: 3, padding: '1px 5px' }}
|
||||
>
|
||||
O {counts.optional}
|
||||
</span>
|
||||
)}
|
||||
{total === 0 && <span style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}>—</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Line 2: conceptual product family name */}
|
||||
<div style={{ fontSize: 9, color: 'var(--wiz-text-sub)', letterSpacing: '0.02em' }}>{group.subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
function maskToken(token: string): string {
|
||||
if (!token) return '— not configured —'
|
||||
return `•••••••••••• (${token.length} chars)`
|
||||
}
|
||||
|
||||
function shortFingerprint(fp: string): string {
|
||||
if (!fp) return ''
|
||||
// Server-generated SSH fingerprints are SHA256:<base64>, ~50 chars.
|
||||
// Truncate to prefix + last 8 for the review row.
|
||||
if (fp.length <= 24) return fp
|
||||
return `${fp.slice(0, 12)}…${fp.slice(-8)}`
|
||||
}
|
||||
|
||||
function dimIfMissing(value: string | null | undefined, fallback = '— not configured —'): React.ReactNode {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span style={{ color: 'var(--wiz-text-hint)' }}>{fallback}</span>
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/* ── StepReview ──────────────────────────────────────────────────── */
|
||||
export function StepReview() {
|
||||
const store = useWizardStore()
|
||||
const { back } = useStepNav()
|
||||
const bp = useBreakpoint()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const isMobile = bp === 'mobile'
|
||||
|
||||
const topology = store.topology
|
||||
const regionLabels = topology ? (TOPOLOGY_REGION_LABELS[topology] ?? []) : []
|
||||
const regionProviders = store.regionProviders
|
||||
/* ── Derived values for display + POST body ──────────────────── */
|
||||
const sovereignFQDN = resolveSovereignDomain(store)
|
||||
|
||||
// Pool domain row label (pool mode only): the wizard stores the pool
|
||||
// *id* ('omani-works'); the row needs the human-readable domain.
|
||||
const poolDomainLabel =
|
||||
SOVEREIGN_POOL_DOMAINS.find(p => p.id === store.sovereignPoolDomain)?.domain ?? store.sovereignPoolDomain
|
||||
|
||||
// Provider region for the first slot — solo topology is a single region;
|
||||
// multi-region topologies treat slot 0 as primary. Mirrors the
|
||||
// POST-body's `region` field in `provision()`.
|
||||
const firstRegion = store.regionCloudRegions[0] ?? ''
|
||||
const firstProvider = (store.regionProviders[0] as CloudProvider | undefined) ?? null
|
||||
const firstRegionDef =
|
||||
firstProvider && firstRegion
|
||||
? PROVIDER_REGIONS[firstProvider].find(r => r.id === firstRegion)
|
||||
: undefined
|
||||
|
||||
// Component totals for the section header.
|
||||
const totalComponents = Object.values(store.componentGroups).reduce((s, ids) => s + ids.length, 0)
|
||||
|
||||
/* Include air-gap region if enabled */
|
||||
const allRegionLabels = store.airgap
|
||||
? [...regionLabels, 'AIR-GAP Region']
|
||||
: regionLabels
|
||||
// Worker SKU/count are nullable in some store states (pre-Item-2 selector
|
||||
// wiring); render a placeholder rather than letting "undefined" reach
|
||||
// the screen.
|
||||
const workerSizeDisplay = store.workerSize ?? null
|
||||
const workerCountDisplay =
|
||||
typeof store.workerCount === 'number' && Number.isFinite(store.workerCount) ? store.workerCount : null
|
||||
|
||||
/* ── Submission ─────────────────────────────────────────────── */
|
||||
async function provision() {
|
||||
setLoading(true)
|
||||
// Resolve the wizard's pool/byo state into a single FQDN that the
|
||||
// catalyst-api ProvisionRequest understands. Per provisioner.go.Validate,
|
||||
// this must be a non-empty hostname or the request is rejected.
|
||||
const sovereignFQDN = resolveSovereignDomain(store)
|
||||
// Pick the region for the first region slot (solo topology = single region).
|
||||
// For multi-region topologies this is the "primary" region; the
|
||||
// provisioner extends to the rest after the control plane is up.
|
||||
const firstRegion = store.regionCloudRegions[0] ?? 'fsn1'
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/deployments`, {
|
||||
method: 'POST',
|
||||
@ -142,33 +279,23 @@ export function StepReview() {
|
||||
// Sovereign domain — pool subdomain or BYO
|
||||
sovereignFQDN,
|
||||
sovereignDomainMode: store.sovereignDomainMode,
|
||||
sovereignPoolDomain:
|
||||
// Map the wizard's pool ID ('omani-works') to the actual domain
|
||||
// ('omani.works') by looking it up in SOVEREIGN_POOL_DOMAINS.
|
||||
// Provisioner needs the literal domain string for Dynadot calls.
|
||||
SOVEREIGN_POOL_DOMAINS.find(p => p.id === store.sovereignPoolDomain)?.domain ?? '',
|
||||
sovereignPoolDomain: poolDomainLabel,
|
||||
sovereignSubdomain: store.sovereignSubdomain,
|
||||
// Hetzner credentials + region (runtime parameter)
|
||||
hetznerToken: store.hetznerToken,
|
||||
hetznerProjectID: store.hetznerProjectId,
|
||||
region: firstRegion,
|
||||
region: firstRegion || 'fsn1',
|
||||
// Topology + sizing
|
||||
controlPlaneSize: store.controlPlaneSize,
|
||||
workerSize: store.workerSize,
|
||||
workerCount: store.workerCount,
|
||||
haEnabled: store.haEnabled,
|
||||
// SSH key — captured in StepCredentials (#160). Either auto-generated
|
||||
// by /api/v1/sshkey/generate (Mode A, private half downloaded once
|
||||
// by the browser) or pasted by the operator (Mode B). The wizard's
|
||||
// Continue button on the credentials step is gated on this string
|
||||
// being non-empty + matching the OpenSSH algorithm allow-list.
|
||||
// SSH key
|
||||
sshPublicKey: store.sshPublicKey,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
// Surface the validation error from provisioner.Validate to the user
|
||||
// rather than silently swallowing it.
|
||||
alert(`Provisioning rejected: ${data.error || 'unknown error'}`)
|
||||
setLoading(false)
|
||||
return
|
||||
@ -182,182 +309,325 @@ export function StepReview() {
|
||||
window.location.href = path('provision.html')
|
||||
}
|
||||
|
||||
const isMobile = bp === 'mobile'
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title="Ready to launch"
|
||||
description="Your full OpenOva ecosystem — infrastructure, platform stack, security, and observability — will be provisioned exactly as configured below."
|
||||
description="Every value below is exactly what we'll send to the provisioning API. Use Back to amend any section — none of the steps lose state when you navigate."
|
||||
onNext={provision}
|
||||
onBack={back}
|
||||
nextLabel={<><Zap size={13} style={{ marginRight: 5 }} />Launch OpenOva</>}
|
||||
nextLabel={
|
||||
<>
|
||||
<Zap size={13} style={{ marginRight: 5 }} />
|
||||
Launch OpenOva
|
||||
</>
|
||||
}
|
||||
nextLoading={loading}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* ── Row 1: Organisation (1fr) + Components (2fr) ── */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr 2fr',
|
||||
gap: 14,
|
||||
alignItems: 'stretch',
|
||||
}}>
|
||||
|
||||
{/* Organisation */}
|
||||
<Section title="Organisation">
|
||||
<Row label="Name" value={store.orgName} />
|
||||
<Row label="Domain" value={store.orgDomain} />
|
||||
<Row label="Email" value={store.orgEmail} />
|
||||
<Row label="Industry" value={store.orgIndustry} />
|
||||
<Row label="Size" value={store.orgSize} />
|
||||
<Row label="HQ" value={store.orgHeadquarters} />
|
||||
<Row label="Compliance" value={
|
||||
store.orgCompliance.length > 0
|
||||
? <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{store.orgCompliance.map(t => (
|
||||
<span key={t} style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: 'rgba(56,189,248,0.1)', border: '1px solid rgba(56,189,248,0.2)', color: '#38BDF8' }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
: <span style={{ color: 'var(--wiz-text-hint)' }}>None</span>
|
||||
} />
|
||||
</Section>
|
||||
|
||||
{/* Components — 3×3 grid of group mini-cards */}
|
||||
<Section title={`Components · ${totalComponents} selected`}>
|
||||
<div style={{ padding: '10px 14px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||
{GROUPS.map(g => <GroupMiniCard key={g.id} gid={g.id} />)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* ── Row 2: Infrastructure — full width ── */}
|
||||
<Section title={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span>Infrastructure</span>
|
||||
{topology && (
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--wiz-text-md)', letterSpacing: 0 }}>
|
||||
{TOPOLOGY_NAMES[topology]}
|
||||
</span>
|
||||
)}
|
||||
{store.airgap && (
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#F59E0B', background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.25)', borderRadius: 3, padding: '1px 6px', letterSpacing: '0.04em' }}>AIR-GAP</span>
|
||||
)}
|
||||
</span>
|
||||
}>
|
||||
{/* Node sizing — control plane + worker SKU + worker count */}
|
||||
{(() => {
|
||||
const cp = HETZNER_NODE_SIZES.find(s => s.id === store.controlPlaneSize)
|
||||
const wk = HETZNER_NODE_SIZES.find(s => s.id === store.workerSize)
|
||||
const regionCount = allRegionLabels.length || 1
|
||||
const cpHourly = cp?.priceHour ?? 0
|
||||
const wkHourly = (wk?.priceHour ?? 0) * store.workerCount
|
||||
const totalHour = (cpHourly + wkHourly) * regionCount
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--wiz-border-sub)',
|
||||
display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center',
|
||||
}}>
|
||||
{/* Control plane chip */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
padding: '6px 10px', borderRadius: 7,
|
||||
border: '1px solid rgba(56,189,248,0.25)',
|
||||
background: 'rgba(56,189,248,0.05)',
|
||||
}}>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--wiz-accent)' }}>Control plane</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--wiz-text-hi)' }}>
|
||||
{cp ? `${cp.label} · ${cp.vcpu} vCPU · ${cp.ram} GB` : store.controlPlaneSize}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
{store.haEnabled ? '3 nodes (HA)' : '1 node'} per region
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Worker chip */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
padding: '6px 10px', borderRadius: 7,
|
||||
border: store.workerCount > 0 ? '1px solid rgba(167,139,250,0.3)' : '1px dashed var(--wiz-border-sub)',
|
||||
background: store.workerCount > 0 ? 'rgba(167,139,250,0.05)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: store.workerCount > 0 ? '#A78BFA' : 'var(--wiz-text-hint)' }}>Workers</span>
|
||||
{store.workerCount > 0 ? (
|
||||
<>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--wiz-text-hi)' }}>
|
||||
{wk ? `${wk.label} · ${wk.vcpu} vCPU · ${wk.ram} GB` : store.workerSize}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
{store.workerCount} per region
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--wiz-text-sub)' }}>
|
||||
None — control plane carries workloads
|
||||
{/* ── 1. Organisation ──────────────────────────────────── */}
|
||||
<Section title="Organisation">
|
||||
<Row label="Name" value={dimIfMissing(store.orgName)} />
|
||||
<Row label="Industry" value={dimIfMissing(store.orgIndustry)} />
|
||||
<Row label="Size" value={dimIfMissing(store.orgSize)} />
|
||||
<Row label="HQ" value={dimIfMissing(store.orgHeadquarters)} />
|
||||
<Row
|
||||
label="Compliance"
|
||||
value={
|
||||
store.orgCompliance.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{store.orgCompliance.map(t => (
|
||||
<span
|
||||
key={t}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
background: 'rgba(56,189,248,0.1)',
|
||||
border: '1px solid rgba(56,189,248,0.2)',
|
||||
color: '#38BDF8',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost rollup */}
|
||||
<div style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2,
|
||||
}}>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--wiz-text-hint)' }}>Compute total</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--wiz-text-hi)', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
€{totalHour.toFixed(3)}/hr
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--wiz-text-sub)' }}>
|
||||
across {regionCount} region{regionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Region cards — flex row, all equal height, 1–5 cards */}
|
||||
<div style={{ padding: '10px 14px', display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||
{allRegionLabels.map((rl, i) => {
|
||||
const isAirgap = store.airgap && i === regionLabels.length
|
||||
const p = regionProviders[i] as CloudProvider | undefined
|
||||
const cloudRegionId = store.regionCloudRegions[i]
|
||||
const cloudRegionDef = p && cloudRegionId ? PROVIDER_REGIONS[p].find(r => r.id === cloudRegionId) : undefined
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: isMobile ? '100%' : 0,
|
||||
borderRadius: 8, padding: '8px 10px',
|
||||
border: `1px solid ${isAirgap ? 'rgba(245,158,11,0.3)' : 'var(--wiz-border-sub)'}`,
|
||||
background: isAirgap ? 'rgba(245,158,11,0.03)' : 'var(--wiz-bg-xs)',
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isAirgap ? '#F59E0B' : 'var(--wiz-accent)', width: 12, flexShrink: 0 }}>{i + 1}</span>
|
||||
{isAirgap && <span style={{ fontSize: 8, fontWeight: 700, color: '#F59E0B', background: 'rgba(245,158,11,0.12)', borderRadius: 3, padding: '0 4px' }}>AIR-GAP</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--wiz-text-lo)', lineHeight: 1.3 }}>{rl}</div>
|
||||
{p && (
|
||||
<div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{PROVIDER_LOGOS[p]}
|
||||
<span style={{ fontSize: 10, color: 'var(--wiz-text-sub)' }}>{PROVIDER_NAMES[p]}</span>
|
||||
</div>
|
||||
{cloudRegionDef && (
|
||||
<div style={{ fontSize: 9, color: 'var(--wiz-text-hint)' }}>{cloudRegionDef.label} — {cloudRegionDef.location}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!p && <div style={{ fontSize: 10, color: 'var(--wiz-text-hint)', marginTop: 4 }}>Not configured</div>}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>None selected</span>
|
||||
)
|
||||
})}
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. Provider ──────────────────────────────────────── */}
|
||||
<Section title="Cloud Provider">
|
||||
<Row
|
||||
label="Provider"
|
||||
value={
|
||||
firstProvider ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{PROVIDER_LOGOS[firstProvider]}
|
||||
{PROVIDER_NAMES[firstProvider]}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>— not selected —</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Region"
|
||||
value={
|
||||
firstRegionDef ? (
|
||||
<span>
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>{firstRegionDef.id}</code>
|
||||
<span style={{ color: 'var(--wiz-text-sub)' }}> · {firstRegionDef.location}</span>
|
||||
</span>
|
||||
) : (
|
||||
dimIfMissing(firstRegion)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row label="Project ID" value={dimIfMissing(store.hetznerProjectId)} />
|
||||
<Row
|
||||
label="API token"
|
||||
value={
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: 'var(--wiz-text-md)' }}>
|
||||
{maskToken(store.hetznerToken)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Token validated"
|
||||
value={
|
||||
store.credentialValidated ? (
|
||||
<span style={{ color: '#4ADE80' }}>Yes</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>No — provisioner will re-check at apply time</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 3. SSH ───────────────────────────────────────────── */}
|
||||
<Section title="SSH Access">
|
||||
<Row
|
||||
label="Source"
|
||||
value={
|
||||
store.sshKeyGeneratedThisSession
|
||||
? (
|
||||
<span>
|
||||
Auto-generated this session{' '}
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>(private key downloaded once)</span>
|
||||
</span>
|
||||
)
|
||||
: store.sshPublicKey
|
||||
? 'Pasted by operator'
|
||||
: <span style={{ color: 'var(--wiz-text-hint)' }}>— no key configured —</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Fingerprint"
|
||||
value={
|
||||
store.sshFingerprint ? (
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
|
||||
{shortFingerprint(store.sshFingerprint)}
|
||||
</code>
|
||||
) : store.sshPublicKey ? (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>
|
||||
not pre-computed — server will derive at apply time
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>—</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 4. Topology ──────────────────────────────────────── */}
|
||||
<Section
|
||||
title={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>Topology</span>
|
||||
{store.haEnabled && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
color: '#4ADE80',
|
||||
background: 'rgba(74,222,128,0.12)',
|
||||
border: '1px solid rgba(74,222,128,0.25)',
|
||||
borderRadius: 3,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
HA
|
||||
</span>
|
||||
)}
|
||||
{store.airgap && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
border: '1px solid rgba(245,158,11,0.25)',
|
||||
borderRadius: 3,
|
||||
padding: '1px 6px',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
AIR-GAP
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row label="Template" value={dimIfMissing(store.topology)} />
|
||||
<Row label="Control plane SKU" value={dimIfMissing(store.controlPlaneSize)} />
|
||||
<Row
|
||||
label="Worker SKU"
|
||||
value={dimIfMissing(workerSizeDisplay, '— not yet selected —')}
|
||||
/>
|
||||
<Row
|
||||
label="Worker count"
|
||||
value={
|
||||
workerCountDisplay === null
|
||||
? <span style={{ color: 'var(--wiz-text-hint)' }}>— not yet selected —</span>
|
||||
: workerCountDisplay
|
||||
}
|
||||
/>
|
||||
<Row label="HA" value={store.haEnabled ? 'Enabled' : 'Disabled'} />
|
||||
</Section>
|
||||
|
||||
{/* ── 5. Components ────────────────────────────────────── */}
|
||||
<Section title={`Components · ${totalComponents} selected`}>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(3, 1fr)',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{GROUPS.map(g => <GroupMiniCard key={g.id} gid={g.id} />)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderTop: '1px solid var(--wiz-border-sub)',
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 10,
|
||||
color: 'var(--wiz-text-sub)',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: '#4ADE80', fontWeight: 700 }}>M</span> mandatory (incl. transitive)
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: '#38BDF8', fontWeight: 700 }}>R</span> recommended
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: '#A78BFA', fontWeight: 700 }}>O</span> user-selected optional
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Privacy note */}
|
||||
<div style={{ borderRadius: 8, padding: '9px 12px', background: 'rgba(56,189,248,0.04)', border: '1px solid rgba(56,189,248,0.1)' }}>
|
||||
{/* ── 6. Domain ────────────────────────────────────────── */}
|
||||
<Section title="Domain">
|
||||
<Row label="Mode" value={DOMAIN_MODE_LABELS[store.sovereignDomainMode]} />
|
||||
{store.sovereignDomainMode === 'pool' ? (
|
||||
<>
|
||||
<Row
|
||||
label="Subdomain"
|
||||
value={
|
||||
store.sovereignSubdomain ? (
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
|
||||
{store.sovereignSubdomain}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>— not chosen —</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Pool domain"
|
||||
value={
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
|
||||
{poolDomainLabel}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Row
|
||||
label="BYO domain"
|
||||
value={
|
||||
store.sovereignByoDomain ? (
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
|
||||
{store.sovereignByoDomain}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>— not entered —</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{store.sovereignDomainMode === 'byo-api' && (
|
||||
<>
|
||||
<Row label="Registrar" value={dimIfMissing(store.registrarType)} />
|
||||
<Row
|
||||
label="Registrar token"
|
||||
value={
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: 'var(--wiz-text-md)' }}>
|
||||
{maskToken(store.registrarToken)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Token validated"
|
||||
value={
|
||||
store.registrarTokenValidated ? (
|
||||
<span style={{ color: '#4ADE80' }}>Yes</span>
|
||||
) : (
|
||||
<span style={{ color: '#F87171' }}>
|
||||
No — return to the Domain step to validate before launch
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Row
|
||||
label="Resolved FQDN"
|
||||
value={
|
||||
sovereignFQDN ? (
|
||||
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: '#38BDF8' }}>
|
||||
console.{sovereignFQDN}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--wiz-text-hint)' }}>— not yet resolvable —</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row label="Admin email" value={dimIfMissing(store.orgEmail)} />
|
||||
</Section>
|
||||
|
||||
{/* ── Privacy note ─────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '9px 12px',
|
||||
background: 'rgba(56,189,248,0.04)',
|
||||
border: '1px solid rgba(56,189,248,0.1)',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 11, color: 'var(--wiz-text-sub)', margin: 0, lineHeight: 1.6 }}>
|
||||
Provisioning runs entirely within your cloud account. OpenOva never stores your credentials or accesses your infrastructure after this session.
|
||||
Provisioning runs entirely within your cloud account. OpenOva never stores your credentials or accesses
|
||||
your infrastructure after this session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user