From 98049f3c503fef665901e5e36e461762de2625e2 Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sat, 25 Apr 2026 20:56:32 -0400 Subject: [PATCH] OPC # 0002: Improvements to Client provisioning workflows --- .../src/components/wizard/DeployWizard.tsx | 2 + .../wizard/DeploymentConfigStep.tsx | 107 ++++++++++++------ .../src/components/wizard/ReviewStep.tsx | 35 +++++- clarity.controlplane/src/index.css | 33 ++++++ .../src/types/provisioning.ts | 47 ++++++++ 5 files changed, 191 insertions(+), 33 deletions(-) diff --git a/clarity.controlplane/src/components/wizard/DeployWizard.tsx b/clarity.controlplane/src/components/wizard/DeployWizard.tsx index 48f2a63..161b054 100644 --- a/clarity.controlplane/src/components/wizard/DeployWizard.tsx +++ b/clarity.controlplane/src/components/wizard/DeployWizard.tsx @@ -5,11 +5,13 @@ import DeploymentConfigStep from './DeploymentConfigStep'; import ReviewStep from './ReviewStep'; import DeploymentLiveStep from './DeploymentLiveStep'; import { submitProvisioningJob } from '../../api/provisioningApi'; +import { defaultStackConfig } from '../../types/provisioning'; import type { ProvisioningRequest } from '../../types/provisioning'; const EMPTY: ProvisioningRequest = { clientName: '', stateCode: '', subdomain: '', adminEmail: '', siteCode: '', environment: 'fdev', tier: 'Shared', + stackConfig: defaultStackConfig('Shared'), }; const STEP_LABELS = ['Client Details', 'Deployment Config', 'Review', 'Deploying']; diff --git a/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx index e925d6a..ce3422f 100644 --- a/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx +++ b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; -import type { ProvisioningRequest, TenantEnvironment, TenantTier } from '../../types/provisioning'; +import { ALLOWED_MODES, defaultStackConfig } from '../../types/provisioning'; +import type { ComponentMode, ProvisioningRequest, StackConfig, TenantEnvironment, TenantTier } from '../../types/provisioning'; interface Props { signalParent: (state: { isValid: boolean }) => void; @@ -8,36 +9,31 @@ interface Props { } const ENVIRONMENTS: { value: TenantEnvironment; label: string; description: string }[] = [ - { value: 'fdev', label: 'Dev (fdev)', description: 'Feature development - fast provisioning, no production data.' }, - { value: 'uat', label: 'UAT', description: 'User acceptance testing - mirrors production configuration.' }, - { value: 'prod', label: 'Production', description: 'Live production environment. Full isolation enforced.' }, + { value: 'fdev', label: 'Dev (fdev)', description: 'Feature dev — fast provisioning, no production data.' }, + { value: 'uat', label: 'UAT', description: 'User acceptance testing — mirrors production config.' }, + { value: 'prod', label: 'Production', description: 'Live production. Full isolation enforced.' }, ]; -const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [ - { - value: 'Trial', - label: 'Trial', - badge: 'Sandbox', - description: 'Ephemeral all-in-one sandbox. Bundled Postgres, shared Keycloak and Vault. No persistent data guarantee.', - }, - { - value: 'Shared', - label: 'Shared', - badge: 'Standard', - description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.', - }, - { - value: 'Dedicated', - label: 'Dedicated', - badge: 'Professional', - description: 'Own sidecar containers per component (Postgres, Keycloak, Vault, MinIO) on the shared host.', - }, - { - value: 'Enterprise', - label: 'Enterprise', - badge: 'Enterprise', - description: 'Full VM isolation per component. VpsDocker or VpsBareMetal, provisioned via Pulumi.', - }, +const TIERS: { value: TenantTier; label: string; badge: string; description: string }[] = [ + { value: 'Trial', label: 'Trial', badge: 'Sandbox', description: 'Ephemeral all-in-one sandbox. No persistent data guarantee.' }, + { value: 'Shared', label: 'Shared', badge: 'Standard', description: 'Shared platform services, isolated by realm/schema/bucket.' }, + { value: 'Dedicated', label: 'Dedicated', badge: 'Professional', description: 'Own sidecar containers per component on the shared host.' }, + { value: 'Enterprise', label: 'Enterprise', badge: 'Enterprise', description: 'Full VM isolation per component, provisioned via Pulumi.' }, +]; + +const MODE_LABELS: Record = { + SharedPlatform: 'Shared Platform', + Bundled: 'Bundled (in image)', + OwnContainer: 'Own Container', + VpsDocker: 'VPS — Docker', + VpsBareMetal: 'VPS — Bare Metal', +}; + +const COMPONENTS: { key: keyof StackConfig; label: string; description: string }[] = [ + { key: 'postgres', label: 'PostgreSQL', description: 'Relational database for tenant data.' }, + { key: 'keycloak', label: 'Keycloak', description: 'Identity & access management (realms, OIDC clients).' }, + { key: 'vault', label: 'Vault', description: 'Secrets management and dynamic credentials.' }, + { key: 'minio', label: 'MinIO', description: 'Object storage (S3-compatible).' }, ]; export default function DeploymentConfigStep({ signalParent, data, onChange }: Props) { @@ -45,10 +41,22 @@ export default function DeploymentConfigStep({ signalParent, data, onChange }: P signalParent({ isValid: !!data.tier && !!data.environment }); }, [data.tier, data.environment, signalParent]); + function handleTierChange(tier: TenantTier) { + // Reset stackConfig to the default for the new tier so nothing is invalid + onChange({ tier, stackConfig: defaultStackConfig(tier) }); + } + + function handleComponentChange(key: keyof StackConfig, mode: ComponentMode) { + onChange({ stackConfig: { ...data.stackConfig, [key]: mode } }); + } + + const allowed = ALLOWED_MODES[data.tier]; + return (
-

