OPC # 0002: Improvements to Client provisioning workflows

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-25 21:57:42 -04:00
parent 35fe82d225
commit 378daf98d6
5 changed files with 501 additions and 77 deletions
+8
View File
@@ -36,6 +36,14 @@ export function streamComposeUp(onLine: (line: string) => void, onDone: () => vo
return src;
}
/** Force-recreates all containers and removes orphans — fixes name-conflict errors. */
export function streamComposeForceUp(onLine: (line: string) => void, onDone: () => void): EventSource {
const src = new EventSource(`${BASE_URL}/api/infra/compose/up-force/stream`);
src.onmessage = (e) => onLine(e.data);
src.onerror = () => { onDone(); src.close(); };
return src;
}
export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource {
const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`);
src.onmessage = (e) => onLine(e.data);
+337 -49
View File
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Callout, Intent, Tag, Spinner,
HTMLTable, Card, Elevation,
HTMLTable, Card, Elevation, Tabs, Tab, type TabId,
FormGroup, InputGroup,
} from '@blueprintjs/core';
import { getImageStatus, getBuildHistory, type ImageBuildStatus, type BuildRecord } from '../api/provisioningApi';
import {
getInfraStatus, streamComposeUp, streamComposeForceUp, streamComposeDown,
type InfraService,
} from '../api/infraApi';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
@@ -13,9 +18,13 @@ const STATUS_INTENT: Record<string, Intent> = {
Running: Intent.PRIMARY,
};
// ── Live terminal ─────────────────────────────────────────────────────────────
// ── Shared terminal ───────────────────────────────────────────────────────────
function BuildTerminal({ lines }: { lines: string[] }) {
function Terminal({ lines, height = 360, placeholder = 'Waiting for output…' }: {
lines: string[];
height?: number;
placeholder?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -26,28 +35,28 @@ function BuildTerminal({ lines }: { lines: string[] }) {
<div
ref={ref}
style={{
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '0.75rem',
lineHeight: 1.5,
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75rem 1rem',
borderRadius: 6,
height: 420,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
border: '1px solid #30363d',
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '0.75rem',
lineHeight: 1.5,
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75rem 1rem',
borderRadius: 6,
height,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
border: '1px solid #30363d',
}}
>
{lines.length === 0 ? (
<span style={{ color: '#484f58' }}>Waiting for build output</span>
<span style={{ color: '#484f58' }}>{placeholder}</span>
) : (
lines.map((l, i) => {
const isError = l.startsWith('✖');
const isError = l.startsWith('✖') || l.toLowerCase().includes('error');
const isSuccess = l.startsWith('✔');
const isSep = l.startsWith('──');
const color = isError ? '#f85149' : isSuccess ? '#3fb950' : isSep ? '#484f58' : undefined;
const color = isError ? '#f85149' : isSuccess ? '#3fb950' : isSep ? '#484f58' : undefined;
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
})
)}
@@ -89,9 +98,266 @@ function BuildHistoryTable({ records }: { records: BuildRecord[] }) {
);
}
// ── Platform tab ──────────────────────────────────────────────────────────────
function PlatformTab() {
const [services, setServices] = useState<InfraService[]>([]);
const [loading, setLoading] = useState(false);
const [composeBusy, setBusy] = useState<'up' | 'force' | 'down' | null>(null);
const [lines, setLines] = useState<string[]>([]);
const sseRef = useRef<EventSource | null>(null);
const refresh = () => {
setLoading(true);
getInfraStatus()
.then(d => setServices(d.services))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { refresh(); }, []);
function startStream(
streamer: (onLine: (l: string) => void, onDone: () => void) => EventSource,
label: 'up' | 'force' | 'down',
) {
sseRef.current?.close();
setLines([`▶ compose ${label}`]);
setBusy(label);
const src = streamer(
(line) => setLines(prev => [...prev, line]),
() => { setBusy(null); refresh(); },
);
sseRef.current = src;
}
const running = services.filter(s => s.status === 'running').length;
const statusIntent = services.length === 0 ? Intent.NONE
: running === services.length ? Intent.SUCCESS
: running === 0 ? Intent.DANGER
: Intent.WARNING;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Card elevation={Elevation.ONE} style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.75rem 1rem', flexWrap: 'wrap',
}}>
{loading ? <Spinner size={16} /> : (
<Tag intent={statusIntent} round large>
{services.length === 0 ? 'Not checked' : `${running} / ${services.length} running`}
</Tag>
)}
<Button small icon="refresh" minimal onClick={refresh} loading={loading}>Refresh</Button>
<div style={{ display: 'flex', gap: '0.4rem', marginLeft: 'auto' }}>
<Button
small icon="play" intent={Intent.SUCCESS}
loading={composeBusy === 'up'} disabled={composeBusy !== null}
onClick={() => startStream(streamComposeUp, 'up')}
>Compose Up</Button>
<Button
small icon="refresh" intent={Intent.WARNING}
loading={composeBusy === 'force'} disabled={composeBusy !== null}
onClick={() => startStream(streamComposeForceUp, 'force')}
title="Force-recreate all containers and remove orphans. Fixes 'container name already in use' errors."
>Force Recreate</Button>
<Button
small icon="stop" intent={Intent.DANGER}
loading={composeBusy === 'down'} disabled={composeBusy !== null}
onClick={() => startStream(streamComposeDown, 'down')}
>Compose Down</Button>
</div>
</Card>
{services.length > 0 && (
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{services.map(s => (
<Tag
key={s.container}
intent={s.status === 'running' ? Intent.SUCCESS : s.status === 'unhealthy' ? Intent.WARNING : Intent.DANGER}
minimal
title={s.uptime ? `Up ${s.uptime}` : undefined}
>
{s.container}
</Tag>
))}
</div>
)}
<Terminal lines={lines} height={300} placeholder="Run Compose Up or Force Recreate to see output…" />
</div>
);
}
// ── Verify tab ────────────────────────────────────────────────────────────────
function VerifyTab() {
const [ehContainer, setEhContainer] = useState('');
const [ehResult, setEhResult] = useState<string | null>(null);
const [ehLoading, setEhLoading] = useState(false);
const [ehError, setEhError] = useState<string | null>(null);
const [dnsContainer, setDnsContainer] = useState('');
const [dnsUrl, setDnsUrl] = useState('https://keycloak.clarity.test/health/ready');
const [dnsResult, setDnsResult] = useState<{ success: boolean; output: string; error: string } | null>(null);
const [dnsLoading, setDnsLoading] = useState(false);
const [subdomain, setSubdomain] = useState('');
const [artifact, setArtifact] = useState<string | null>(null);
const [artLoading, setArtLoading] = useState(false);
const [artError, setArtError] = useState<string | null>(null);
async function checkExtraHosts() {
setEhLoading(true); setEhResult(null); setEhError(null);
try {
const res = await fetch(`${BASE_URL}/api/image/verify/extra-hosts/${encodeURIComponent(ehContainer)}`);
const data = await res.json();
if (!res.ok) { setEhError(data.error ?? 'Not found'); return; }
setEhResult(JSON.stringify(data.extraHosts, null, 2));
} catch (e) {
setEhError(e instanceof Error ? e.message : 'Unknown error');
} finally { setEhLoading(false); }
}
async function runDnsTest() {
setDnsLoading(true); setDnsResult(null);
try {
const res = await fetch(`${BASE_URL}/api/image/verify/dns-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ container: dnsContainer, url: dnsUrl }),
});
const data = await res.json();
setDnsResult({ success: data.success, output: data.output ?? '', error: data.error ?? '' });
} catch (e) {
setDnsResult({ success: false, output: '', error: e instanceof Error ? e.message : 'Unknown error' });
} finally { setDnsLoading(false); }
}
async function viewArtifact() {
setArtLoading(true); setArtifact(null); setArtError(null);
try {
const res = await fetch(`${BASE_URL}/api/image/artifact/${encodeURIComponent(subdomain)}`);
const data = await res.json();
if (!res.ok) { setArtError(data.error ?? 'Not found'); return; }
setArtifact(data.content);
} catch (e) {
setArtError(e instanceof Error ? e.message : 'Unknown error');
} finally { setArtLoading(false); }
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<Card elevation={Elevation.ONE}>
<h4 style={{ margin: '0 0 0.4rem', fontSize: '0.9rem' }}>1 · Extra Hosts Check</h4>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.8rem', color: '#8f99a8' }}>
Verifies <code>*.clarity.test host-gateway</code> was injected so OIDC discovery
traffic routes through nginx correctly.
</p>
<FormGroup label="Container name" labelFor="eh-container" style={{ marginBottom: '0.5rem' }}>
<InputGroup
id="eh-container"
value={ehContainer}
onChange={e => setEhContainer(e.target.value)}
placeholder="fdev-app-clarity-01000001"
rightElement={
<Button small minimal loading={ehLoading} intent={Intent.PRIMARY}
onClick={checkExtraHosts} disabled={!ehContainer}>
Check
</Button>
}
/>
</FormGroup>
{ehError && <Callout intent={Intent.DANGER} style={{ fontSize: '0.8rem' }}>{ehError}</Callout>}
{ehResult && (
<pre style={{
marginTop: '0.5rem', background: '#0d1117', color: '#3fb950',
padding: '0.5rem 0.75rem', borderRadius: 4, fontSize: '0.8rem',
border: '1px solid #30363d', overflowX: 'auto',
}}>{ehResult}</pre>
)}
</Card>
<Card elevation={Elevation.ONE}>
<h4 style={{ margin: '0 0 0.4rem', fontSize: '0.9rem' }}>2 · DNS Resolution Test</h4>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.8rem', color: '#8f99a8' }}>
Runs <code>curl</code> from inside the container to verify <code>*.clarity.test</code> resolves
through nginx the critical path for Keycloak JWT validation.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<FormGroup label="Container" labelFor="dns-container" style={{ flex: '1 1 200px', marginBottom: 0 }}>
<InputGroup
id="dns-container"
value={dnsContainer}
onChange={e => setDnsContainer(e.target.value)}
placeholder="fdev-app-clarity-01000001"
/>
</FormGroup>
<FormGroup label="URL" labelFor="dns-url" style={{ flex: '2 1 280px', marginBottom: 0 }}>
<InputGroup id="dns-url" value={dnsUrl} onChange={e => setDnsUrl(e.target.value)} />
</FormGroup>
<Button
intent={Intent.PRIMARY} loading={dnsLoading}
disabled={!dnsContainer || !dnsUrl}
onClick={runDnsTest}
style={{ marginBottom: 0 }}
>Test DNS</Button>
</div>
{dnsResult && (
<Callout
intent={dnsResult.success ? Intent.SUCCESS : Intent.DANGER}
style={{ marginTop: '0.75rem', fontSize: '0.8rem' }}
>
{dnsResult.success
? '✔ Reachable — DNS and nginx routing is working correctly.'
: '✖ Unreachable — check nginx/dnsmasq or extra_hosts injection.'}
{(dnsResult.output || dnsResult.error) && (
<pre style={{ margin: '0.5rem 0 0', fontSize: '0.75rem', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{dnsResult.output || dnsResult.error}
</pre>
)}
</Callout>
)}
</Card>
<Card elevation={Elevation.ONE}>
<h4 style={{ margin: '0 0 0.4rem', fontSize: '0.9rem' }}>3 · Compose Artifact</h4>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.8rem', color: '#8f99a8' }}>
View the generated <code>docker-compose.yml</code> saved to{' '}
<code>ClientAssets/{'{subdomain}'}/</code> after provisioning.
</p>
<FormGroup label="Subdomain" labelFor="art-subdomain" style={{ marginBottom: '0.5rem' }}>
<InputGroup
id="art-subdomain"
value={subdomain}
onChange={e => setSubdomain(e.target.value)}
placeholder="acme"
rightElement={
<Button small minimal loading={artLoading} intent={Intent.PRIMARY}
onClick={viewArtifact} disabled={!subdomain}>
View
</Button>
}
/>
</FormGroup>
{artError && <Callout intent={Intent.DANGER} style={{ fontSize: '0.8rem' }}>{artError}</Callout>}
{artifact && (
<pre style={{
marginTop: '0.5rem', background: '#0d1117', color: '#c9d1d9',
padding: '0.75rem 1rem', borderRadius: 4, fontSize: '0.75rem',
border: '1px solid #30363d', overflowX: 'auto', maxHeight: 400, overflowY: 'auto',
}}>{artifact}</pre>
)}
</Card>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function ImageBuildPage() {
const [tab, setTab] = useState<TabId>('build');
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
const [history, setHistory] = useState<BuildRecord[]>([]);
const [building, setBuilding] = useState(false);
@@ -162,17 +428,11 @@ export default function ImageBuildPage() {
<div className="page-header">
<div>
<h1>Image Build</h1>
<p>Build the <code style={{ fontSize: '0.85em' }}>clarity-server</code> Docker image from the current repo.</p>
<p>
Build and verify the <code style={{ fontSize: '0.85em' }}>clarity-server</code> Docker image.
Use <strong>Platform</strong> to manage infra, <strong>Verify</strong> to inspect a provisioned tenant.
</p>
</div>
<Button
icon="build"
intent={Intent.WARNING}
large
loading={building}
disabled={building}
onClick={handleBuild}
text={building ? 'Building…' : 'Build Image'}
/>
</div>
{/* ── Status bar ── */}
@@ -192,9 +452,7 @@ export default function ImageBuildPage() {
{status.lastMessage}
</Tag>
)}
{lastBuilt && (
<span style={{ fontSize: '0.8rem', color: '#8f99a8' }}>Last built: {lastBuilt}</span>
)}
{lastBuilt && <span style={{ fontSize: '0.8rem', color: '#8f99a8' }}>Last built: {lastBuilt}</span>}
</>
) : (
<Spinner size={16} />
@@ -207,25 +465,55 @@ export default function ImageBuildPage() {
</Callout>
)}
{/* ── Terminal ── */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Output</h3>
{logs.length > 0 && !building && (
<Button minimal small icon="trash" text="Clear" onClick={() => setLogs([])} />
)}
</div>
<BuildTerminal lines={logs} />
</div>
{/* ── Tabs ── */}
<Tabs id="ibp-tabs" selectedTabId={tab} onChange={setTab} renderActiveTabPanelOnly>
{/* ── History ── */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Build History</h3>
<Button minimal small icon="refresh" onClick={refreshStatus} />
</div>
<BuildHistoryTable records={history} />
</div>
<Tab
id="build"
title="Build"
panel={
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingTop: '1rem' }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Output</h3>
<div style={{ display: 'flex', gap: '0.4rem' }}>
{logs.length > 0 && !building && (
<Button minimal small icon="trash" text="Clear" onClick={() => setLogs([])} />
)}
<Button
icon="build" intent={Intent.WARNING}
loading={building} disabled={building}
onClick={handleBuild}
text={building ? 'Building…' : 'Build Image'}
/>
</div>
</div>
<Terminal lines={logs} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>History</h3>
<Button minimal small icon="refresh" onClick={refreshStatus} />
</div>
<BuildHistoryTable records={history} />
</div>
</div>
}
/>
<Tab
id="platform"
title="Platform"
panel={<div style={{ paddingTop: '1rem' }}><PlatformTab /></div>}
/>
<Tab
id="verify"
title="Verify"
panel={<div style={{ paddingTop: '1rem' }}><VerifyTab /></div>}
/>
</Tabs>
</>
);
}