Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
@@ -31,13 +38,23 @@
|
||||
let blueprintsLoading = $state(true);
|
||||
let blueprintError = $state('');
|
||||
let removing = $state('');
|
||||
let confirmRemove = $state('');
|
||||
let tab = $state('active');
|
||||
let limit = $state(25);
|
||||
let collectionLimit = $state(10);
|
||||
let historyState = $state('all');
|
||||
$effect(() => { action; search; tab; historyState; limit = 25; collectionLimit = 10; });
|
||||
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) &&
|
||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||
(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;
|
||||
@@ -54,12 +71,7 @@
|
||||
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(() => {
|
||||
void load();
|
||||
void loadBlueprints();
|
||||
const timer = setInterval(load, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
onMount(() => pollVisible(load, 5000));
|
||||
async function loadBlueprints() {
|
||||
try {
|
||||
const res = await fetch('/api/blueprints');
|
||||
@@ -70,11 +82,12 @@
|
||||
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); confirmRemove = '';
|
||||
blueprints = blueprints.filter(b => b.id !== id);
|
||||
} catch (err) { blueprintError = (err as Error).message; }
|
||||
finally { removing = ''; }
|
||||
}
|
||||
@@ -87,20 +100,18 @@
|
||||
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." />
|
||||
<div class="launch-banner">
|
||||
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps and target lobby counts. Existing and queued servers count toward the blueprint; preview and create only the shortfall.</p></div>
|
||||
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
|
||||
</div>
|
||||
<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} 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} />
|
||||
<div class="fleet-shutdown">
|
||||
<div><strong>Call it a night.</strong><p>Queue the whole fleet for removal once each server is empty.</p></div>
|
||||
<Button variant="danger" onclick={() => housePartyOpen = true}>House Party Protocol</Button>
|
||||
</div>
|
||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in In flight below.`; void load(); }} />
|
||||
<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}
|
||||
<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}
|
||||
@@ -108,7 +119,7 @@
|
||||
{: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 as blueprint (blueprint.id)}
|
||||
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
|
||||
<article class="blueprint">
|
||||
<div class="blueprint-top">
|
||||
<span class="eyebrow">Server collection</span>
|
||||
@@ -120,57 +131,61 @@
|
||||
<div class="blueprint-footer">
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
|
||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
||||
</div>
|
||||
{#if confirmRemove === blueprint.id}
|
||||
<div class="remove-confirm">
|
||||
<p>Remove this blueprint? Existing servers and launch history are kept.</p>
|
||||
<button disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>{removing === blueprint.id ? 'Removing…' : 'Remove blueprint'}</button>
|
||||
<button disabled={!!removing} onclick={() => confirmRemove = ''}>Keep it</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={blueprints.length} bind:limit label="blueprints" />
|
||||
{:else}
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if collections.length}
|
||||
{#if visibleCollections.length}
|
||||
<section>
|
||||
<h2>Collection launches</h2>
|
||||
<div class="rows">{#each collections as collection (collection.id)}
|
||||
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
|
||||
<a class="deployment-row" href="/deployments/collections/{collection.id}">
|
||||
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.failed ? 'danger' : collection.active ? 'info' : 'online'}>{collection.active ? 'In progress' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
|
||||
<p>{collection.done} of {collection.total} servers verified{collection.failed ? ` · ${collection.failed} need attention` : ''}{collection.active ? ` · ${collection.active} in progress` : ''}</p>
|
||||
<span class="meta">Started {fmt(collection.created_at)}</span>
|
||||
</a>
|
||||
{/each}</div>
|
||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} 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'; }}>Review history</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}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Destructions</option></select></label>
|
||||
<label>Status <select bind:value={historyState}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
|
||||
<label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
|
||||
</div>
|
||||
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="History is recorded for server operations performed after this update, including failures." />
|
||||
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
{#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 label="operations" />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet row(d: Deployment)}
|
||||
<a class="deployment-row" 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">{d.server}</strong>
|
||||
<strong class="server"><ServerIdentity name={d.server} /></strong>
|
||||
</div>
|
||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||
<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>
|
||||
@@ -180,22 +195,21 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.fleet-shutdown { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
|
||||
.fleet-shutdown p { margin: 5px 0 0; font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.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; }
|
||||
.launch-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; border: 1px solid var(--border-strong); border-radius: 16px; padding: 28px; background: linear-gradient(120deg, color-mix(in oklab, var(--accent-primary) 10%, var(--surface)), var(--surface)); }
|
||||
.launch-banner > div { max-width: 650px; }.launch-banner h2 { font-size: 25px; letter-spacing: -.03em; }.launch-banner p, .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.banner-actions { display: flex; gap: 8px; flex-wrap: wrap; }.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: 10px; 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: 12px; 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: 11px; 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, .remove-confirm button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.remove-confirm { border-top: 1px solid var(--border-subtle); margin-top: 14px; }.remove-confirm button { margin-right: 12px; }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: 12px; padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
|
||||
|
||||
.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: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||
@@ -204,4 +218,8 @@
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user