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
+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>