diff --git a/ControlPlane.Api/Endpoints/InfraEndpoints.cs b/ControlPlane.Api/Endpoints/InfraEndpoints.cs index 1761abc..7642141 100644 --- a/ControlPlane.Api/Endpoints/InfraEndpoints.cs +++ b/ControlPlane.Api/Endpoints/InfraEndpoints.cs @@ -16,6 +16,7 @@ public static class InfraEndpoints 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/nuke/stream", ComposeNukeStream); g.MapGet ("/compose/down/stream", ComposeDownStream); return app; @@ -131,17 +132,49 @@ public static class InfraEndpoints private static Task ComposeUpForceStream(HttpContext ctx, IConfiguration config, CancellationToken ct) => StreamComposeOutput(ctx, config, "up -d --force-recreate --remove-orphans", ct); + // Nuke: force-removes every known platform container by name first (kills orphans that + // --remove-orphans won't touch because they belong to a different compose project), + // then runs a fresh compose up. + private static async Task ComposeNukeStream(HttpContext ctx, IConfiguration config, CancellationToken ct) + { + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + async Task Send(string line) + { + await ctx.Response.WriteAsync($"data: {line}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + await Send("▶ Removing all known platform containers…"); + foreach (var container in PlatformContainers) + { + var (code, _) = await DockerAsync($"rm -f {container}"); + await Send(code == 0 + ? $" ✔ removed {container}" + : $" · {container} not found (skipped)"); + } + + await Send("▶ Running compose up…"); + await StreamComposeOutput(ctx, config, "up -d", ct, skipHeaders: true); + } + private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) => StreamComposeOutput(ctx, config, "down", ct); private static async Task StreamComposeOutput( - HttpContext ctx, IConfiguration config, string composeArgs, CancellationToken ct) + HttpContext ctx, IConfiguration config, string composeArgs, CancellationToken ct, + bool skipHeaders = false) { var infraDir = ResolveInfraPath(config); - ctx.Response.Headers.ContentType = "text/event-stream"; - ctx.Response.Headers.CacheControl = "no-cache"; - ctx.Response.Headers.Connection = "keep-alive"; + if (!skipHeaders) + { + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + } var channel = System.Threading.Channels.Channel.CreateUnbounded( new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = false, SingleReader = true }); diff --git a/clarity.controlplane/src/api/infraApi.ts b/clarity.controlplane/src/api/infraApi.ts index bef0cb5..7f33891 100644 --- a/clarity.controlplane/src/api/infraApi.ts +++ b/clarity.controlplane/src/api/infraApi.ts @@ -44,6 +44,18 @@ export function streamComposeForceUp(onLine: (line: string) => void, onDone: () return src; } +/** + * Nuke & Recreate — force-removes every known platform container by name first + * (kills orphans that --remove-orphans won't touch), then runs compose up fresh. + * Use this when Force Recreate still fails with "container name already in use". + */ +export function streamComposeNuke(onLine: (line: string) => void, onDone: () => void): EventSource { + const src = new EventSource(`${BASE_URL}/api/infra/compose/nuke/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); diff --git a/clarity.controlplane/src/pages/ImageBuildPage.tsx b/clarity.controlplane/src/pages/ImageBuildPage.tsx index 3f10ef2..3ff4a48 100644 --- a/clarity.controlplane/src/pages/ImageBuildPage.tsx +++ b/clarity.controlplane/src/pages/ImageBuildPage.tsx @@ -6,7 +6,7 @@ import { } from '@blueprintjs/core'; import { getImageStatus, getBuildHistory, type ImageBuildStatus, type BuildRecord } from '../api/provisioningApi'; import { - getInfraStatus, streamComposeUp, streamComposeForceUp, streamComposeDown, + getInfraStatus, streamComposeUp, streamComposeForceUp, streamComposeNuke, streamComposeDown, type InfraService, } from '../api/infraApi'; @@ -103,7 +103,7 @@ function BuildHistoryTable({ records }: { records: BuildRecord[] }) { function PlatformTab() { const [services, setServices] = useState([]); const [loading, setLoading] = useState(false); - const [composeBusy, setBusy] = useState<'up' | 'force' | 'down' | null>(null); + const [composeBusy, setBusy] = useState<'up' | 'force' | 'nuke' | 'down' | null>(null); const [lines, setLines] = useState([]); const sseRef = useRef(null); @@ -119,7 +119,7 @@ function PlatformTab() { function startStream( streamer: (onLine: (l: string) => void, onDone: () => void) => EventSource, - label: 'up' | 'force' | 'down', + label: 'up' | 'force' | 'nuke' | 'down', ) { sseRef.current?.close(); setLines([`▶ compose ${label}…`]); @@ -161,6 +161,12 @@ function PlatformTab() { onClick={() => startStream(streamComposeForceUp, 'force')} title="Force-recreate all containers and remove orphans. Fixes 'container name already in use' errors." >Force Recreate +