OPC # 0002: Improvements to Client provisioning workflows
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ControlPlane.Api.Services;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
@@ -14,6 +16,11 @@ public static class ImageBuildEndpoints
|
||||
group.MapGet("/status", GetStatus);
|
||||
group.MapPost("/build", TriggerBuild);
|
||||
|
||||
// Post-provisioning verification helpers
|
||||
group.MapGet ("/verify/extra-hosts/{containerName}", GetExtraHosts);
|
||||
group.MapPost("/verify/dns-test", DnsTest);
|
||||
group.MapGet ("/artifact/{subdomain}", GetArtifact);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -72,4 +79,118 @@ public static class ImageBuildEndpoints
|
||||
await ctx.Response.WriteAsync("data: {\"done\":true}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// ── Post-provisioning verification endpoints ──────────────────────────────
|
||||
|
||||
private static readonly Regex SafeContainerName = new(@"^[a-zA-Z0-9_.\-]+$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ExtraHosts list for a running tenant container.
|
||||
/// e.g. GET /api/image/verify/extra-hosts/fdev-app-clarity-01000001
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetExtraHosts(string containerName)
|
||||
{
|
||||
if (!SafeContainerName.IsMatch(containerName))
|
||||
return Results.BadRequest(new { error = "Invalid container name." });
|
||||
|
||||
var (code, output) = await DockerRunAsync($"inspect --format {{{{json .HostConfig.ExtraHosts}}}} {containerName}");
|
||||
if (code != 0 || string.IsNullOrWhiteSpace(output))
|
||||
return Results.NotFound(new { error = $"Container '{containerName}' not found or not running.", detail = output });
|
||||
|
||||
try
|
||||
{
|
||||
var hosts = JsonDocument.Parse(output.Trim()).RootElement;
|
||||
return Results.Ok(new { containerName, extraHosts = hosts });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Ok(new { containerName, extraHosts = (object?)null, raw = output.Trim() });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>curl</c> from inside the container to verify *.clarity.test DNS resolves through nginx.
|
||||
/// POST /api/image/verify/dns-test body: { container, url }
|
||||
/// </summary>
|
||||
private static async Task<IResult> DnsTest(DnsTestRequest body)
|
||||
{
|
||||
if (!SafeContainerName.IsMatch(body.Container))
|
||||
return Results.BadRequest(new { error = "Invalid container name." });
|
||||
|
||||
// Only allow http/https URLs — prevents command injection via url field
|
||||
if (!body.Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!body.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return Results.BadRequest(new { error = "URL must start with http:// or https://." });
|
||||
|
||||
var psi = new ProcessStartInfo("docker",
|
||||
$"exec {body.Container} curl -sf --max-time 10 --write-out \"\\nHTTP %{{http_code}}\" {body.Url}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return Results.Problem("Failed to start docker process.");
|
||||
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await proc.StandardError.ReadToEndAsync();
|
||||
await proc.WaitForExitAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
success = proc.ExitCode == 0,
|
||||
exitCode = proc.ExitCode,
|
||||
output = stdout.Trim(),
|
||||
error = stderr.Trim(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the generated docker-compose.yml from ClientAssets/{subdomain}/.
|
||||
/// GET /api/image/artifact/{subdomain}
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetArtifact(string subdomain, IConfiguration config)
|
||||
{
|
||||
// Restrict subdomain to safe characters — prevents path traversal
|
||||
if (!SafeContainerName.IsMatch(subdomain))
|
||||
return Results.BadRequest(new { error = "Invalid subdomain." });
|
||||
|
||||
var root = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
|
||||
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
|
||||
|
||||
// Use Path.GetFileName to ensure no directory traversal
|
||||
var safeSubdomain = Path.GetFileName(subdomain);
|
||||
var composePath = Path.GetFullPath(Path.Combine(root, safeSubdomain, "docker-compose.yml"));
|
||||
|
||||
// Verify the final path is still inside the ClientAssets root
|
||||
if (!composePath.StartsWith(Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase))
|
||||
return Results.BadRequest(new { error = "Invalid subdomain path." });
|
||||
|
||||
if (!File.Exists(composePath))
|
||||
return Results.NotFound(new { error = $"No compose artifact found for '{subdomain}'." });
|
||||
|
||||
var content = await File.ReadAllTextAsync(composePath);
|
||||
return Results.Ok(new { subdomain, path = composePath, content });
|
||||
}
|
||||
|
||||
private static async Task<(int code, string? output)> DockerRunAsync(string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo("docker", args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return (-1, null);
|
||||
var output = await proc.StandardOutput.ReadToEndAsync();
|
||||
var err = await proc.StandardError.ReadToEndAsync();
|
||||
await proc.WaitForExitAsync();
|
||||
return (proc.ExitCode, string.IsNullOrWhiteSpace(output) ? err : output);
|
||||
}
|
||||
|
||||
private record DnsTestRequest(string Container, string Url);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public static class InfraEndpoints
|
||||
g.MapPost("/{container}/stop", (string container) => ServiceAction(container, "stop"));
|
||||
g.MapPost("/{container}/restart",(string container) => ServiceAction(container, "restart"));
|
||||
g.MapGet ("/compose/up/stream", ComposeUpStream);
|
||||
g.MapGet ("/compose/up-force/stream", ComposeUpForceStream);
|
||||
g.MapGet ("/compose/down/stream", ComposeDownStream);
|
||||
|
||||
return app;
|
||||
@@ -121,8 +122,14 @@ public static class InfraEndpoints
|
||||
: Results.Problem(output ?? "Docker command failed", statusCode: 500);
|
||||
}
|
||||
|
||||
// Starts all platform services; --remove-orphans cleans up containers with stale names
|
||||
// (e.g. a leftover clarity-dnsmasq that causes the "name already in use" conflict).
|
||||
private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
||||
StreamComposeOutput(ctx, config, "up --pull missing", ct);
|
||||
StreamComposeOutput(ctx, config, "up -d --remove-orphans", ct);
|
||||
|
||||
// Force-recreates every container regardless of config drift — use after image or compose changes.
|
||||
private static Task ComposeUpForceStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
||||
StreamComposeOutput(ctx, config, "up -d --force-recreate --remove-orphans", ct);
|
||||
|
||||
private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
||||
StreamComposeOutput(ctx, config, "down", ct);
|
||||
|
||||
@@ -553,13 +553,13 @@ public class ClarityContainerService(
|
||||
// ── Postgres ──────────────────────────────────────────────────────────
|
||||
if (stack.Postgres == ComponentMode.OwnContainer)
|
||||
{
|
||||
sb.AppendLine($"""
|
||||
pg-{s}:
|
||||
sb.AppendLine($$"""
|
||||
pg-{{s}}:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: clarity
|
||||
POSTGRES_PASSWORD: ${{POSTGRES_PASSWORD:-clarity-dev}}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clarity-dev}
|
||||
POSTGRES_DB: clarity
|
||||
expose:
|
||||
- "5432"
|
||||
@@ -574,7 +574,7 @@ public class ClarityContainerService(
|
||||
- clarity-net
|
||||
labels:
|
||||
clarity.managed: "true"
|
||||
clarity.subdomain: {s}
|
||||
clarity.subdomain: {{s}}
|
||||
clarity.component: postgres
|
||||
""");
|
||||
}
|
||||
@@ -591,20 +591,20 @@ public class ClarityContainerService(
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
sb.AppendLine($"""
|
||||
kc-{s}:
|
||||
sb.AppendLine($$"""
|
||||
kc-{{s}}:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
restart: unless-stopped
|
||||
command: start-dev
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${{KEYCLOAK_ADMIN_PASSWORD:-admin}}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: pg-{s}
|
||||
KC_DB_URL_HOST: pg-{{s}}
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_USERNAME: clarity
|
||||
KC_DB_PASSWORD: ${{POSTGRES_PASSWORD:-clarity-dev}}
|
||||
KC_HOSTNAME: {kcHostname}
|
||||
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-clarity-dev}
|
||||
KC_HOSTNAME: {{kcHostname}}
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
expose:
|
||||
@@ -614,11 +614,11 @@ public class ClarityContainerService(
|
||||
networks:
|
||||
- clarity-net
|
||||
extra_hosts:
|
||||
- "{kcHostname}:host-gateway"
|
||||
{dependsBlock}
|
||||
- "{{kcHostname}}:host-gateway"
|
||||
{{dependsBlock}}
|
||||
labels:
|
||||
clarity.managed: "true"
|
||||
clarity.subdomain: {s}
|
||||
clarity.subdomain: {{s}}
|
||||
clarity.component: keycloak
|
||||
""");
|
||||
}
|
||||
@@ -626,14 +626,14 @@ public class ClarityContainerService(
|
||||
// ── Vault ─────────────────────────────────────────────────────────────
|
||||
if (stack.Vault == ComponentMode.OwnContainer)
|
||||
{
|
||||
sb.AppendLine($"""
|
||||
vault-{s}:
|
||||
sb.AppendLine($$"""
|
||||
vault-{{s}}:
|
||||
image: hashicorp/vault:latest
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- IPC_LOCK
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: ${{VAULT_TOKEN:-vault-dev-root}}
|
||||
VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_TOKEN:-vault-dev-root}
|
||||
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
|
||||
expose:
|
||||
- "8200"
|
||||
@@ -643,7 +643,7 @@ public class ClarityContainerService(
|
||||
- clarity-net
|
||||
labels:
|
||||
clarity.managed: "true"
|
||||
clarity.subdomain: {s}
|
||||
clarity.subdomain: {{s}}
|
||||
clarity.component: vault
|
||||
""");
|
||||
}
|
||||
@@ -651,14 +651,14 @@ public class ClarityContainerService(
|
||||
// ── MinIO ─────────────────────────────────────────────────────────────
|
||||
if (stack.Minio == ComponentMode.OwnContainer)
|
||||
{
|
||||
sb.AppendLine($"""
|
||||
minio-{s}:
|
||||
sb.AppendLine($$"""
|
||||
minio-{{s}}:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${{MINIO_ROOT_USER:-minio}}
|
||||
MINIO_ROOT_PASSWORD: ${{MINIO_ROOT_PASSWORD:-minio-dev}}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio-dev}
|
||||
expose:
|
||||
- "9000"
|
||||
- "9001"
|
||||
@@ -669,7 +669,7 @@ public class ClarityContainerService(
|
||||
- clarity-net
|
||||
labels:
|
||||
clarity.managed: "true"
|
||||
clarity.subdomain: {s}
|
||||
clarity.subdomain: {{s}}
|
||||
clarity.component: minio
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -33,7 +42,7 @@ function BuildTerminal({ lines }: { lines: string[] }) {
|
||||
color: '#c9d1d9',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 6,
|
||||
height: 420,
|
||||
height,
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
@@ -41,10 +50,10 @@ function BuildTerminal({ lines }: { lines: string[] }) {
|
||||
}}
|
||||
>
|
||||
{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;
|
||||
@@ -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' }}>
|
||||
{/* ── Tabs ── */}
|
||||
<Tabs id="ibp-tabs" selectedTabId={tab} onChange={setTab} renderActiveTabPanelOnly>
|
||||
|
||||
<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>
|
||||
<BuildTerminal lines={logs} />
|
||||
</div>
|
||||
|
||||
{/* ── History ── */}
|
||||
<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' }}>Build History</h3>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user