167 lines
6.1 KiB
TypeScript
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>✓</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>
|
|
);
|
|
}
|