Files
electric-cherry/src/routes/deployments/+page.svelte
T

259 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
interface Deployment {
id: string; server: string; action: 'create' | 'destroy';
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
canCancelRemoval: boolean;
}
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let error = $state('');
let open = $state(false);
let blueprintId = $state('');
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let housePartyOpen = $state(false);
let housePartyNotice = $state('');
let collections = $state<CollectionSummary[]>([]);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let tab = $state('active');
let limit = $state(25);
let collectionLimit = $state(10);
let historyState = $state('all');
let cancelling = $state(false);
let cancellationNotice = $state('');
$effect(() => {
const params = $page.url.searchParams;
tab = ['active', 'blueprints', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
action = ['create', 'destroy'].includes(params.get('action') ?? '') ? params.get('action')! : 'all';
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
search = params.get('server') ?? '';
limit = Math.max(25, Number(params.get('limit')) || 25);
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
});
function saveContext(reset = false) {
if (reset) { limit = 25; collectionLimit = 10; }
const url = new URL($page.url);
for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '', collections: collectionLimit > 10 ? String(collectionLimit) : '' })) {
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
}
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function cancelRequests(ids: string[]) {
cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
for (const id of ids) {
try { const response = await fetch(`/api/deployments/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
catch { unavailable.push(id); }
}
cancellationNotice = `${cancelled} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`;
cancelling = false; await load();
}
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]);
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
let action = $state('all');
let search = $state('');
let refreshing = false;
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
const attention = $derived(deployments.filter(needsAttention));
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
(historyState === 'all' || needsAttention(d)) &&
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
async function load() {
if (refreshing) return;
refreshing = true;
try {
const [res, collectionRes] = await Promise.all([fetch('/api/deployments'), fetch('/api/deployment-collections')]);
if (!res.ok || !collectionRes.ok) throw new Error('Could not load server deployments.');
deployments = (await res.json()).deployments;
collections = (await collectionRes.json()).collections;
error = '';
} catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; }
}
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
onMount(() => pollVisible(load, 5000));
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
if (!res.ok) throw new Error('Could not load your blueprints.');
blueprints = (await res.json()).blueprints;
blueprintError = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; }
}
async function removeBlueprint(id: string) {
if (!await confirmAction({title:'Remove blueprint?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove blueprint', danger:true})) return;
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove blueprint.');
blueprints = blueprints.filter(b => b.id !== id);
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
function fmt(value: string) { return $timeFormat.dateTime(value); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
</script>
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
{#snippet actions()}<Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
</SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if tab === 'blueprints'}
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading blueprints…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
{:else}
<div class="blueprints">
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview missing servers →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="blueprints" />
{:else}
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if visibleCollections.length}
<section>
<h2>Collection launches</h2>
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
<a class="deployment-row collection-row" href="/deployments/collections/{collection.id}">
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> ready</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} launching</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow launch' : 'View results'} →</span></div>
</a>
{/each}</div>
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" />
</section>
{/if}
{#if tab === 'active'}
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if}
{#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
<section>
<h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading deployments...</p>
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section>
{:else}
<section>
<h2>History</h2>
<div class="filters">
<label>Operation <select bind:value={action} onchange={event => { action = event.currentTarget.value; saveContext(true); }}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Removals</option></select></label>
<label>Status <select bind:value={historyState} onchange={event => { historyState = event.currentTarget.value; saveContext(true); }}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
<label>Server <input bind:value={search} oninput={event => { search = event.currentTarget.value; saveContext(true); }} placeholder="Filter by server name" /></label>
</div>
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
</section>
{/if}
{/if}
</div>
</div>
{#snippet row(d: Deployment)}
<article class="deployment-row"><a class="row-link" href="/deployments/{d.id}">
<div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
<strong class="server"><ServerIdentity name={d.server} /></strong>
</div>
<StatusBadge state={d.state} />
</div>
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server creation completed.' : 'Server destroyed.') : d.stage}</p>
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
{#if d.error}<p class="error">{d.error}</p>{/if}
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
</a>{#if d.canCancelRemoval}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
{/snippet}
<style>
.row-link { color: inherit; text-decoration: none; display: block; }.row-actions { margin-top: 12px; }
.page { display: flex; flex-direction: column; gap: 28px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); }
.rows { display: grid; gap: 12px; }
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.collection-row { display: grid; gap: 16px; }.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }.collection-footer .meta, .collection-link { font-size: 12px; }.collection-link { color: var(--accent-primary-hover); }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
.meta { font-size: 13px; color: var(--text-secondary); }
.error { color: var(--state-danger); }
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 0; } }
.panels { display:grid; gap:24px; }
.fleet-actions { position:relative; font-size:13px; }
.fleet-actions summary { padding:10px; cursor:pointer; }
.fleet-actions[open] :global(.btn) { position:absolute; right:0; top:100%; z-index:10; white-space:nowrap; box-shadow:var(--shadow-raised); }
</style>