OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Drawer, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core';
|
||||
import { html as diff2htmlHtml } from 'diff2html';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { getCommitDetail, type CommitDetail } from '../api/opcApi';
|
||||
|
||||
interface Props {
|
||||
hash: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitCommitDrawer({ hash, onClose }: Props) {
|
||||
const [detail, setDetail] = useState<CommitDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const diffRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) { setDetail(null); setError(null); return; }
|
||||
setLoading(true); setDetail(null); setError(null);
|
||||
getCommitDetail(hash)
|
||||
.then(setDetail)
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hash]);
|
||||
|
||||
// After diff HTML is injected, run highlight.js over code blocks
|
||||
useEffect(() => {
|
||||
if (detail && diffRef.current) {
|
||||
diffRef.current.querySelectorAll<HTMLElement>('code[class]').forEach(el => {
|
||||
hljs.highlightElement(el);
|
||||
});
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
const combinedPatch = detail?.files.map(f => f.patch).join('\n') ?? '';
|
||||
const diffHtml = combinedPatch
|
||||
? diff2htmlHtml(combinedPatch, {
|
||||
drawFileList: true,
|
||||
matching: 'lines',
|
||||
outputFormat: 'line-by-line',
|
||||
renderNothingWhenEmpty: false,
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={!!hash}
|
||||
onClose={onClose}
|
||||
title={detail ? (
|
||||
<span className="git-drawer-title">
|
||||
<code className="git-drawer-hash">{detail.shortHash}</code>
|
||||
<span className="git-drawer-subject">{detail.subject}</span>
|
||||
</span>
|
||||
) : 'Commit Diff'}
|
||||
size="70%"
|
||||
position="right"
|
||||
className="git-commit-drawer"
|
||||
>
|
||||
<div className="git-drawer-body">
|
||||
{loading && <NonIdealState icon={<Spinner size={24} />} title="Loading diff…" />}
|
||||
{error && <NonIdealState icon="error" intent={Intent.DANGER} title="Failed to load commit" description={error} />}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{/* Metadata bar */}
|
||||
<div className="git-commit-meta-bar">
|
||||
<div className="git-commit-meta-left">
|
||||
<Tooltip content="Copy full hash">
|
||||
<code
|
||||
className="git-commit-hash-chip"
|
||||
onClick={() => navigator.clipboard.writeText(detail.hash)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{detail.shortHash}
|
||||
</code>
|
||||
</Tooltip>
|
||||
<span className="git-commit-author">{detail.author}</span>
|
||||
<span className="git-commit-date">{detail.date}</span>
|
||||
</div>
|
||||
<div className="git-commit-meta-right">
|
||||
<Tag intent={Intent.SUCCESS} minimal round icon="add">
|
||||
+{detail.files.reduce((a, f) => a + f.additions, 0)}
|
||||
</Tag>
|
||||
<Tag intent={Intent.DANGER} minimal round icon="remove">
|
||||
-{detail.files.reduce((a, f) => a + f.deletions, 0)}
|
||||
</Tag>
|
||||
<Tag minimal round>{detail.files.length} file{detail.files.length !== 1 ? 's' : ''}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit body if multiline */}
|
||||
{detail.body.trim() !== detail.subject.trim() && (
|
||||
<pre className="git-commit-body">{detail.body.trim()}</pre>
|
||||
)}
|
||||
|
||||
{/* Diff */}
|
||||
{diffHtml
|
||||
? <div ref={diffRef} className="git-diff-container" dangerouslySetInnerHTML={{ __html: diffHtml }} />
|
||||
: <NonIdealState icon="git-commit" title="No diff" description="This commit has no file changes." />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && !detail && hash && (
|
||||
<NonIdealState icon={<Spinner size={20} />} title="Loading…" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="git-drawer-footer">
|
||||
<Button text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Callout, Intent, Tag } from '@blueprintjs/core';
|
||||
import { getImageStatus, type ImageBuildStatus } from '../api/provisioningApi';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export default function ImageBuildPanel() {
|
||||
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getImageStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-scroll log panel
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [logs]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (building) return;
|
||||
setBuilding(true);
|
||||
setOpen(true);
|
||||
setLogs([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// POST /api/image/build — the response body IS the SSE stream
|
||||
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setError(`Build failed to start: ${res.statusText}`);
|
||||
setBuilding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
|
||||
for (const chunk of parts) {
|
||||
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const msg = JSON.parse(dataLine);
|
||||
if (msg.done) {
|
||||
// Build finished — refresh status
|
||||
getImageStatus().then(setStatus).catch(() => {});
|
||||
} else if (typeof msg.line === 'string') {
|
||||
setLogs((prev) => [...prev.slice(-1000), msg.line]);
|
||||
}
|
||||
} catch { /* ignore non-JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error during build');
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastBuilt = status?.builtAt
|
||||
? new Date(status.builtAt).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Button
|
||||
icon="build"
|
||||
intent={Intent.WARNING}
|
||||
loading={building}
|
||||
onClick={handleBuild}
|
||||
text="Build Image"
|
||||
/>
|
||||
{!building && (
|
||||
<Tag minimal intent={Intent.NONE} style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}>
|
||||
{status?.imageName ?? 'clarity-server:latest'} · last built {lastBuilt}
|
||||
</Tag>
|
||||
)}
|
||||
{building && (
|
||||
<Tag minimal intent={Intent.WARNING}>Building…</Tag>
|
||||
)}
|
||||
{logs.length > 0 && !building && (
|
||||
<Button
|
||||
icon={open ? 'chevron-up' : 'chevron-down'}
|
||||
minimal
|
||||
small
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
text={open ? 'Hide log' : 'Show log'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Callout intent={Intent.DANGER} compact>{error}</Callout>
|
||||
)}
|
||||
|
||||
{open && logs.length > 0 && (
|
||||
<div
|
||||
ref={logRef}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
background: '#111',
|
||||
color: '#d4d4d4',
|
||||
padding: '0.6rem 0.8rem',
|
||||
borderRadius: '4px',
|
||||
height: '220px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{logs.map((l, i) => <div key={i}>{l}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Callout, FormGroup, InputGroup, Intent } from '@blueprintjs/core';
|
||||
import { CLARITY_DOMAIN } from '../../config';
|
||||
import type { ProvisioningRequest } from '../../types/provisioning';
|
||||
|
||||
interface Props {
|
||||
signalParent: (state: { isValid: boolean }) => void;
|
||||
data: ProvisioningRequest;
|
||||
onChange: (updated: Partial<ProvisioningRequest>) => void;
|
||||
}
|
||||
|
||||
function deriveSubdomain(environment: string, siteCode: string): string {
|
||||
const env = environment.toLowerCase().replace(/[^a-z]/g, '');
|
||||
const site = siteCode.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (!env || !site) return '';
|
||||
return `${env}-app-clarity-${site}`;
|
||||
}
|
||||
|
||||
export default function ClientDetailsStep({ signalParent, data, onChange }: Props) {
|
||||
// Keep a flag so we can show the user the derived name immediately
|
||||
const derivedSubdomain = deriveSubdomain(data.environment, data.siteCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (derivedSubdomain !== data.subdomain) {
|
||||
onChange({ subdomain: derivedSubdomain });
|
||||
}
|
||||
}, [derivedSubdomain]);
|
||||
|
||||
const isValid =
|
||||
data.clientName.trim().length > 0 &&
|
||||
data.stateCode.trim().length === 2 &&
|
||||
data.siteCode.trim().length > 0 &&
|
||||
data.adminEmail.includes('@') &&
|
||||
data.subdomain.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
signalParent({ isValid });
|
||||
}, [isValid, signalParent]);
|
||||
|
||||
return (
|
||||
<div className="wizard-step">
|
||||
<p className="step-description">Enter the details for the new client tenant.</p>
|
||||
|
||||
<FormGroup label="Client Name" labelFor="clientName" labelInfo="(required)">
|
||||
<InputGroup
|
||||
id="clientName"
|
||||
placeholder="e.g. Florida Commerce"
|
||||
value={data.clientName}
|
||||
onValueChange={(v) => onChange({ clientName: v })}
|
||||
large
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="State Code" labelFor="stateCode" labelInfo="(required, 2 letters)">
|
||||
<InputGroup
|
||||
id="stateCode"
|
||||
placeholder="FL"
|
||||
maxLength={2}
|
||||
value={data.stateCode}
|
||||
onValueChange={(v) => onChange({ stateCode: v.toUpperCase() })}
|
||||
large
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Site Code"
|
||||
labelFor="siteCode"
|
||||
labelInfo="(required)"
|
||||
helperText="Unique numeric identifier, e.g. 01000014"
|
||||
>
|
||||
<InputGroup
|
||||
id="siteCode"
|
||||
placeholder="01000014"
|
||||
value={data.siteCode}
|
||||
onValueChange={(v) => onChange({ siteCode: v.replace(/[^0-9]/g, '') })}
|
||||
large
|
||||
style={{ maxWidth: 200 }}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Day-Zero Admin Email" labelFor="adminEmail" labelInfo="(required)">
|
||||
<InputGroup
|
||||
id="adminEmail"
|
||||
type="email"
|
||||
placeholder="director@commerce.fl.gov"
|
||||
value={data.adminEmail}
|
||||
onValueChange={(v) => onChange({ adminEmail: v })}
|
||||
large
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{data.subdomain && (
|
||||
<Callout intent={Intent.PRIMARY} style={{ marginTop: '0.5rem' }}>
|
||||
<strong>Container name:</strong> <code>{data.subdomain}</code>
|
||||
<br />
|
||||
<strong>Client URL:</strong> <code>{data.subdomain}.{CLARITY_DOMAIN}</code>
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Intent } from '@blueprintjs/core';
|
||||
import ClientDetailsStep from './ClientDetailsStep';
|
||||
import DeploymentConfigStep from './DeploymentConfigStep';
|
||||
import ReviewStep from './ReviewStep';
|
||||
import DeploymentLiveStep from './DeploymentLiveStep';
|
||||
import { submitProvisioningJob } from '../../api/provisioningApi';
|
||||
import type { ProvisioningRequest } from '../../types/provisioning';
|
||||
|
||||
const EMPTY: ProvisioningRequest = {
|
||||
clientName: '', stateCode: '', subdomain: '', adminEmail: '',
|
||||
siteCode: '', environment: 'fdev', tier: 'Shared',
|
||||
};
|
||||
|
||||
const STEP_LABELS = ['Client Details', 'Deployment Config', 'Review', 'Deploying'];
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DeployWizard({ onClose }: Props) {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [formData, setFormData] = useState<ProvisioningRequest>(EMPTY);
|
||||
const [step0Valid, setStep0Valid] = useState(false);
|
||||
const [step1Valid, setStep1Valid] = useState(true); // tier has a default
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (u: Partial<ProvisioningRequest>) =>
|
||||
setFormData((p) => ({ ...p, ...u }));
|
||||
|
||||
const handleDeploy = async () => {
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const id = await submitProvisioningJob(formData);
|
||||
setJobId(id);
|
||||
setActiveStep(3);
|
||||
} catch (e: unknown) {
|
||||
setSubmitError(e instanceof Error ? e.message : 'Deployment failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = activeStep > 0 && !jobId;
|
||||
const isLastFormStep = activeStep === 2;
|
||||
|
||||
return (
|
||||
<div className="wizard-page">
|
||||
{/* Header */}
|
||||
<div className="wizard-page-header">
|
||||
<div className="wizard-page-title">
|
||||
<h2>Deploy New Client</h2>
|
||||
<p>Provision a new Clarity tenant from scratch.</p>
|
||||
</div>
|
||||
{!jobId && (
|
||||
<Button minimal icon="cross" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step progress */}
|
||||
<div className="wizard-progress">
|
||||
{STEP_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`wizard-progress-step${i === activeStep ? ' active' : i < activeStep ? ' done' : ''}`}
|
||||
>
|
||||
<div className="wizard-progress-dot">{i < activeStep ? '✓' : i + 1}</div>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="wizard-page-body">
|
||||
{activeStep === 0 && (
|
||||
<ClientDetailsStep
|
||||
data={formData}
|
||||
onChange={handleChange}
|
||||
signalParent={({ isValid }) => setStep0Valid(isValid)}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 1 && (
|
||||
<DeploymentConfigStep
|
||||
data={formData}
|
||||
onChange={handleChange}
|
||||
signalParent={({ isValid }) => setStep1Valid(isValid)}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 2 && (
|
||||
<ReviewStep data={formData} signalParent={() => {}} />
|
||||
)}
|
||||
{activeStep === 3 && jobId && (
|
||||
<DeploymentLiveStep jobId={jobId} subdomain={formData.subdomain} />
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<p className="wizard-error" style={{ marginTop: 12 }}>{submitError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer nav */}
|
||||
{activeStep < 3 && (
|
||||
<div className="wizard-page-footer">
|
||||
{canGoBack && (
|
||||
<Button text="Back" minimal icon="arrow-left" onClick={() => setActiveStep((s) => s - 1)} disabled={submitting} />
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{activeStep === 0 && (
|
||||
<Button intent={Intent.PRIMARY} text="Next" rightIcon="arrow-right" disabled={!step0Valid} onClick={() => setActiveStep(1)} />
|
||||
)}
|
||||
{activeStep === 1 && (
|
||||
<Button intent={Intent.PRIMARY} text="Next" rightIcon="arrow-right" disabled={!step1Valid} onClick={() => setActiveStep(2)} />
|
||||
)}
|
||||
{isLastFormStep && (
|
||||
<Button intent={Intent.DANGER} text="Deploy Client" icon="cloud-upload" loading={submitting} onClick={handleDeploy} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ProvisioningRequest, TenantEnvironment, TenantTier } from '../../types/provisioning';
|
||||
|
||||
interface Props {
|
||||
signalParent: (state: { isValid: boolean }) => void;
|
||||
data: ProvisioningRequest;
|
||||
onChange: (updated: Partial<ProvisioningRequest>) => void;
|
||||
}
|
||||
|
||||
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.' },
|
||||
];
|
||||
|
||||
const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [
|
||||
{
|
||||
value: 'Shared',
|
||||
label: 'Shared',
|
||||
badge: 'Standard',
|
||||
description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.',
|
||||
},
|
||||
{
|
||||
value: 'Isolated',
|
||||
label: 'Isolated',
|
||||
badge: 'Professional',
|
||||
description: 'Shared Keycloak and Vault, but a dedicated Postgres container and MinIO bucket per tenant.',
|
||||
},
|
||||
{
|
||||
value: 'Dedicated',
|
||||
label: 'Dedicated',
|
||||
badge: 'Enterprise',
|
||||
description: 'Fully dedicated Keycloak, Vault, Postgres and MinIO containers for complete hard isolation.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DeploymentConfigStep({ signalParent, data, onChange }: Props) {
|
||||
useEffect(() => {
|
||||
signalParent({ isValid: !!data.tier && !!data.environment });
|
||||
}, [data.tier, data.environment, signalParent]);
|
||||
|
||||
return (
|
||||
<div className="wizard-step">
|
||||
<p className="step-description">Choose the deployment environment and infrastructure isolation tier.</p>
|
||||
|
||||
<h4 style={{ marginBottom: '0.5rem' }}>Environment</h4>
|
||||
<div className="tier-cards" style={{ marginBottom: '1.5rem' }}>
|
||||
{ENVIRONMENTS.map((env) => (
|
||||
<button
|
||||
key={env.value}
|
||||
type="button"
|
||||
className={`tier-card${data.environment === env.value ? ' selected' : ''}`}
|
||||
onClick={() => onChange({ environment: env.value })}
|
||||
>
|
||||
<div className="tier-card-header">
|
||||
<span className="tier-card-label">{env.label}</span>
|
||||
</div>
|
||||
<p className="tier-card-description">{env.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: '0.5rem' }}>Isolation Tier</h4>
|
||||
<div className="tier-cards">
|
||||
{TIERS.map((tier) => (
|
||||
<button
|
||||
key={tier.value}
|
||||
type="button"
|
||||
className={`tier-card${data.tier === tier.value ? ' selected' : ''}`}
|
||||
onClick={() => onChange({ tier: tier.value })}
|
||||
>
|
||||
<div className="tier-card-header">
|
||||
<span className="tier-card-label">{tier.label}</span>
|
||||
<span className={`tier-card-badge tier-badge-${tier.value.toLowerCase()}`}>{tier.badge}</span>
|
||||
</div>
|
||||
<p className="tier-card-description">{tier.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnchorButton, Callout, Intent, ProgressBar, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core';
|
||||
import { subscribeToJobStream } from '../../api/provisioningApi';
|
||||
import { tenantUrl } from '../../config';
|
||||
import type { ProvisioningProgressEvent } from '../../types/provisioning';
|
||||
|
||||
const SAGA_STEPS = [
|
||||
'Infrastructure Provisioning',
|
||||
'Identity Bootstrapping (Keycloak)',
|
||||
'Cryptographic Pre-Flight (Vault)',
|
||||
'Database Migration & Seeding (EF Core)',
|
||||
'Handoff (Email Magic Link)',
|
||||
];
|
||||
|
||||
type StepStatus = 'pending' | 'running' | 'complete' | 'failed';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
subdomain: string;
|
||||
}
|
||||
|
||||
export default function DeploymentLiveStep({ jobId, subdomain }: Props) {
|
||||
const [stepStatuses, setStepStatuses] = useState<Record<string, StepStatus>>(
|
||||
Object.fromEntries(SAGA_STEPS.map((s) => [s, 'pending' as StepStatus]))
|
||||
);
|
||||
const [logs, setLogs] = useState<ProvisioningProgressEvent[]>([]);
|
||||
const [diagnostics, setDiagnostics] = useState<ProvisioningProgressEvent[]>([]);
|
||||
const [finalStatus, setFinalStatus] = useState<'running' | 'complete' | 'failed'>('running');
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const diagEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let terminal = false;
|
||||
|
||||
const source = subscribeToJobStream(jobId, (evt) => {
|
||||
if (evt.type === 'diagnostic') {
|
||||
setDiagnostics((prev) => [...prev, evt]);
|
||||
return;
|
||||
}
|
||||
setLogs((prev) => [...prev, evt]);
|
||||
if (evt.type === 'step_started' && evt.step)
|
||||
setStepStatuses((p) => ({ ...p, [evt.step!]: 'running' }));
|
||||
else if (evt.type === 'step_complete' && evt.step)
|
||||
setStepStatuses((p) => ({ ...p, [evt.step!]: 'complete' }));
|
||||
else if (evt.type === 'step_failed' && evt.step) {
|
||||
setStepStatuses((p) => ({ ...p, [evt.step!]: 'failed' }));
|
||||
setFinalStatus('failed');
|
||||
} else if (evt.type === 'job_complete') {
|
||||
terminal = true;
|
||||
setFinalStatus('complete');
|
||||
} else if (evt.type === 'job_failed') {
|
||||
terminal = true;
|
||||
setFinalStatus('failed');
|
||||
}
|
||||
}, () => { if (!terminal) setFinalStatus('failed'); });
|
||||
|
||||
return () => source.close();
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]);
|
||||
useEffect(() => { diagEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [diagnostics]);
|
||||
|
||||
const completedCount = Object.values(stepStatuses).filter((s) => s === 'complete').length;
|
||||
const clientUrl = tenantUrl(subdomain);
|
||||
|
||||
const progressPanel = (
|
||||
<>
|
||||
<div className="step-tracker">
|
||||
{SAGA_STEPS.map((step) => {
|
||||
const status = stepStatuses[step];
|
||||
return (
|
||||
<div key={step} className="step-tracker-row">
|
||||
{status === 'running' && <Spinner size={14} />}
|
||||
{status === 'complete' && <Tag intent={Intent.SUCCESS} minimal round>✓</Tag>}
|
||||
{status === 'failed' && <Tag intent={Intent.DANGER} minimal round>✗</Tag>}
|
||||
{status === 'pending' && <span className="step-dot" />}
|
||||
<span className={`step-label step-${status}`}>{step}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="log-feed">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className={`log-line log-${log.type}`}>
|
||||
<span className="log-ts">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span className="log-msg">{log.message ?? log.type}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const diagnosticsPanel = (
|
||||
<div className="log-feed log-feed--diagnostics">
|
||||
{diagnostics.length === 0
|
||||
? <span className="log-msg" style={{ opacity: 0.5 }}>No diagnostics captured.</span>
|
||||
: diagnostics.map((d, i) => (
|
||||
<div key={i} className="log-line log-diagnostic">
|
||||
<span className="log-ts">{new Date(d.timestamp).toLocaleTimeString()}</span>
|
||||
<span className="log-step">{d.step}</span>
|
||||
<pre className="log-detail">{d.detail ?? d.message}</pre>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div ref={diagEndRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wizard-step">
|
||||
<p className="step-description">
|
||||
{finalStatus === 'running' ? 'Provisioning in progress - do not close this window.' :
|
||||
finalStatus === 'complete' ? 'Deployment complete.' : 'Deployment failed. Rollback triggered.'}
|
||||
</p>
|
||||
|
||||
<ProgressBar
|
||||
value={completedCount / SAGA_STEPS.length}
|
||||
intent={finalStatus === 'failed' ? Intent.DANGER : finalStatus === 'complete' ? Intent.SUCCESS : Intent.PRIMARY}
|
||||
animate={finalStatus === 'running'}
|
||||
stripes={finalStatus === 'running'}
|
||||
style={{ marginBottom: '1.5rem' }}
|
||||
/>
|
||||
|
||||
<Tabs id="deploy-tabs" renderActiveTabPanelOnly={false}>
|
||||
<Tab id="progress" title="Progress" panel={progressPanel} />
|
||||
<Tab
|
||||
id="diagnostics"
|
||||
title={
|
||||
<span>
|
||||
Diagnostics
|
||||
{diagnostics.length > 0 && (
|
||||
<Tag intent={Intent.DANGER} minimal round style={{ marginLeft: 6 }}>
|
||||
{diagnostics.length}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
panel={diagnosticsPanel}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{finalStatus === 'complete' && (
|
||||
<Callout intent={Intent.SUCCESS} title="Client Provisioned" style={{ marginTop: '1rem' }}>
|
||||
<p style={{ marginBottom: '0.75rem' }}>
|
||||
Tenant <strong>{subdomain}</strong> is live. The day-zero admin has been set up in Keycloak.
|
||||
</p>
|
||||
<AnchorButton
|
||||
intent={Intent.SUCCESS}
|
||||
icon="share"
|
||||
text={`Open ${subdomain}.clarity.test`}
|
||||
href={clientUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
</Callout>
|
||||
)}
|
||||
{finalStatus === 'failed' && (
|
||||
<Callout intent={Intent.DANGER} title="Deployment Failed" style={{ marginTop: '1rem' }}>
|
||||
A compensating rollback has been triggered. Check the Diagnostics tab for the full stack trace.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Callout, HTMLTable, Intent, Tag } from '@blueprintjs/core';
|
||||
import { tenantUrl } from '../../config';
|
||||
import type { ProvisioningRequest } from '../../types/provisioning';
|
||||
|
||||
interface Props {
|
||||
signalParent: (state: { isValid: boolean }) => void;
|
||||
data: ProvisioningRequest;
|
||||
}
|
||||
|
||||
export default function ReviewStep({ data }: Props) {
|
||||
const clientUrl = tenantUrl(data.subdomain);
|
||||
const containerName = data.subdomain;
|
||||
|
||||
return (
|
||||
<div className="wizard-step">
|
||||
<p className="step-description">Confirm the details below before deploying.</p>
|
||||
|
||||
<HTMLTable striped className="review-table">
|
||||
<tbody>
|
||||
<tr><td>Client Name</td><td><strong>{data.clientName}</strong></td></tr>
|
||||
<tr><td>State Code</td><td><Tag intent={Intent.PRIMARY} round>{data.stateCode}</Tag></td></tr>
|
||||
<tr><td>Site Code</td><td><code>{data.siteCode}</code></td></tr>
|
||||
<tr><td>Environment</td><td><Tag intent={data.environment === 'prod' ? Intent.DANGER : data.environment === 'uat' ? Intent.WARNING : Intent.PRIMARY} round>{data.environment}</Tag></td></tr>
|
||||
<tr><td>Container Name</td><td><code>{containerName}</code></td></tr>
|
||||
<tr><td>Client URL</td><td><code style={{ fontSize: '0.9em' }}>{clientUrl}</code></td></tr>
|
||||
<tr><td>Admin Email</td><td>{data.adminEmail}</td></tr>
|
||||
<tr><td>Tier</td><td><Tag intent={data.tier === 'Dedicated' ? Intent.DANGER : data.tier === 'Isolated' ? Intent.WARNING : Intent.NONE} round>{data.tier}</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.
|
||||
A compensating rollback will trigger automatically on failure.
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user