Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+232 -54
View File
@@ -2,7 +2,13 @@
import { untrack } from 'svelte';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
import { actionLabels } from '$lib/provisioning.js';
import { fleetActions, fleetActionAvailable, queueFleetAction, type FleetAction, type FleetActionFailure } from '$ui/fleet-actions.js';
import {
fleetActions,
fleetActionAvailable,
queueFleetAction,
type FleetAction,
type FleetActionFailure
} from '$ui/fleet-actions.js';
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
import { serverHealth } from '$ui/server-health.js';
import { serverTitle } from '$ui/server-labels.js';
@@ -10,7 +16,10 @@
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
let {
open = $bindable(false),
onqueued
}: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
let servers = $state<FleetServer[]>([]);
let action = $state<FleetAction>('restart');
let selected = $state<string[]>([]);
@@ -25,71 +34,121 @@
start: 'Start the selected servers and verify their health.',
restart: 'Restart the selected servers once they are empty, then verify their health.',
stop: 'Stop the selected servers once they are empty. Their files, keys, and registrations are kept.',
rebuild: 'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
destroy: 'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
rebuild:
'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
destroy:
'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
};
const destructive = $derived(action === 'rebuild' || action === 'destroy');
const eligible = $derived(servers.filter(server => !queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)));
const targets = $derived(eligible.filter(server => selected.includes(server.name)));
const eligible = $derived(
servers.filter(
(server) =>
!queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)
)
);
const targets = $derived(eligible.filter((server) => selected.includes(server.name)));
const allSelected = $derived(eligible.length > 0 && targets.length === eligible.length);
$effect(() => {
if (!open) return;
untrack(() => { action = 'restart'; selected = []; queued = []; failures = []; confirmed = false; });
untrack(() => {
action = 'restart';
selected = [];
queued = [];
failures = [];
confirmed = false;
});
});
$effect(() => {
if (!open) return;
refresh;
void refresh;
const controller = new AbortController();
untrack(() => { void load(controller.signal); });
untrack(() => {
void load(controller.signal);
});
return () => controller.abort();
});
async function load(signal: AbortSignal) {
loading = true; error = '';
loading = true;
error = '';
try {
const response = await fetch('/api/servers', { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load servers.');
const body = await response.json();
if (signal.aborted) return;
servers = body.servers.sort((a: FleetServer, b: FleetServer) => serverTitle(a.name).localeCompare(serverTitle(b.name)));
servers = body.servers.sort((a: FleetServer, b: FleetServer) =>
serverTitle(a.name).localeCompare(serverTitle(b.name))
);
syncServerTime(body.serverNow);
} catch (cause) { if (!signal.aborted) error = cause instanceof Error ? cause.message : 'Could not load servers.'; }
finally { if (!signal.aborted) loading = false; }
} catch (cause) {
if (!signal.aborted)
error = cause instanceof Error ? cause.message : 'Could not load servers.';
} finally {
if (!signal.aborted) loading = false;
}
}
function chooseAction() {
selected = [];
confirmed = false;
failures = [];
}
function chooseAction() { selected = []; confirmed = false; failures = []; }
function toggle(name: string, checked: boolean) {
selected = checked ? [...selected, name] : selected.filter(value => value !== name);
selected = checked ? [...selected, name] : selected.filter((value) => value !== name);
confirmed = false;
}
function selectAll(checked: boolean) {
selected = checked ? eligible.map((server) => server.name) : [];
confirmed = false;
}
function selectAll(checked: boolean) { selected = checked ? eligible.map(server => server.name) : []; confirmed = false; }
async function submit() {
if (busy || loading || error || (destructive && !confirmed)) return;
const names = targets.filter(server => fleetActionAvailable(server, action, serverTimeNow())).map(server => server.name);
const names = targets
.filter((server) => fleetActionAvailable(server, action, serverTimeNow()))
.map((server) => server.name);
if (!names.length) return;
busy = true; failures = [];
busy = true;
failures = [];
try {
const result = await queueFleetAction(action, names);
queued = [...queued, ...result.queued.map(item => item.server)];
selected = selected.filter(name => !queued.includes(name));
queued = [...queued, ...result.queued.map((item) => item.server)];
selected = selected.filter((name) => !queued.includes(name));
failures = result.failed;
confirmed = false;
if (result.queued.length) onqueued(result.queued.length, action);
if (!result.failed.length) open = false;
} finally { busy = false; }
} finally {
busy = false;
}
}
</script>
<Dialog bind:open title="Fleet actions" description="Choose an action, then select the servers to apply it to." {busy} wide>
<Dialog
bind:open
title="Fleet actions"
description="Choose an action, then select the servers to apply it to."
{busy}
wide
>
<div class="fields">
<label class="action">Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
</select></label>
<label class="action"
>Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
</select></label
>
<p>{descriptions[action]}</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if loading}<p role="status">Loading the latest server status…</p>
{:else}
<div class="selection-tools">
<label class="check"><input type="checkbox" checked={allSelected} indeterminate={targets.length > 0 && !allSelected} disabled={busy || !!error || !eligible.length} onchange={event => selectAll(event.currentTarget.checked)} />Select all eligible</label>
<label class="check"
><input
type="checkbox"
checked={allSelected}
indeterminate={targets.length > 0 && !allSelected}
disabled={busy || !!error || !eligible.length}
onchange={(event) => selectAll(event.currentTarget.checked)}
/>Select all eligible</label
>
<span>{targets.length} selected · {eligible.length} eligible</span>
<Button size="sm" disabled={busy} onclick={() => refresh++}>Refresh servers</Button>
</div>
@@ -97,46 +156,165 @@
<fieldset disabled={busy || !!error} class="servers">
<legend>Servers</legend>
{#each servers as server (server.name)}
{@const available = eligible.some(item => item.name === server.name)}
{@const available = eligible.some((item) => item.name === server.name)}
{@const health = serverHealth(server, $uiNow)}
<label class="server" class:unavailable={!available}>
<input type="checkbox" checked={available && selected.includes(server.name)} disabled={!available} onchange={event => toggle(server.name, event.currentTarget.checked)} />
<span><strong>{serverTitle(server.name)}</strong><span class="metadata">{server.host?.name ?? 'Host unavailable'} · {health.label}</span>
{#if queued.includes(server.name)}<span class="metadata">Job queued</span>
{:else if !available}<span class="metadata">{actionLabels[action]} unavailable for the current server status.</span>
{:else}<span class="metadata">{lifecycleImpact(action, health.state === 'online' && freshServer(server, $uiNow) ? server.last_players : null, currentRound(server, server.telemetry, $uiNow))}</span>{/if}
<input
type="checkbox"
checked={available && selected.includes(server.name)}
disabled={!available}
onchange={(event) =>
toggle(server.name, event.currentTarget.checked)}
/>
<span
><strong>{serverTitle(server.name)}</strong><span class="metadata"
>{server.host?.name ?? 'Host unavailable'} · {health.label}</span
>
{#if queued.includes(server.name)}<span class="metadata"
>Job queued</span
>
{:else if !available}<span class="metadata"
>{actionLabels[action]} unavailable for the current server status.</span
>
{:else}<span class="metadata"
>{lifecycleImpact(
action,
health.state === 'online' && freshServer(server, $uiNow)
? server.last_players
: null,
currentRound(server, server.telemetry, $uiNow)
)}</span
>{/if}
</span>
</label>
{/each}
</fieldset>
{:else if !error}<p>No servers are available.</p>{/if}
{/if}
{#if action !== 'start'}<p>Each server waits for a live check confirming it is empty. Follow progress or cancel waiting jobs in Provisioning.</p>{/if}
{#if destructive}<p>Shared game libraries, recorded history, saved keys, and collections are retained.</p>{/if}
{#if destructive}<label class="check"><input type="checkbox" bind:checked={confirmed} disabled={busy || !targets.length} />I understand that the selected servers’ writable files and data will be permanently deleted.</label>{/if}
{#if failures.length}<div role="alert"><p class="ui-error">Some requests could not be queued:</p><ul>{#each failures as failure}<li><strong>{serverTitle(failure.server)}:</strong> {failure.error}</li>{/each}</ul></div>{/if}
{#if busy}<p role="status">Queuing {actionLabels[action].toLowerCase()} for the selected servers…</p>{/if}
{#if action !== 'start'}<p>
Each server waits for a live check confirming it is empty. Follow progress or cancel
waiting jobs in Provisioning.
</p>{/if}
{#if destructive}<p>
Shared game libraries, recorded history, saved keys, and collections are retained.
</p>{/if}
{#if destructive}<label class="check"
><input
type="checkbox"
bind:checked={confirmed}
disabled={busy || !targets.length}
/>I understand that the selected servers’ writable files and data will be
permanently deleted.</label
>{/if}
{#if failures.length}<div role="alert">
<p class="ui-error">Some requests could not be queued:</p>
<ul>
{#each failures as failure}<li>
<strong>{serverTitle(failure.server)}:</strong>
{failure.error}
</li>{/each}
</ul>
</div>{/if}
{#if busy}<p role="status">
Queuing {actionLabels[action].toLowerCase()} for the selected servers…
</p>{/if}
</div>
{#snippet actions()}
<Button disabled={busy} onclick={() => open = false}>Close</Button>
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)} loading={busy} onclick={submit}>{actionLabels[action]} {targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start' ? ' when empty' : ''}</Button>
<Button disabled={busy} onclick={() => (open = false)}>Close</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)}
loading={busy}
onclick={submit}
>{actionLabels[action]}
{targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start'
? ' when empty'
: ''}</Button
>
{/snippet}
</Dialog>
<style>
.fields, .action { display: grid; gap: var(--space-4); }
.action { gap: var(--space-2); }
select { width: 100%; min-height: 40px; padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
p, ul { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.check, .selection-tools { display: flex; align-items: center; gap: var(--space-3); }
.check { line-height: 1.5; }
input[type='checkbox'] { flex: 0 0 auto; width: 18px; height: 18px; accent-color: var(--accent-primary); }
.selection-tools { flex-wrap: wrap; justify-content: space-between; font-size: var(--text-sm); }
.servers { min-width: 0; margin: 0; padding: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); max-height: 360px; overflow-y: auto; }
legend { margin-left: var(--space-3); padding: 0 var(--space-2); }
.server { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); cursor: pointer; }
.server + .server { border-top: 1px solid var(--border-subtle); }
.server > span { min-width: 0; overflow-wrap: anywhere; }
.metadata { display: block; margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; }
.unavailable { cursor: default; }
.fields,
.action {
display: grid;
gap: var(--space-4);
}
.action {
gap: var(--space-2);
}
select {
width: 100%;
min-height: 40px;
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-primary);
font: inherit;
}
p,
ul {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.check,
.selection-tools {
display: flex;
align-items: center;
gap: var(--space-3);
}
.check {
line-height: 1.5;
}
input[type='checkbox'] {
flex: 0 0 auto;
width: 18px;
height: 18px;
accent-color: var(--accent-primary);
}
.selection-tools {
flex-wrap: wrap;
justify-content: space-between;
font-size: var(--text-sm);
}
.servers {
min-width: 0;
margin: 0;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
max-height: 360px;
overflow-y: auto;
}
legend {
margin-left: var(--space-3);
padding: 0 var(--space-2);
}
.server {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
cursor: pointer;
}
.server + .server {
border-top: 1px solid var(--border-subtle);
}
.server > span {
min-width: 0;
overflow-wrap: anywhere;
}
.metadata {
display: block;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.unavailable {
cursor: default;
}
</style>