Choose the deployment environment and infrastructure isolation tier.

+

Choose the deployment environment, isolation tier, and per-component infrastructure mode.

+ {/* ── Environment ───────────────────────────────────────── */}

Environment

{ENVIRONMENTS.map((env) => ( @@ -66,14 +74,15 @@ export default function DeploymentConfigStep({ signalParent, data, onChange }: P ))}
+ {/* ── Isolation Tier ────────────────────────────────────── */}

Isolation Tier

-
+
{TIERS.map((tier) => ( ))}
+ + {/* ── Per-Component Stack Config ────────────────────────── */} +

Stack Configuration

+

+ Defaults are set by the tier. Override individual components as needed. +

+ + + + + + + + + + {COMPONENTS.map(({ key, label, description }) => ( + + + + + + ))} + +
ComponentDescriptionMode
{label}{description} + +
); } diff --git a/clarity.controlplane/src/components/wizard/ReviewStep.tsx b/clarity.controlplane/src/components/wizard/ReviewStep.tsx index 3442af3..e9352a4 100644 --- a/clarity.controlplane/src/components/wizard/ReviewStep.tsx +++ b/clarity.controlplane/src/components/wizard/ReviewStep.tsx @@ -1,15 +1,32 @@ import { Callout, HTMLTable, Intent, Tag } from '@blueprintjs/core'; import { tenantUrl } from '../../config'; -import type { ProvisioningRequest } from '../../types/provisioning'; +import type { ComponentMode, ProvisioningRequest } from '../../types/provisioning'; interface Props { signalParent: (state: { isValid: boolean }) => void; data: ProvisioningRequest; } +const MODE_LABELS: Record = { + SharedPlatform: 'Shared Platform', + Bundled: 'Bundled (in image)', + OwnContainer: 'Own Container', + VpsDocker: 'VPS — Docker', + VpsBareMetal: 'VPS — Bare Metal', +}; + +const MODE_INTENTS: Record = { + SharedPlatform: Intent.NONE, + Bundled: Intent.PRIMARY, + OwnContainer: Intent.WARNING, + VpsDocker: Intent.DANGER, + VpsBareMetal: Intent.DANGER, +}; + export default function ReviewStep({ data }: Props) { const clientUrl = tenantUrl(data.subdomain); const containerName = data.subdomain; + const sc = data.stackConfig; return (
@@ -28,6 +45,22 @@ export default function ReviewStep({ data }: Props) { +

Stack Configuration

+ + + + Component + Mode + + + + PostgreSQL{MODE_LABELS[sc.postgres]} + Keycloak{MODE_LABELS[sc.keycloak]} + Vault{MODE_LABELS[sc.vault]} + MinIO{MODE_LABELS[sc.minio]} + + + Clicking Deploy will start a {containerName} Docker container running Clarity.Server, create a Keycloak realm, unseal Vault, and register the subdomain route in the Gateway. diff --git a/clarity.controlplane/src/index.css b/clarity.controlplane/src/index.css index 310f67a..84300b7 100644 --- a/clarity.controlplane/src/index.css +++ b/clarity.controlplane/src/index.css @@ -285,6 +285,39 @@ body { .review-table { width: 100%; font-size: 0.875rem; } .review-table td:first-child { width: 150px; color: #738091; padding-right: 1rem; padding-bottom: 0.6rem; } .review-table td:last-child { font-weight: 500; } +.review-table th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: #738091; } + +/* Stack config table in DeploymentConfigStep */ +.stack-config-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} +.stack-config-table th, +.stack-config-table td { + padding: 0.5rem 0.75rem; + border: 1px solid #dce0e6; + vertical-align: middle; +} +.stack-config-table th { + background: #f6f7f9; + font-weight: 600; + text-align: left; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #5f6b7c; +} +.stack-config-table tbody tr:hover { background: #f6f7f9; } +.stack-config-select { + width: 100%; + padding: 0.3rem 0.5rem; + border: 1px solid #b3bac5; + border-radius: 4px; + background: #fff; + font-size: 0.875rem; + cursor: pointer; +} .wizard-footer-actions { display: flex; gap: 8px; align-items: center; } diff --git a/clarity.controlplane/src/types/provisioning.ts b/clarity.controlplane/src/types/provisioning.ts index 21afc55..8a4f81a 100644 --- a/clarity.controlplane/src/types/provisioning.ts +++ b/clarity.controlplane/src/types/provisioning.ts @@ -1,6 +1,52 @@ export type TenantTier = 'Trial' | 'Shared' | 'Dedicated' | 'Enterprise'; export type TenantEnvironment = 'fdev' | 'uat' | 'prod'; +export type ComponentMode = + | 'SharedPlatform' + | 'Bundled' + | 'OwnContainer' + | 'VpsDocker' + | 'VpsBareMetal'; + +export interface StackConfig { + postgres: ComponentMode; + keycloak: ComponentMode; + vault: ComponentMode; + minio: ComponentMode; +} + +/** Mirrors StackConfig.DefaultForTier() on the backend. */ +export function defaultStackConfig(tier: TenantTier): StackConfig { + switch (tier) { + case 'Trial': + return { postgres: 'Bundled', keycloak: 'SharedPlatform', vault: 'SharedPlatform', minio: 'SharedPlatform' }; + case 'Shared': + return { postgres: 'SharedPlatform', keycloak: 'SharedPlatform', vault: 'SharedPlatform', minio: 'SharedPlatform' }; + case 'Dedicated': + return { postgres: 'OwnContainer', keycloak: 'OwnContainer', vault: 'OwnContainer', minio: 'OwnContainer' }; + case 'Enterprise': + return { postgres: 'VpsDocker', keycloak: 'VpsDocker', vault: 'VpsDocker', minio: 'VpsDocker' }; + } +} + +/** + * Allowed ComponentMode values per tier. + * Mirrors the table in StackConfig.cs. + * + * | Trial | Shared | Dedicated | Enterprise | + * SharedPlatform | ✅ | ✅ | ✅ | ✅ | + * Bundled | ✅ | ❌ | ❌ | ❌ | + * OwnContainer | ❌ | ❌ | ✅ | ✅ | + * VpsDocker | ❌ | ❌ | ❌ | ✅ | + * VpsBareMetal | ❌ | ❌ | ❌ | ✅ | + */ +export const ALLOWED_MODES: Record = { + Trial: ['SharedPlatform', 'Bundled'], + Shared: ['SharedPlatform'], + Dedicated: ['SharedPlatform', 'OwnContainer'], + Enterprise: ['SharedPlatform', 'OwnContainer', 'VpsDocker', 'VpsBareMetal'], +}; + export interface ProvisioningRequest { clientName: string; stateCode: string; @@ -9,6 +55,7 @@ export interface ProvisioningRequest { siteCode: string; environment: TenantEnvironment; tier: TenantTier; + stackConfig: StackConfig; } export interface ProvisioningJob {