From bab78038dcac83e8e42fb7ea3bf28d06a520f555 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 19:06:56 -0400 Subject: [PATCH] Group settings with explicit modes and one save action --- docs/guides/provisioning.md | 6 +- docs/guides/settings.md | 24 +- src/lib/fleet-settings.ts | 68 +++++- src/lib/ui/components/SettingsEditor.svelte | 242 ++++++++++++++++++++ src/routes/settings/+page.svelte | 69 +----- tests/unit/fleet-settings.test.ts | 52 ++++- tests/unit/ui-settings.test.ts | 42 ++++ 7 files changed, 425 insertions(+), 78 deletions(-) create mode 100644 src/lib/ui/components/SettingsEditor.svelte create mode 100644 tests/unit/ui-settings.test.ts diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index f333ca5..8c1dea3 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -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. diff --git a/docs/guides/settings.md b/docs/guides/settings.md index 6ce83a8..e0c263b 100644 --- a/docs/guides/settings.md +++ b/docs/guides/settings.md @@ -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. diff --git a/src/lib/fleet-settings.ts b/src/lib/fleet-settings.ts index 1a852bb..a2d44a8 100644 --- a/src/lib/fleet-settings.ts +++ b/src/lib/fleet-settings.ts @@ -101,10 +101,10 @@ export const fleetSettingLabels: Record = { 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> = { 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> = { 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> = { 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.' }; diff --git a/src/lib/ui/components/SettingsEditor.svelte b/src/lib/ui/components/SettingsEditor.svelte new file mode 100644 index 0000000..eba3387 --- /dev/null +++ b/src/lib/ui/components/SettingsEditor.svelte @@ -0,0 +1,242 @@ + + + +
+ +
+ {#if error}{/if} + {#if notice}

{notice}

{/if} + {#if query.trim()}

{shownCount} {shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across all sections.

{/if} + {#if reviewInvalid && invalid.length}

Showing invalid settings across all sections.

{/if} +
{ event.preventDefault(); void save(); }}> +
+ Fleet settings + {#each visibleGroups as group (group.id)} +
+

{group.title}

{group.description}

+
+ + {#each group.keys as key (key)} + {@const zeroMode = settingZeroMode(key)} + {@const unlimited = !!zeroMode && draft[key] === 0} + {@const validation = validationMessage(key, draft[key])} + + + + + + + {/each} +
SettingValueDefaultWhat it affects
{#if changed.includes(key)}Changed{/if} +
+ {#if unlimited} + {:else} { error = ''; notice = ''; }} />{/if} + {settingUnit(key)} +
+ {#if zeroMode}{/if} + {#if validation}

{validation}

{/if} +
Default: {settingDisplayValue(key, defaultFleetSettings[key])}{settingHelp[key]}
+ {#if group.id === 'concurrency'}

Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.

{/if} +
+ {:else}

No settings match this search. Try a setting name, a section, or “Forever”.

{/each} +
+
+
+
0}> + Retention preview and protections + {#if invalid.length}

Correct invalid values to preview cleanup.

{/if} + +
+
+
+
+
+
+ 0}>{changed.length ? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}` : 'All changes saved'} + Applies to everyone. No restart needed. + {#if invalid.length}{/if} +
+
+
+ + diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 33f5c01..31ee991 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,14 +1,12 @@ Settings · Electric Cherry
- -
-

Fleet settings

-

Saved for everyone using this Electric Cherry instance. Changes apply without restarting the app.

- {#if error}{/if} - {#if notice}

{notice}

{/if} - {#if loading} -

Loading fleet settings…

- {:else if settings} - {#each [{ title: 'Concurrency', keys: concurrencyKeys }, { title: 'Retention', keys: retentionKeys }] as section} -
-

{section.title}

-
saveSettings(event, section.title, section.keys)}> -
- {#each section.keys as key} - - -

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.' : ''}

- {/each} - {#if section.title === 'Concurrency'}

Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.

{/if} - -
-
- {#if section.title === 'Retention'}{/if} -
- {/each} - {:else} - - {/if} -
- + + {#if loading}

Loading fleet settings…

+ {:else if settings} + {:else}
{/if}
diff --git a/tests/unit/fleet-settings.test.ts b/tests/unit/fleet-settings.test.ts index 5f2338e..168bcd0 100644 --- a/tests/unit/fleet-settings.test.ts +++ b/tests/unit/fleet-settings.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/ui-settings.test.ts b/tests/unit/ui-settings.test.ts new file mode 100644 index 0000000..71374b1 --- /dev/null +++ b/tests/unit/ui-settings.test.ts @@ -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(/]*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(/]*id="setting-preparationConcurrency"[^>]*value="7"/); + expect(body).toMatch(/]*disabled[^>]*>[\s\S]*?]*>Discard<\/span>/); + expect(body).toMatch(/]*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(/]*for="setting-restartConcurrencyPerHost-zero"/); + expect(body).toMatch(/]*id="setting-restartConcurrencyPerHost-zero"[^>]*checked/); + expect(body.match(/')).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.'); + }); +});