Migrate instances when draining and verify hosts before enabling
This commit is contained in:
@@ -5,6 +5,8 @@ import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/h
|
||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { config } from '$lib/server/config.js';
|
||||
import { hostInstances } from '$lib/server/hosts/instances.js';
|
||||
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function GET({ request, params }: Event) {
|
||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||
@@ -12,7 +14,14 @@ export function GET({ request, params }: Event) {
|
||||
}
|
||||
export async function PATCH({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining') throw new Error('Choose Enable or Drain host. Hosts disable automatically after draining.');
|
||||
updateHost(params.id, body);
|
||||
if (body.mode === 'active') reconcileHostPreparation(params.id);
|
||||
if (body.mode === 'draining') void kickProvisioning().catch(cause => console.error('[host-drain]', cause));
|
||||
return json({ host: presentHostPreparation(getHost(params.id)!) });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
|
||||
export function DELETE({ request, params }: Event) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
let requestSequence = 0;
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Host, HostMode, CapacityCalibration } from '$lib/hosts.js';
|
||||
import type { Host, CapacityCalibration } from '$lib/hosts.js';
|
||||
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -14,11 +14,13 @@
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import type { ServerObservation } from '$lib/server-observation.js';
|
||||
import { syncServerTime } from '$ui/stores/servers.js';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
let host = $state<Host | null>(null);
|
||||
let instances = $state<ServerObservation[]>([]);
|
||||
let calibration = $state<CapacityCalibration | null>(null);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let drainOpen = $state(false);
|
||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
||||
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
||||
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
||||
@@ -38,7 +40,12 @@
|
||||
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); }
|
||||
async function mode(value: HostMode) { await action('', 'PATCH', { mode: value }, value === 'draining' ? 'Drain enabled. Existing games continue until safe retirement.' : `Host ${value === 'active' ? 'enabled' : 'disabled'}.`); }
|
||||
async function mode(value: 'active' | 'draining') {
|
||||
await action('', 'PATCH', { mode: value }, value === 'draining'
|
||||
? 'Drain started. Empty instances migrate as destination capacity becomes available.'
|
||||
: 'Automatic setup started. This host will enable after the runtime and both game libraries are verified.');
|
||||
if (!error) drainOpen = false;
|
||||
}
|
||||
$effect(() => {
|
||||
void endpoint;
|
||||
return untrack(() => {
|
||||
@@ -57,8 +64,8 @@
|
||||
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
|
||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
||||
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
|
||||
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
|
||||
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
|
||||
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode !== 'disabled' || !!host.enablingSince} onclick={() => mode('active')}>{host.enablingSince ? 'Enabling…' : 'Enable'}</Button><Button disabled={busy || host.mode !== 'active'} onclick={() => drainOpen = true}>Drain host</Button></div>{/if}
|
||||
<p>Draining blocks new deployments and migrates empty instances to other hosts with capacity. The host disables when migration is complete and it is empty. Enabling refreshes the runtime and verifies BO1 and BO2 game files before returning the host to the capacity pool.</p>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="running-instances-heading">
|
||||
<h2 id="running-instances-heading">Running instances · {instances.length}</h2>
|
||||
@@ -108,6 +115,12 @@
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
|
||||
<Dialog bind:open={drainOpen} title="Drain {host?.name ?? 'host'}?" {busy}>
|
||||
<p>New deployments will stop on this host. Each instance will wait until its lobby is empty and another host has capacity, then be destroyed here and rebuilt there with the same server identity and map. Its address or port may change.</p>
|
||||
<p>If capacity is unavailable, instances stay here until space becomes available. After all migrations finish, this host will be disabled.</p>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#snippet actions()}<Button disabled={busy} onclick={() => drainOpen = false}>Cancel</Button><Button variant="danger" disabled={busy} onclick={() => mode('draining')}>{busy ? 'Starting drain…' : 'Confirm drain'}</Button>{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
|
||||
|
||||
Reference in New Issue
Block a user