OPC # 0002: Improvements to Client provisioning workflows
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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<ComponentMode, string> = {
|
||||
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 (
|
||||
<div className="wizard-step">
|
||||
<p className="step-description">Choose the deployment environment and infrastructure isolation tier.</p>
|
||||
<p className="step-description">Choose the deployment environment, isolation tier, and per-component infrastructure mode.</p>
|
||||
|
||||
{/* ── Environment ───────────────────────────────────────── */}
|
||||
<h4 style={{ marginBottom: '0.5rem' }}>Environment</h4>
|
||||
<div className="tier-cards" style={{ marginBottom: '1.5rem' }}>
|
||||
{ENVIRONMENTS.map((env) => (
|
||||
@@ -66,14 +74,15 @@ export default function DeploymentConfigStep({ signalParent, data, onChange }: P
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Isolation Tier ────────────────────────────────────── */}
|
||||
<h4 style={{ marginBottom: '0.5rem' }}>Isolation Tier</h4>
|
||||
<div className="tier-cards">
|
||||
<div className="tier-cards" style={{ marginBottom: '1.5rem' }}>
|
||||
{TIERS.map((tier) => (
|
||||
<button
|
||||
key={tier.value}
|
||||
type="button"
|
||||
className={`tier-card${data.tier === tier.value ? ' selected' : ''}`}
|
||||
onClick={() => onChange({ tier: tier.value })}
|
||||
onClick={() => handleTierChange(tier.value)}
|
||||
>
|
||||
<div className="tier-card-header">
|
||||
<span className="tier-card-label">{tier.label}</span>
|
||||
@@ -83,6 +92,40 @@ export default function DeploymentConfigStep({ signalParent, data, onChange }: P
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Per-Component Stack Config ────────────────────────── */}
|
||||
<h4 style={{ marginBottom: '0.25rem' }}>Stack Configuration</h4>
|
||||
<p style={{ fontSize: '0.85rem', color: '#5f6b7c', marginBottom: '0.75rem' }}>
|
||||
Defaults are set by the tier. Override individual components as needed.
|
||||
</p>
|
||||
<table className="stack-config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
<th>Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPONENTS.map(({ key, label, description }) => (
|
||||
<tr key={key}>
|
||||
<td><strong>{label}</strong></td>
|
||||
<td style={{ color: '#5f6b7c', fontSize: '0.85rem' }}>{description}</td>
|
||||
<td>
|
||||
<select
|
||||
className="stack-config-select"
|
||||
value={data.stackConfig[key]}
|
||||
onChange={(e) => handleComponentChange(key, e.target.value as ComponentMode)}
|
||||
>
|
||||
{allowed.map((mode) => (
|
||||
<option key={mode} value={mode}>{MODE_LABELS[mode]}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ComponentMode, string> = {
|
||||
SharedPlatform: 'Shared Platform',
|
||||
Bundled: 'Bundled (in image)',
|
||||
OwnContainer: 'Own Container',
|
||||
VpsDocker: 'VPS — Docker',
|
||||
VpsBareMetal: 'VPS — Bare Metal',
|
||||
};
|
||||
|
||||
const MODE_INTENTS: Record<ComponentMode, Intent> = {
|
||||
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 (
|
||||
<div className="wizard-step">
|
||||
@@ -28,6 +45,22 @@ export default function ReviewStep({ data }: Props) {
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
|
||||
<h4 style={{ margin: '1.25rem 0 0.5rem' }}>Stack Configuration</h4>
|
||||
<HTMLTable striped bordered className="review-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>PostgreSQL</td><td><Tag intent={MODE_INTENTS[sc.postgres]} minimal round>{MODE_LABELS[sc.postgres]}</Tag></td></tr>
|
||||
<tr><td>Keycloak</td><td><Tag intent={MODE_INTENTS[sc.keycloak]} minimal round>{MODE_LABELS[sc.keycloak]}</Tag></td></tr>
|
||||
<tr><td>Vault</td><td><Tag intent={MODE_INTENTS[sc.vault]} minimal round>{MODE_LABELS[sc.vault]}</Tag></td></tr>
|
||||
<tr><td>MinIO</td><td><Tag intent={MODE_INTENTS[sc.minio]} minimal round>{MODE_LABELS[sc.minio]}</Tag></td></tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
|
||||
<Callout intent={Intent.WARNING} title="This provisions real infrastructure" style={{ marginTop: '1.5rem' }}>
|
||||
Clicking Deploy will start a <code>{containerName}</code> Docker container running Clarity.Server,
|
||||
create a Keycloak realm, unseal Vault, and register the subdomain route in the Gateway.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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<TenantTier, ComponentMode[]> = {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user