Files
OPC/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx
T
2026-04-25 18:05:57 -04:00

167 lines
6.1 KiB
TypeScript

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>&#10003;</Tag>}
{status === 'failed' && <Tag intent={Intent.DANGER} minimal round>&#10007;</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>
);
}