Add House Party Protocol for fleet-wide queued destruction

This commit is contained in:
2026-09-18 00:39:19 -04:00
parent 586133c711
commit 274aedb66e
7 changed files with 212 additions and 3 deletions
+11 -3
View File
@@ -416,12 +416,16 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
const destroying = new Set<string>();
// Persist the request before returning so navigation and client disconnects cannot lose it.
export function destroyServer(name: string): DestroyServerResult & { deploymentId?: string } {
function hasActiveCreation(name: string): boolean {
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
}
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
const operation = db().transaction(() => {
const id = beginDeployment(name, 'destroy');
const id = beginDeployment(name, 'destroy', triggeredBy);
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
deploymentStage(id, 'Waiting to verify the server is empty');
return id;
@@ -443,6 +447,10 @@ async function runDestructions(): Promise<void> {
destroying.add(operation.server);
try {
if (!getServer(operation.server)) {
if (hasActiveCreation(operation.server)) {
deploymentStage(operation.id, 'Waiting for server creation to finish');
continue;
}
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
continue;
}
+28
View File
@@ -0,0 +1,28 @@
import { db } from '../db/index.js';
import { destroyServer } from './create.js';
export function housePartyTargets(): string[] {
return (db().prepare(`
SELECT name FROM servers WHERE destroyed_at IS NULL
UNION
SELECT server AS name FROM server_deployments
WHERE action = 'create' AND state IN ('queued', 'running', 'awaiting-health')
ORDER BY name
`).all() as { name: string }[]).map(row => row.name);
}
export function queueHouseParty(expectedServers: string[]) {
return db().transaction(() => {
const servers = housePartyTargets();
const expected = [...expectedServers].sort();
if (servers.length !== expected.length || servers.some((name, index) => name !== expected[index])) {
return { ok: false as const, error: 'The fleet changed. Refresh the preview and review the servers again.' };
}
const deployments = servers.map(server => {
const result = destroyServer(server, 'house-party-protocol');
if (!result.ok) throw new Error(result.message);
return { server, deploymentId: result.deploymentId! };
});
return { ok: true as const, deployments };
})();
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let servers = $state<string[] | null>(null);
let busy = $state(false);
let error = $state('');
let wasOpen = false;
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; dialog.close(); }
});
async function review() {
busy = true; error = ''; servers = null;
try {
const res = await fetch('/api/deployments/house-party');
if (!res.ok) throw new Error('Could not load the fleet.');
servers = (await res.json()).servers;
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function launch() {
if (busy || !servers?.length) return;
busy = true; error = '';
try {
const res = await fetch('/api/deployments/house-party', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const body = await res.json();
if (!res.ok) {
if (res.status === 409) servers = null;
throw new Error(body.error ?? 'Could not queue destruction.');
}
open = false;
onqueued(body.deployments.length);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-label="House Party Protocol" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="content">
<header><span class="eyebrow">Call it a night</span><h2>House Party Protocol</h2></header>
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
{#if servers.length}<ul>{#each servers as server}<li>{server}</li>{/each}</ul><p class="caption">Existing destruction requests are reused. Servers added after this request are not included.</p>{/if}
{:else}<p>{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}</p>{/if}
<div class="actions">
<Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button>
{#if !servers}<Button disabled={busy} onclick={review}>Refresh preview</Button>
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Queue destruction of {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
</div>
</div>
</dialog>
<style>
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
.eyebrow { display: block; color: var(--state-danger); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: 8px; }
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>