OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user