Group settings with explicit modes and one save action

This commit is contained in:
2026-10-07 19:06:56 -04:00
parent e4f37f6964
commit bab78038dc
7 changed files with 425 additions and 78 deletions
+3 -3
View File
@@ -248,9 +248,9 @@ See [native host management](native-hosts.md) for command delivery and recovery.
## Concurrency limits
Admin → Settings separates Concurrency and Retention, with independent
save actions. Existing fleet restart, startup and destruction limits continue to
apply. Additional per-host restart, startup and destruction limits, a combined
Admin → Settings groups Concurrency separately from history and storage settings,
with one Save changes action for all pending edits. Existing fleet restart, startup
and removal limits continue to apply. Additional per-host restart, startup and destruction limits, a combined
lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default
to 0 (no additional cap), preserving existing throughput on upgrade.
+18 -6
View File
@@ -1,18 +1,30 @@
# Fleet settings
Admin → Settings has independent **Concurrency** and **Retention**
sections. Settings persist in SQLite, apply without restarting the controller,
and are writable only by administrators. Saving one section preserves the other
section's saved values. Upgrades preserve existing values and add new defaults.
Admin → Settings groups fleet limits and retention by the work they affect.
Search by name or description, or choose a category in the section list. Each row
shows its default, effect, and whether it has changed. Switching categories or
searching keeps your pending edits.
The sticky **Save changes** bar saves all pending changes together. **Discard**
restores the last saved values. Settings persist in SQLite, apply without restarting
the controller, and are writable only by administrators. Upgrades preserve existing
values and add new defaults.
Use **Forever** for history with no age expiry and **No limit** for optional
concurrency or telemetry count caps. **No age expiry** on artifact versions still
allows the version-count policy to remove old unused versions. **No delay** applies
to the game-file grace period; **No minimum** applies to minimum retained counts.
Turning these modes off restores the last finite value. Numeric fields show the
unit beside the value.
## Concurrency
The existing fleet restart, startup and destruction limits default to 1. Additional
The existing fleet restart, startup and removal limits default to 1. Additional
limits default to 0 (no additional cap):
- Restarts per host, including starts and rebuild recovery.
- Startups per host, including creation and rebuilds.
- Destructions per host, including migration source removal.
- Removals per host, including migration source removal.
- Combined lifecycle work across create/start/stop/restart/rebuild/destroy.
- Rebuilds across the fleet, including host drain migrations.
- Hosts downloading or verifying game files simultaneously, including manual imports.
+59 -9
View File
@@ -101,10 +101,10 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
observationKeepDays: 'Routine operation history retention',
restartConcurrency: 'Concurrent restarts',
createConcurrency: 'Concurrent server startups',
destroyConcurrency: 'Concurrent server destructions',
destroyConcurrency: 'Concurrent server removals',
restartConcurrencyPerHost: 'Concurrent restarts per host',
createConcurrencyPerHost: 'Concurrent startups per host',
destroyConcurrencyPerHost: 'Concurrent destructions per host',
destroyConcurrencyPerHost: 'Concurrent server removals per host',
lifecycleConcurrency: 'Combined lifecycle limit',
rebuildConcurrency: 'Concurrent rebuilds',
preparationConcurrency: 'Hosts preparing game files simultaneously',
@@ -137,6 +137,56 @@ export function settingMinimum(key: keyof FleetSettings): number {
return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0;
}
export interface SettingsGroup {
id: string;
title: string;
description: string;
keys: (keyof FleetSettings)[];
}
/** Presentation groups do not change the saved schema or the section PATCH API. */
export const settingsGroups: SettingsGroup[] = [
{ id: 'concurrency', title: 'Concurrency', description: 'Limit simultaneous server operations and game-file preparation.', keys: concurrencyKeys },
{ id: 'operation-history', title: 'Operation history', description: 'Campaigns, server operations, maintenance, and routine checks.', keys: [
'deployKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'observationKeepDays', 'failureKeepDays',
'maintenanceKeepDays', 'recoveryKeepDays', 'betaKeepDays', 'historyKeepCount'
] },
{ id: 'matches-players', title: 'Matches & players', description: 'Saved matches, player records, and leaderboard history.', keys: ['matchKeepDays'] },
{ id: 'telemetry-metrics', title: 'Telemetry & metrics', description: 'Game diagnostics, fleet activity, and host resource samples.', keys: [
'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'metricsKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays'
] },
{ id: 'files-versions', title: 'Files & versions', description: 'File snapshots, saved versions, game files, and abandoned uploads.', keys: [
'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays'
] },
{ id: 'accounts-audit', title: 'Accounts & audit', description: 'Sign-in and account administration records.', keys: ['auditKeepDays'] }
];
/** Zero has a different meaning for age limits, count caps, and minimums. */
export function settingZeroMode(key: keyof FleetSettings): string | null {
if (settingMinimum(key) > 0) return null;
if (additionalConcurrencyKeys.includes(key) || key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal') return 'No limit';
if (key === 'versionKeepDays') return 'No age expiry';
if (key === 'gameFileGraceDays') return 'No delay';
if (key === 'versionKeepCount' || key === 'historyKeepCount') return 'No minimum';
return key.endsWith('Days') ? 'Forever' : null;
}
export function settingUnit(key: keyof FleetSettings): 'days' | 'records' | 'versions' | 'operations' | 'hosts' {
if (key.endsWith('Days')) return 'days';
if (key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal' || key === 'historyKeepCount') return 'records';
if (key === 'versionKeepCount' || key === 'gameFileKeepCount') return 'versions';
return key === 'preparationConcurrency' ? 'hosts' : 'operations';
}
const settingNumbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 20 });
export function settingDisplayValue(key: keyof FleetSettings, value: number): string {
if (!Number.isFinite(value)) return '—';
const zeroMode = settingZeroMode(key);
if (value === 0 && zeroMode) return zeroMode;
const unit = settingUnit(key);
return `${settingNumbers.format(value)} ${value === 1 ? unit.slice(0, -1) : unit}`;
}
export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
provisioningKeepDays: 'Completed lifecycle history. Recovery, source workflows and cancellation dependencies remain protected.',
actionKeepDays: 'Completed agent actions. Referenced commands and retry evidence remain protected. Expired actions keep compact receipts to prevent duplicate execution.',
@@ -147,15 +197,15 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
instanceResourceKeepDays: 'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
telemetryKeepDays: 'Raw received telemetry diagnostics. Independent of saved matches. Count limits also apply.',
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit. 0 disables this count cap.',
telemetryMaxTotal: 'Fleet-wide count cap for telemetry diagnostics. 0 disables this count cap.',
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit.',
telemetryMaxTotal: 'Fleet-wide count cap for telemetry diagnostics, in addition to the age limit.',
matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files. 0 keeps files indefinitely.',
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. Current and referenced versions are always protected.',
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files.',
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. With no age expiry, the version count policy may still remove older unused versions. Current and referenced versions are always protected.',
versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected. 0 adds no delay.',
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected.',
gameFileKeepCount: 'Keep this many recent published versions per game, in addition to every referenced or protected version.',
uploadKeepDays: 'Remove incomplete browser uploads after this many days without activity. Active transfers and publication are protected.',
auditKeepDays: 'Recorded authentication and account-administration events.',
@@ -167,9 +217,9 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
restartConcurrencyPerHost: 'Additional recovery limit on each host, including starts and rebuilds.',
createConcurrencyPerHost: 'Additional startup limit on each host, including rebuilds.',
destroyConcurrencyPerHost: 'Additional removal limit on each host, including migration source removal.',
lifecycleConcurrency: 'Combined fleet limit for creates, starts, stops, restarts, rebuilds and destructions. Each workflow counts once.',
lifecycleConcurrency: 'Combined fleet limit for creates, starts, stops, restarts, rebuilds and removals. Each workflow counts once.',
rebuildConcurrency: 'Additional fleet limit for rebuilds, including host drain migrations.',
preparationConcurrency: 'Maximum hosts downloading or verifying game files at once. Interrupted transfers keep their slot until reconciled.',
deployKeepDays: 'Eligible campaign records and file diffs. Pending, unresolved failed, held and recovery-linked campaigns are protected.',
observationKeepDays: 'Successful monitoring and file-read records. Failures and actions are retained. 0 keeps records indefinitely.'
observationKeepDays: 'Successful monitoring and file-read records. Failures and actions are retained.'
};
+242
View File
@@ -0,0 +1,242 @@
<script lang="ts">
import { untrack } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js';
import Button from './primitives/Button.svelte';
import RetentionTools from './RetentionTools.svelte';
import {
defaultFleetSettings, fleetSettingLabels, settingsGroups, settingHelp, settingMinimum,
settingZeroMode, settingUnit, settingDisplayValue, validateFleetSettings, type FleetSettings
} from '$lib/fleet-settings.js';
type Key = keyof FleetSettings;
type Draft = { [K in Key]: number | undefined };
let { initial }: { initial: FleetSettings } = $props();
const id = $props.id();
const keys = Object.keys(defaultFleetSettings) as Key[];
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
let draft = $state<Draft>({ ...untrack(() => initial) });
let remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
let query = $state('');
let selectedGroup = $state(settingsGroups[0].id);
let reviewInvalid = $state(false);
let saving = $state(false);
let error = $state('');
let notice = $state('');
let allowNavigation = false;
const changed = $derived(keys.filter(key => draft[key] !== baseline[key]));
const invalid = $derived(keys.filter(key => !!validationMessage(key, draft[key])));
const visibleGroups = $derived.by(() => {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
return settingsGroups.map(group => ({ ...group, keys: group.keys.filter(key => {
if (reviewInvalid && invalid.length) return invalid.includes(key);
if (!terms.length) return group.id === selectedGroup;
const text = `${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
return terms.every(term => text.includes(term));
}) })).filter(group => group.keys.length);
});
const shownCount = $derived(visibleGroups.reduce((count, group) => count + group.keys.length, 0));
function validationMessage(key: Key, value: number | undefined): string {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < settingMinimum(key)) return 'Enter a valid whole number.';
if (key.endsWith('Days') && value > 36500) return 'Enter 36,500 days or fewer.';
return '';
}
function toggleMode(key: Key, enabled: boolean) {
if (saving) return;
const current = draft[key];
if (enabled) {
if (current !== undefined && Number.isSafeInteger(current) && current > 0) remembered[key] = current;
draft[key] = 0;
} else {
draft[key] = remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
}
error = ''; notice = '';
}
function selectGroup(groupId: string) { selectedGroup = groupId; query = ''; reviewInvalid = false; }
function discard() {
if (saving) return;
draft = { ...baseline };
remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
error = ''; notice = ''; reviewInvalid = false;
}
async function save() {
if (saving || !changed.length || invalid.length) return;
saving = true; error = ''; notice = '';
try {
const values = validateFleetSettings(draft);
const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
baseline = validateFleetSettings(result);
draft = { ...baseline };
for (const key of keys) if (baseline[key] > 0) remembered[key] = baseline[key];
notice = 'Settings saved. Changes apply to the next queue or cleanup check.';
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not save fleet settings.'; }
finally { saving = false; }
}
beforeNavigate(({ cancel, to, from, type }) => {
if (!changed.length || allowNavigation || type === 'leave' || !to || to.url.pathname === from?.url.pathname) return;
cancel();
void confirmAction({ title: 'Leave Settings?', message: 'Your unsaved settings will be discarded.', confirmLabel: 'Leave settings', danger: true }).then(async accepted => {
if (!accepted) return;
allowNavigation = true;
try { await goto(to.url); } finally { allowNavigation = false; }
});
});
function beforeUnload(event: BeforeUnloadEvent) { if (changed.length) { event.preventDefault(); event.returnValue = ''; } }
</script>
<svelte:window onbeforeunload={beforeUnload} />
<div class="settings-layout">
<aside class="section-rail">
<label class="search-label" for="settings-search">Find a setting</label>
<input id="settings-search" type="search" placeholder="Search settings" bind:value={query} disabled={saving} oninput={() => reviewInvalid = false} />
<label class="mobile-section">Section
<select aria-label="Settings section" value={selectedGroup} disabled={saving} onchange={event => selectGroup(event.currentTarget.value)}>
{#each settingsGroups as group}<option value={group.id}>{group.title}{changed.some(key => group.keys.includes(key)) ? ` · ${changed.filter(key => group.keys.includes(key)).length} changed` : ''}</option>{/each}
</select>
</label>
<nav class="group-links" aria-label="Settings sections">
{#each settingsGroups as group}
{@const count = changed.filter(key => group.keys.includes(key)).length}
<a href={`#settings-group-${group.id}`} aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id ? 'location' : undefined} onclick={event => { event.preventDefault(); if (!saving) selectGroup(group.id); }}>
<span>{group.title}</span>{#if count}<span class="changed-count">{count} changed</span>{/if}
</a>
{/each}
</nav>
</aside>
<div class="settings-content">
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if query.trim()}<p class="search-results">{shownCount} {shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across all sections. <button type="button" class="text-button" onclick={() => query = ''}>Clear search</button></p>{/if}
{#if reviewInvalid && invalid.length}<p class="search-results">Showing invalid settings across all sections. <button type="button" class="text-button" onclick={() => reviewInvalid = false}>Show selected section</button></p>{/if}
<form id={`${id}-form`} onsubmit={event => { event.preventDefault(); void save(); }}>
<fieldset disabled={saving}>
<legend class="sr-only">Fleet settings</legend>
{#each visibleGroups as group (group.id)}
<section class="settings-group" id={`settings-group-${group.id}`} aria-labelledby={`settings-heading-${group.id}`}>
<header><h2 id={`settings-heading-${group.id}`}>{group.title}</h2><p>{group.description}</p></header>
<div class="table-wrap"><table>
<thead><tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Default</th><th scope="col">What it affects</th></tr></thead>
<tbody>{#each group.keys as key (key)}
{@const zeroMode = settingZeroMode(key)}
{@const unlimited = !!zeroMode && draft[key] === 0}
{@const validation = validationMessage(key, draft[key])}
<tr data-setting={key} class:changed={changed.includes(key)}>
<th scope="row"><label for={`setting-${key}`}>{fleetSettingLabels[key]}</label>{#if changed.includes(key)}<span class="changed-marker">Changed</span>{/if}</th>
<td class="value-cell">
<div class="numeric-value">
{#if unlimited}<input id={`setting-${key}`} type="number" disabled placeholder="—" aria-describedby={`setting-${key}-help setting-${key}-default`} />
{:else}<input id={`setting-${key}`} type="number" min={Math.max(1, settingMinimum(key))} max={key.endsWith('Days') ? 36500 : undefined} step="1" required bind:value={draft[key]} aria-invalid={!!validation} aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`} oninput={() => { error = ''; notice = ''; }} />{/if}
<span class="unit">{settingUnit(key)}</span>
</div>
{#if zeroMode}<label class="zero-mode" for={`setting-${key}-zero`}><input id={`setting-${key}-zero`} type="checkbox" checked={unlimited} onchange={event => toggleMode(key, event.currentTarget.checked)} aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`} aria-describedby={`setting-${key}-help`} /><span>{zeroMode}</span></label>{/if}
{#if validation}<p class="field-error" id={`setting-${key}-error`}>{validation}</p>{/if}
</td>
<td class="default-value" id={`setting-${key}-default`}><span class="mobile-label">Default: </span>{settingDisplayValue(key, defaultFleetSettings[key])}</td>
<td class="help" id={`setting-${key}-help`}>{settingHelp[key]}</td>
</tr>
{/each}</tbody>
</table></div>
{#if group.id === 'concurrency'}<p class="group-note">Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>{/if}
</section>
{:else}<p class="no-results">No settings match this search. Try a setting name, a section, or “Forever”.</p>{/each}
</fieldset>
</form>
<div class="retention-tools">
<fieldset disabled={saving || invalid.length > 0}>
<legend class="sr-only">Retention preview and protections</legend>
{#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if}
<RetentionTools settings={draft as FleetSettings} />
</fieldset>
</div>
</div>
</div>
<div class="save-bar">
<div class="save-summary">
<strong class:has-changes={changed.length > 0}>{changed.length ? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}` : 'All changes saved'}</strong>
<span>Applies to everyone. No restart needed.</span>
{#if invalid.length}<button type="button" class="invalid-summary text-button" onclick={() => { query = ''; reviewInvalid = true; }}>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid settings</button>{/if}
</div>
<div class="save-actions"><Button variant="ghost" disabled={saving || !changed.length} onclick={discard}>Discard</Button><button class="save-button" type="submit" form={`${id}-form`} disabled={saving || !changed.length || invalid.length > 0}>{saving ? 'Saving…' : 'Save changes'}</button></div>
</div>
<style>
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-5); align-items: start; }
.section-rail { position: sticky; top: 80px; display: grid; gap: var(--space-2); min-width: 0; }
.search-label { font-size: var(--text-sm); color: var(--text-secondary); }
#settings-search { width: 100%; }
.group-links { display: grid; gap: var(--space-1); margin-top: var(--space-3); }
.group-links a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 40px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); text-decoration: none; font-size: var(--text-sm); color: var(--text-secondary); }
.group-links a:hover, .group-links a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
.group-links a[aria-current] { font-weight: var(--weight-semibold); }
.changed-count { font-size: var(--text-xs); color: var(--state-warning); white-space: nowrap; }
.settings-content, fieldset { min-width: 0; }
fieldset { border: 0; padding: 0; margin: 0; }
.settings-group { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-4); }
.settings-group header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
header p { margin: 0; font-size: var(--text-xs); }
.table-wrap { position: relative; min-width: 0; overflow-x: auto; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-xs); text-align: left; }
th, td { padding: var(--space-3); vertical-align: top; border-bottom: 1px solid var(--border-subtle); }
th { font-weight: var(--weight-medium); }
thead th { color: var(--text-secondary); font-weight: var(--weight-semibold); }
thead th:nth-child(1) { width: 23%; }
thead th:nth-child(2) { width: 25%; }
thead th:nth-child(3) { width: 15%; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
.changed { background: color-mix(in srgb, var(--state-warning) 3%, transparent); }
.changed-marker { display: block; margin-top: var(--space-1); color: var(--state-warning); font-size: var(--text-xs); }
.numeric-value { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1); }
.numeric-value input { width: 88px; min-height: 32px; padding: 5px var(--space-2); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
input:disabled { color: var(--text-tertiary); opacity: 0.75; }
.unit { color: var(--text-secondary); font-size: var(--text-xs); }
.zero-mode { display: inline-flex; align-items: center; gap: var(--space-1); margin-top: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
.zero-mode input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); }
.default-value, .help { color: var(--text-secondary); line-height: var(--leading-body); }
.default-value { font-variant-numeric: tabular-nums; }
.field-error { margin: var(--space-1) 0 0; color: var(--state-danger); font-size: var(--text-xs); }
.group-note { margin: 0; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); }
.search-results { margin-top: 0; }
.text-button { padding: 0; background: none; border: 0; color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
.retention-tools { margin-top: var(--space-4); }
.save-bar { position: sticky; bottom: var(--space-3); z-index: 10; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); margin-top: var(--space-5); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-raised); }
.save-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.save-summary strong { font-weight: var(--weight-semibold); color: var(--text-primary); }
.save-summary strong.has-changes { color: var(--state-warning); }
.save-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
.save-button { min-height: 40px; padding: var(--space-2) var(--space-4); border: 1px solid transparent; border-radius: var(--radius-md); background: var(--accent-primary); color: var(--text-on-accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
.save-button:hover:not(:disabled) { background: var(--accent-primary-hover); }
.save-button:disabled { opacity: 0.5; cursor: not-allowed; }
.invalid-summary { color: var(--state-danger); }
.notice { color: var(--state-online); }
.mobile-section, .mobile-label { display: none; }
@media (max-width: 1100px) { .settings-layout { grid-template-columns: 180px minmax(0, 1fr); gap: var(--space-4); } thead th:nth-child(1) { width: 25%; } thead th:nth-child(2) { width: 27%; } thead th:nth-child(3) { width: 16%; } th, td { padding: var(--space-2); } }
@media (max-width: 720px) {
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
.section-rail { position: static; }
.group-links { display: none; }
.mobile-section { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-3); font-size: var(--text-sm); color: var(--text-secondary); }
.mobile-section select { width: 100%; }
.table-wrap { overflow: visible; }
table, tbody { display: block; }
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2); padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
tbody tr:last-child { border-bottom: 0; }
th, td { display: block; padding: 0; border: 0; }
tbody th { grid-column: 1 / -1; font-size: var(--text-sm); }
.changed-marker { display: inline; margin-left: var(--space-2); }
.value-cell { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
.zero-mode { min-height: 44px; margin-top: 0; }
.zero-mode input { width: 20px; height: 20px; }
.default-value, .help { grid-column: 1 / -1; }
.mobile-label { display: inline; }
.save-bar { bottom: max(var(--space-2), env(safe-area-inset-bottom)); padding: var(--space-3); gap: var(--space-2); }
.save-summary { gap: var(--space-1); flex-direction: column; align-items: flex-start; }
.save-actions { width: 100%; justify-content: flex-end; }
}
</style>
+10 -59
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import RetentionTools from '$ui/components/RetentionTools.svelte';
import SettingsEditor from '$ui/components/SettingsEditor.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import { defaultFleetSettings, fleetSettingLabels, additionalConcurrencyKeys, concurrencyKeys, retentionKeys, settingHelp, settingMinimum, type FleetSettings } from '$lib/fleet-settings.js';
import { validateFleetSettings, type FleetSettings } from '$lib/fleet-settings.js';
let settings = $state<FleetSettings | null>(null);
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let notice = $state('');
onMount(() => { void loadSettings(); });
async function loadSettings() {
@@ -16,68 +14,21 @@
try {
const response = await fetch('/api/settings');
if (!response.ok) throw new Error('Could not load fleet settings.');
settings = await response.json();
} catch (cause) { error = (cause as Error).message; }
settings = validateFleetSettings(await response.json());
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not load fleet settings.'; }
finally { loading = false; }
}
async function saveSettings(event: SubmitEvent, section: string, keys: (keyof FleetSettings)[]) {
event.preventDefault();
saving = true; error = ''; notice = '';
try {
const response = await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ section, values: Object.fromEntries(keys.map(key => [key, settings![key]])) }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
for (const key of keys) settings![key] = result[key];
notice = 'Fleet settings saved. Changes apply to the next queue or cleanup check.';
} catch (cause) { error = (cause as Error).message; }
finally { saving = false; }
}
</script>
<svelte:head><title>Settings · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Settings" subtitle="Manage shared fleet limits and history retention." />
<section class="guide" aria-labelledby="fleet-heading">
<h2 id="fleet-heading">Fleet settings</h2>
<p>Saved for everyone using this Electric Cherry instance. Changes apply without restarting the app.</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}
<p role="status">Loading fleet settings…</p>
{:else if settings}
{#each [{ title: 'Concurrency', keys: concurrencyKeys }, { title: 'Retention', keys: retentionKeys }] as section}
<section class="settings-section" aria-labelledby={section.title}>
<h2 id={section.title}>{section.title}</h2>
<form onsubmit={(event) => saveSettings(event, section.title, section.keys)}>
<fieldset disabled={saving}>
{#each section.keys as key}
<label for={key}>{fleetSettingLabels[key]}{key.endsWith('Days') ? ' (days)' : ''}</label>
<input id={key} type="number" min={settingMinimum(key)}
max={key.endsWith('Days') ? 36500 : undefined} step="1" required bind:value={settings[key]} aria-describedby={`${key}-help`} />
<p id={`${key}-help`}>Default: {defaultFleetSettings[key]}. {settingHelp[key] ?? ''} {additionalConcurrencyKeys.includes(key) ? '0 adds no extra limit.' : key === 'versionKeepDays' ? '0 disables age expiry; the version count policy may still apply.' : key.endsWith('Days') && key !== 'gameFileGraceDays' ? '0 keeps indefinitely.' : ''}</p>
{/each}
{#if section.title === 'Concurrency'}<p>Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>{/if}
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : `Save ${section.title.toLowerCase()}`}</Button>
</fieldset>
</form>
{#if section.title === 'Retention'}<RetentionTools {settings} />{/if}
</section>
{/each}
{:else}
<Button onclick={loadSettings}>Retry loading</Button>
{/if}
</section>
<SectionHeader title="Settings" subtitle="Shared fleet limits and history retention. Changes apply without restarting." />
{#if loading}<p role="status">Loading fleet settings…</p>
{:else if settings}<SettingsEditor initial={settings} />
{:else}<div><p class="ui-error" role="alert">{error}</p><Button onclick={loadSettings}>Retry loading</Button></div>{/if}
</section>
<style>
.page { display: grid; gap: 20px; }
.guide { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }
.settings-section { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
h2 { font-size: var(--text-md); margin: 0; }
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; }
fieldset { border: 0; padding: 0; margin: 20px 0 0; min-width: 0; }
fieldset label { display: block; margin: 20px 0 8px; font-size: var(--text-sm); }
fieldset input { width: 160px; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
.page { display: grid; gap: var(--space-4); }
p { font-size: var(--text-sm); color: var(--text-secondary); }
</style>
+51 -1
View File
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
import { defaultFleetSettings as defaults } from '../../src/lib/fleet-settings.js';
import { defaultFleetSettings as defaults, additionalConcurrencyKeys, fleetSettingLabels, settingDisplayValue, settingHelp,
settingMinimum, settingsGroups, settingUnit, settingZeroMode, validateFleetSettings, type FleetSettings } from '../../src/lib/fleet-settings.js';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
@@ -139,3 +140,52 @@ it('saves a section without overwriting changes in the other section', async ()
expect(response.status).toBe(200);
expect(await GET().json()).toMatchObject({ deployKeepDays: 42, rebuildConcurrency: 2 });
});
describe('settings presentation semantics', () => {
it('places every saved setting in exactly one searchable group', () => {
const grouped = settingsGroups.flatMap(group => group.keys);
expect(grouped.length).toBe(Object.keys(defaults).length);
expect(new Set(grouped).size).toBe(grouped.length);
expect([...grouped].sort()).toEqual(Object.keys(defaults).sort());
expect(settingsGroups.map(group => group.title)).toEqual([
'Concurrency', 'Operation history', 'Matches & players', 'Telemetry & metrics', 'Files & versions', 'Accounts & audit'
]);
});
it('distinguishes an unlimited cap from permanent retention, age expiry, and minimum counts', () => {
for (const key of [...additionalConcurrencyKeys, 'telemetryMaxPerServer', 'telemetryMaxTotal'] as (keyof FleetSettings)[]) {
expect(settingZeroMode(key)).toBe('No limit');
}
for (const key of Object.keys(defaults) as (keyof FleetSettings)[]) {
if (key.endsWith('Days') && !['versionKeepDays', 'gameFileGraceDays'].includes(key)) expect(settingZeroMode(key)).toBe('Forever');
if (settingMinimum(key) === 1) expect(settingZeroMode(key)).toBeNull();
}
expect(settingZeroMode('versionKeepDays')).toBe('No age expiry');
expect(settingZeroMode('gameFileGraceDays')).toBe('No delay');
expect(settingZeroMode('versionKeepCount')).toBe('No minimum');
expect(settingZeroMode('historyKeepCount')).toBe('No minimum');
expect(settingHelp.versionKeepDays).toContain('version count policy may still remove');
expect(settingHelp.versionKeepCount).toContain('a positive count removes older unused versions');
});
it('formats numeric values and defaults without changing their stored meaning', () => {
const original = structuredClone(defaults);
for (const key of Object.keys(defaults) as (keyof FleetSettings)[]) {
const zero = settingZeroMode(key);
if (zero) {
expect(settingDisplayValue(key, 0)).toBe(zero);
expect(validateFleetSettings({ ...defaults, [key]: 0 })[key]).toBe(0);
}
expect(settingDisplayValue(key, 1)).toBe(`1 ${settingUnit(key).slice(0, -1)}`);
expect(settingDisplayValue(key, 14)).toBe(`14 ${settingUnit(key)}`);
expect(validateFleetSettings({ ...defaults, [key]: 14 })[key]).toBe(14);
}
expect(settingDisplayValue('telemetryMaxTotal', 10_000)).toBe('10,000 records');
expect(settingDisplayValue('preparationConcurrency', 2)).toBe('2 hosts');
expect(settingDisplayValue('versionKeepCount', 1)).toBe('1 version');
expect(settingDisplayValue('matchKeepDays', NaN)).toBe('—');
expect(defaults).toEqual(original);
expect(fleetSettingLabels.destroyConcurrency).toBe('Concurrent server removals');
expect(fleetSettingLabels.destroyConcurrencyPerHost).toBe('Concurrent server removals per host');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import SettingsEditor from '../../src/lib/ui/components/SettingsEditor.svelte';
import { defaultFleetSettings } from '../../src/lib/fleet-settings.js';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
const html = (initial = defaultFleetSettings) => render(SettingsEditor, { props: { initial } }).body.replaceAll(/<!--.*?-->/g, '');
describe('grouped Settings editor', () => {
it('shows one section with meaningful defaults, and keeps every category reachable from the rail and mobile selector', () => {
const body = html();
expect(body).toContain('aria-label="Settings sections"');
expect(body).toContain('aria-label="Settings section"');
expect(body).toContain('href="#settings-group-matches-players"');
expect(body).toContain('href="#settings-group-files-versions"');
expect(body).toContain('id="setting-restartConcurrency"');
expect(body).not.toContain('id="setting-matchKeepDays"');
expect(body).toContain('1 operation');
expect(body).toContain('No limit');
expect(body).not.toMatch(/<input[^>]*value="0"/);
});
it('compares with the saved baseline rather than factory defaults and disables Save and Discard when unchanged', () => {
const body = html({ ...defaultFleetSettings, preparationConcurrency: 7 });
expect(body).toContain('All changes saved');
expect(body).not.toContain('unsaved changes');
expect(body).not.toContain('class="changed-marker"');
expect(body).toMatch(/<input[^>]*id="setting-preparationConcurrency"[^>]*value="7"/);
expect(body).toMatch(/<button[^>]*disabled[^>]*>[\s\S]*?<span[^>]*>Discard<\/span>/);
expect(body).toMatch(/<button[^>]*type="submit"[^>]*form="[^"]+"[^>]*disabled[^>]*>Save changes<\/button>/);
});
it('keeps zero-mode controls labelled and cleanup tools outside the one settings form', () => {
const body = html();
expect(body).toMatch(/<label[^>]*for="setting-restartConcurrencyPerHost-zero"/);
expect(body).toMatch(/<input[^>]*id="setting-restartConcurrencyPerHost-zero"[^>]*checked/);
expect(body.match(/<form\b/g)).toHaveLength(1);
expect(body.indexOf('</form>')).toBeLessThan(body.indexOf('Cleanup preview and protected records'));
expect(body).toContain('Expired data cannot be restored by increasing retention.');
expect(body).toContain('Player safety checks still apply.');
});
});