Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
+64 -62
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
import ServerIdentity from './ServerIdentity.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import PluginGithubImport from './PluginGithubImport.svelte';
import ConfigEditor from './ConfigEditor.svelte';
@@ -60,9 +67,10 @@
let scopeDialog = $state<HTMLDialogElement | null>(null);
let uploadDialog = $state<HTMLDialogElement | null>(null);
let fleetServers: ServerRow[] = [];
let poll: ReturnType<typeof setInterval> | null = null;
const fleetServers = $derived($sharedFleet);
let loadError = $state('');
let limit = $state(25);
let request = 0;
const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame;
@@ -82,39 +90,27 @@
function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped';
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
}
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
return s === 'tested' ? 'online' :
s === 'test-failed' ? 'danger' :
'neutral';
return s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : s.scope_value === 't5' ? 'Black Ops I' : s.scope_value === 't6' ? 'Black Ops II' : s.scope_value ?? 'Unscoped';
}
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
const [detailRes, serversRes, scopeRes] = await Promise.all([
fetch(`/api/scripts/${id}`),
fetch('/api/servers'),
fetch('/api/scope-options')
]);
if (!detailRes.ok) { loading = false; return; }
const body = (await detailRes.json()) as {
script: Artifact; versions: Version[];
matchedServers: string[]; runningBetaId: number | null;
};
artifact = body.script;
versions = body.versions;
matchedServers = body.matchedServers;
runningBetaId = body.runningBetaId;
if (serversRes.ok) {
const s = (await serversRes.json()) as { servers: ServerRow[] };
fleetServers = s.servers;
}
if (scopeRes.ok) {
scopeOptions = (await scopeRes.json()) as ScopeOptions;
}
if (seedForm) seedScopeForm();
loading = false;
const requestedId = id;
const current = ++request;
try {
const [detailRes, scopeRes] = await Promise.all([
fetch('/api/scripts/' + requestedId),
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
]);
if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
const body = await detailRes.json();
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
if (requestedId !== id || current !== request) return;
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
if (scopes) scopeOptions = scopes;
if (seedForm) seedScopeForm();
loadError = '';
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
finally { if (requestedId === id && current === request) loading = false; }
}
function seedScopeForm(): void {
@@ -126,17 +122,14 @@
} else if (scopeType === 'map') {
scopeMap = artifact.scope_value ?? '';
const owner = fleetServers.find((sv) => sv.map === scopeMap);
scopeGame = owner?.game ?? '';
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
} else {
scopeGame = ''; scopeMap = '';
}
}
onMount(() => {
load();
poll = setInterval(() => load({ seedForm: false }), 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
@@ -163,7 +156,10 @@
}
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
if (!dialog) return;
if (e.target === dialog) dialog.close();
if (e.target === dialog && !uploadingVersion && !updatingScope) {
const bounds = dialog.getBoundingClientRect();
if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) dialog.close();
}
}
function pickVersionFile(e: Event): void {
@@ -189,7 +185,7 @@
versionFile = null; versionNotes = '';
uploadDialog?.close();
await load();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
uploadingVersion = false;
}
}
@@ -218,17 +214,19 @@
}
scopeDialog?.close();
await load();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
updatingScope = false;
}
}
async function deleteArtifact(): Promise<void> {
if (!confirm(`Delete this ${family}?`)) return;
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; }
goto(basePath);
if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
try {
const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
const body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
await goto(basePath);
} catch (error) { flash = (error as Error).message; }
}
let gameOptions = $derived([
@@ -246,11 +244,12 @@
];
</script>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else if !artifact}
{:else if !artifact && !loadError}
<EmptyState title="Not found" description="This {family} may have been deleted." />
{:else}
{:else if artifact}
{@const a = artifact}
<div class="page">
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
@@ -303,7 +302,7 @@
{:else if matchedServers.length === 0}
<span class="warn">No matching servers</span>
{:else}
{matchedServers.join(', ')}
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
{/if}
</span>
</div>
@@ -323,7 +322,7 @@
<span class="k">Latest version</span>
<span class="v mono">
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
<StatusBadge state={latestVersion.state} size="sm" />
</span>
</div>
{/if}
@@ -344,7 +343,7 @@
<EmptyState title="No versions yet" description="Upload a first version to get started." />
{:else}
<div class="version-list">
{#each versions as v (v.id)}
{#each versions.slice(0,limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
@@ -352,16 +351,18 @@
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
<span class="v-when">{v.uploaded_at}</span>
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<Badge variant={stateVariant(v.state)} size="sm">{v.state.replace('-', ' ')}</Badge>
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
{/if}
</div>
{/each}
</div>
{/if}
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
@@ -376,20 +377,20 @@
{#if flash}<div class="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button type="button" class="close" onclick={cancelScopeModal} aria-label="Close">×</button>
<button type="button" class="close" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
</header>
<Field label="Scope">
<div class="scope-row">
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
{#if scopeType === 'game' || scopeType === 'map'}
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
{/if}
{#if scopeType === 'map'}
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
{/if}
</div>
</Field>
@@ -404,11 +405,11 @@
</dialog>
<!-- Upload dialog -->
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
<dialog aria-label="Upload new version" oncancel={event => { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
<button type="button" class="close" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
</header>
<Field label="File">
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
@@ -589,4 +590,5 @@
.panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
.dlg { padding: var(--space-4); }
}
.matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); }
</style>