Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-11 23:30:40 -04:00
parent 253d47300c
commit 4ea291d305
48 changed files with 1684 additions and 933 deletions
+184 -176
View File
@@ -2,6 +2,7 @@
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -33,6 +34,7 @@
family: Family;
}
let { id, family }: Props = $props();
const editable = $derived(canOperate($page.data?.user?.role));
const basePath = $derived(`/${family}s`);
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
@@ -228,7 +230,7 @@
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
@@ -240,7 +242,7 @@
scopeDialog?.close();
}
function openUploadModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
releaseVersion = '';
versionFile = null;
versionNotes = '';
@@ -273,7 +275,7 @@
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!versionFile || uploadingVersion) return;
if (!editable || !versionFile || uploadingVersion) return;
uploadingVersion = true;
flash = null;
const form = new FormData();
@@ -304,7 +306,7 @@
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
@@ -351,6 +353,7 @@
}
async function deleteArtifact(): Promise<void> {
if (!editable) return;
if (
!(await confirmAction({
title: 'Delete ' + family + '?',
@@ -428,13 +431,15 @@
variant="primary"
href={contextHref(`/betas/${runningBetaId}`, $page.url)}
>View active beta</Button
>{:else if latestVersion.id !== a.current_version_id}<Button
>{:else if editable && latestVersion.id !== a.current_version_id}<Button
variant="primary"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]),
$page.url
)}>Test latest version</Button
>{:else}<span class="dim">Latest version is promoted</span>{/if}
>{:else if latestVersion.id === a.current_version_id}<span class="dim"
>Latest version is promoted</span
>{/if}
</div></Card
>{/if}
@@ -450,24 +455,24 @@
<Card padding="lg">
<div class="panel-head">
<h2>Details</h2>
<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>
{#if editable}<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>{/if}
</div>
<details class="file-settings">
@@ -584,7 +589,7 @@
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button
{#if editable && runningBetaId === null}<Button
size="sm"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: v.id }]),
@@ -604,7 +609,7 @@
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
{#if editable && family === 'plugin'}
<div id="github-releases">
<PluginGithubImport
pluginId={id}
@@ -615,170 +620,173 @@
</div>
{/if}
<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if editable}<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
{#if editable}<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
/>
{#if scopeType === 'game' || scopeType === 'map'}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
{#if scopeType === 'game' || scopeType === 'map'}
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added
·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added ·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button
variant="ghost"
onclick={cancelUploadModal}
disabled={uploadingVersion}>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>{/if}
</div>
{/if}
@@ -7,6 +7,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import type { BetaDeploymentServer } from '$ui/betas.js';
import Badge from './primitives/Badge.svelte';
@@ -52,13 +53,14 @@
evidenceKind = $state(''),
evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const editable = $derived(canOperate($page.data?.user?.role));
const hasDeletedServers = $derived(
deletedParticipants ||
test.servers.some((server) => server.server_deleted_at) ||
deployments?.some((server) => server.server_deleted_at)
);
const canSign = $derived(
!closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
editable && !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
);
const expired = $derived(
test.outcome === 'inconclusive' ||
@@ -139,6 +141,7 @@
evidenceLimit = 25;
});
export async function action(action: string, extra: Record<string, string> = {}) {
if (!editable) return;
if (
action === 'retry' &&
!(await confirmAction({
@@ -197,7 +200,7 @@
{#if !deployments}<StatusBadge state={test.outcome} />{/if}
</div>
{#if !closed && !deployments}
{#if !hasDeletedServers}<div class="actions">
{#if editable && !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button
size="sm"
disabled={busy}
@@ -502,7 +505,7 @@
value={Math.min(progress.current, progress.target)}
max={progress.target}
></progress>{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if editable && requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if canSign && server.observingAt}<div class="signoff">
<label
>Tester name and observation<textarea
+51 -44
View File
@@ -5,6 +5,8 @@
import { onMount } from 'svelte';
import { confirmAction } from '$ui/confirm.js';
import { beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
@@ -33,6 +35,7 @@
scopeValue: string | null;
onsaved: () => Promise<void>;
} = $props();
const editable = $derived(canOperate($page.data?.user?.role));
let selected = $state('');
let text = $state('');
let original = $state('');
@@ -45,7 +48,7 @@
let retry = $state(0);
let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
const dirty = $derived(text !== original || notes.trim().length > 0);
const dirty = $derived(editable && (text !== original || notes.trim().length > 0));
const ignoredWarnings = $derived(
configWarnings(text, kind === 'config').filter(
(warning) => warning.includes('ignored') || warning.includes('no value')
@@ -55,6 +58,7 @@
onMount(() => {
selected = String(versions[0]?.id ?? '');
ready = true;
if (!editable) return;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (
@@ -155,7 +159,7 @@
}))
)
return;
clearDraft();
if (editable) clearDraft();
selected = next;
success = '';
}
@@ -194,7 +198,7 @@
input.value = '';
}
async function save() {
if (saving || loading || runningBetaId !== null || !text.trim()) return;
if (!editable || saving || loading || runningBetaId !== null || !text.trim()) return;
saving = true;
error = '';
success = '';
@@ -223,12 +227,14 @@
</script>
<svelte:window onbeforeunload={beforeUnload} />
<section class="workbench" aria-label="Config editor">
<section class="workbench" aria-label={editable ? 'Config editor' : 'Config viewer'}>
<div class="editor-panel">
<div class="heading">
<div>
<span class="eyebrow">Edit · preview · version</span>
<h2>Configuration editor</h2>
<span class="eyebrow"
>{editable ? 'Edit · preview · version' : 'Read-only configuration'}</span
>
<h2>{editable ? 'Configuration editor' : 'Configuration viewer'}</h2>
</div>
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div>
@@ -246,16 +252,16 @@
>{/each}</Select
></Field
>
<label class="file-button"
>Import .cfg<input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving || loading || runningBetaId !== null}
/></label
>
{#if editable}<label class="file-button"
>Import .cfg<input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving || loading || runningBetaId !== null}
/></label
>{/if}
</div>
{#if recovered && !loading}<div class="recovery">
{#if editable && recovered && !loading}<div class="recovery">
<span>A previous draft is available in this browser.</span><Button
size="sm"
onclick={restoreDraft}
@@ -275,41 +281,42 @@
<ConfigTextEditor
bind:text
overlay={kind === 'config'}
disabled={saving || runningBetaId !== null}
readonly={!editable}
disabled={editable && (saving || runningBetaId !== null)}
/>
<Field class="notes-label" label="Version notes">
<Input
bind:value={notes}
placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null}
/></Field
>
<div class="actions ui-sticky-actions">
<span class="muted"
>{runningBetaId !== null
? 'Finish the active beta to save another version.'
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
class="save-warning"
>{ignoredWarnings.length} ignored or incomplete lines — review the open configuration
notes before saving.</strong
>{/if}</span
{#if editable}<Field class="notes-label" label="Version notes">
<Input
bind:value={notes}
placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null}
/></Field
>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
>Discard</Button
>
<Button
variant="primary"
onclick={save}
loading={saving}
disabled={!dirty || !text.trim() || runningBetaId !== null}
>Save new version</Button
>
</div>
<div class="actions ui-sticky-actions">
<span class="muted"
>{runningBetaId !== null
? 'Finish the active beta to save another version.'
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
class="save-warning"
>{ignoredWarnings.length} ignored or incomplete lines — review the open
configuration notes before saving.</strong
>{/if}</span
>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
>Discard</Button
>
<Button
variant="primary"
onclick={save}
loading={saving}
disabled={!dirty || !text.trim() || runningBetaId !== null}
>Save new version</Button
>
</div>{/if}
{/if}
</div>
{#if loadedVersion && !loading}<ConfigInspector
artifactId={id}
draftText={text}
draftText={editable ? text : undefined}
{scopeType}
{scopeValue}
/>{/if}
+5 -3
View File
@@ -171,7 +171,7 @@
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`;
anchor.download = `${selected}${draftText !== undefined ? '-draft' : ''}.cfg`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -181,7 +181,9 @@
<div class="heading">
<div>
{#if !fixedServer}<span class="eyebrow"
>{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span
>{draftText !== undefined
? 'Draft preview'
: 'Follow every value to its source'}</span
>{/if}
<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2>
</div>
@@ -190,7 +192,7 @@
>
</div>
<p class="intro">
{artifactId
{draftText !== undefined
? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
</p>
+121 -113
View File
@@ -3,6 +3,7 @@
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import Tabs from './primitives/Tabs.svelte';
@@ -18,6 +19,7 @@
import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
import { gameTitle } from '$ui/server-labels.js';
const editable = $derived(canOperate($page.data?.user?.role));
interface Version {
id: number;
@@ -69,7 +71,7 @@
{ view: 'library', limit: 25 }
);
});
let dialog: HTMLDialogElement;
let dialog = $state<HTMLDialogElement>();
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
let scopeType = $state<'game' | 'map'>('game');
@@ -164,9 +166,10 @@
void load();
});
function openCreate(type: 'config' | 'baseline' = 'config') {
if (!editable) return;
kind = type;
createError = '';
dialog.showModal();
dialog?.showModal();
}
function resetFilters() {
query = '';
@@ -189,6 +192,7 @@
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (!editable) return;
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) {
createError = 'Add at least one set KEY VALUE line.';
@@ -207,7 +211,7 @@
const response = await fetch('/api/configs', { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
dialog.close();
dialog?.close();
await goto(`/configs/${body.script.id}`);
} catch (err) {
createError = (err as Error).message;
@@ -219,7 +223,7 @@
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button
{#snippet actions()}{#if editable && (view === 'inspect' || loading || artifacts.length || error)}<Button
variant="primary"
onclick={() => openCreate()}>New config</Button
>{/if}{/snippet}
@@ -292,10 +296,10 @@
title="No configs yet"
description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions."
>
{#snippet actions()}<Button
variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button
>{/snippet}
{#snippet actions()}{#if editable}<Button
variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button
>{/if}{/snippet}
</EmptyState>
{:else if !filtered.length && !error}<EmptyState
title="No matching configs"
@@ -362,117 +366,121 @@
</div>
{/snippet}
<dialog
bind:this={dialog}
aria-labelledby="new-config-title"
oncancel={(event) => {
if (saving) event.preventDefault();
}}
>
<form onsubmit={create}>
<div class="dialog-heading">
<div>
<span class="eyebrow">Create version 1</span>
<h2 id="new-config-title">New config</h2>
{#if editable}<dialog
bind:this={dialog}
aria-labelledby="new-config-title"
oncancel={(event) => {
if (saving) event.preventDefault();
}}
>
<form onsubmit={create}>
<div class="dialog-heading">
<div>
<span class="eyebrow">Create version 1</span>
<h2 id="new-config-title">New config</h2>
</div>
<Button
variant="ghost"
onclick={() => dialog?.close()}
disabled={saving}
ariaLabel="Close new config">✕</Button
>
</div>
<Button
variant="ghost"
onclick={() => dialog.close()}
disabled={saving}
ariaLabel="Close new config">✕</Button
<div class="kind-picker">
<button
type="button"
class:active={kind === 'config'}
aria-pressed={kind === 'config'}
onclick={() => (kind = 'config')}
disabled={saving}
><strong>Overlay</strong><span>Override selected settings</span></button
><button
type="button"
class:active={kind === 'baseline'}
aria-pressed={kind === 'baseline'}
onclick={() => (kind = 'baseline')}
disabled={saving}
><strong>Baseline</strong><span>A complete config for a game</span></button
>
</div>
<div class="form-grid">
<Field label="Name">
<Input
bind:value={name}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
required
disabled={saving}
/></Field
><Field label="Game">
<Select
aria-label="Game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
required
disabled={saving}
><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}
>{gameTitle(option)}</option
>{/each}</Select
></Field
>
{#if kind === 'config'}<Field label="Apply to">
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map"
>One map</option
></Select
></Field
>{#if scopeType === 'map'}<Field label="Map">
<Select
aria-label="Map"
bind:value={scopeMap}
required
disabled={saving || !scopeGame}
><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option
>{/each}</Select
></Field
>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">
This game already has a baseline. Open it from the library to save a new
version.
</p>{/if}
{#if !gameOptions.length}<p class="warning">
Register a server first to make its game and map available here.
</p>{/if}
<label class="import"
>Start from a file <input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving}
/></label
>
</div>
<div class="kind-picker">
<button
type="button"
class:active={kind === 'config'}
aria-pressed={kind === 'config'}
onclick={() => (kind = 'config')}
disabled={saving}
><strong>Overlay</strong><span>Override selected settings</span></button
><button
type="button"
class:active={kind === 'baseline'}
aria-pressed={kind === 'baseline'}
onclick={() => (kind = 'baseline')}
disabled={saving}
><strong>Baseline</strong><span>A complete config for a game</span></button
>
</div>
<div class="form-grid">
<Field label="Name">
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<Field class="notes" label="Notes">
<Input
bind:value={name}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
required
bind:value={notes}
placeholder="What is this config for?"
disabled={saving}
/></Field
><Field label="Game">
<Select
aria-label="Game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
required
disabled={saving}
><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
>{/each}</Select
></Field
>
{#if kind === 'config'}<Field label="Apply to">
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map">One map</option
></Select
></Field
>{#if scopeType === 'map'}<Field label="Map">
<Select
aria-label="Map"
bind:value={scopeMap}
required
disabled={saving || !scopeGame}
><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option
>{/each}</Select
></Field
>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">
This game already has a baseline. Open it from the library to save a new version.
</p>{/if}
{#if !gameOptions.length}<p class="warning">
Register a server first to make its game and map available here.
</p>{/if}
<label class="import"
>Start from a file <input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving}
/></label
>
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<Field class="notes" label="Notes">
<Input
bind:value={notes}
placeholder="What is this config for?"
disabled={saving}
/></Field
>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions">
<p>Saved as untested. Test in a beta before promotion.</p>
<Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button
><Button
type="submit"
variant="primary"
loading={saving}
disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
>
</div>
</form>
</dialog>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions">
<p>Saved as untested. Test in a beta before promotion.</p>
<Button variant="ghost" onclick={() => dialog?.close()} disabled={saving}
>Cancel</Button
><Button
type="submit"
variant="primary"
loading={saving}
disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
>
</div>
</form>
</dialog>{/if}
<style>
.page {
+41 -30
View File
@@ -11,14 +11,16 @@
let {
text = $bindable(''),
overlay = false,
readonly = false,
disabled = false
}: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
}: { text?: string; overlay?: boolean; readonly?: boolean; disabled?: boolean } = $props();
let mode = $state<'raw' | 'settings'>('raw');
let search = $state('');
let newKey = $state('');
let newValue = $state('');
let addError = $state('');
function addSetting() {
if (readonly || disabled) return;
try {
text = addConfigSetting(text, newKey, newValue);
search = '';
@@ -64,6 +66,7 @@
<textarea
aria-label="Config contents"
bind:value={text}
{readonly}
{disabled}
spellcheck="false"
rows="12"
@@ -78,9 +81,10 @@
placeholder="Find a setting…"
/></Field
>
<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw file.
</p>
{#if !readonly}<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw
file.
</p>{/if}
{#each visible as entry (entry.index)}
<Field class="setting"
>{#snippet label()}<span
@@ -89,6 +93,7 @@
<Input
mono
value={entry.value}
{readonly}
{disabled}
aria-label={`${entry.key}, line ${entry.index + 1}`}
oninput={(e) =>
@@ -96,31 +101,36 @@
/>
</Field>
{:else}<p class="hint">
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
{entries.length
? 'No matching settings.'
: readonly
? 'No settings in this version.'
: 'Add your first setting below.'}
</p>{/each}
<div class="add-setting">
<Field label="Setting name">
<Input
mono
bind:value={newKey}
onkeydown={addOnEnter}
placeholder="g_inactivity"
{disabled}
/></Field
><Field label="Value">
<Input
mono
bind:value={newValue}
onkeydown={addOnEnter}
placeholder="0"
{disabled}
/></Field
><Button
size="sm"
onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button
>
</div>
{#if !readonly}<div class="add-setting">
<Field label="Setting name">
<Input
mono
bind:value={newKey}
onkeydown={addOnEnter}
placeholder="g_inactivity"
{disabled}
/></Field
><Field label="Value">
<Input
mono
bind:value={newValue}
onkeydown={addOnEnter}
placeholder="0"
{disabled}
/></Field
><Button
size="sm"
onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}
>Add setting</Button
>
</div>{/if}
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
</div>
{/if}
@@ -131,8 +141,9 @@
open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))}
>
<summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before
saving</summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}{readonly
? ''
: ' — review before saving'}</summary
>
<ul>
{#each warnings as warning}<li>{warning}</li>{/each}
+31 -29
View File
@@ -3,7 +3,7 @@
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js';
let { settings }: { settings: FleetSettings } = $props();
let { settings, readOnly = false }: { settings: FleetSettings; readOnly?: boolean } = $props();
import Button from './primitives/Button.svelte';
let busy = $state(false);
let error = $state('');
@@ -58,6 +58,7 @@
selectedCategory = category,
id = recordId
) {
if (readOnly || busy) return;
busy = true;
error = '';
notice = '';
@@ -116,11 +117,12 @@
<details>
<summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record">
<code>{record.id}</code><Button
disabled={busy}
onclick={() => control({ protected: true }, policy.category, record.id)}
>Protect</Button
>
<code>{record.id}</code>{#if !readOnly}<Button
disabled={busy}
onclick={() =>
control({ protected: true }, policy.category, record.id)}
>Protect</Button
>{/if}
</div>{/each}
</details>
{/each}
@@ -131,35 +133,35 @@
? ' · Resolved'
: ''}</span
>
{#if record.protected}<Button
{#if record.protected && !readOnly}<Button
disabled={busy}
onclick={() => control({ protected: false }, record.category, record.id)}
>Remove protection</Button
>{/if}
</div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{/if}
<h3>Protect a record or resolve a failure</h3>
<p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic
retry requests for that campaign; unfinished recovery still prevents cleanup.
</p>
<div class="record">
<Field label="Category">
<Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</Select
></Field
>
<Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field
>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button
>
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if}
</div>
{#if !readOnly}<h3>Protect a record or resolve a failure</h3>
<p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops
automatic retry requests for that campaign; unfinished recovery still prevents cleanup.
</p>
<div class="record">
<Field label="Category">
<Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</Select
></Field
>
<Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field
>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button
>
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if}
</div>{/if}
</details>
<style>
+39 -38
View File
@@ -22,7 +22,7 @@
type Key = keyof FleetSettings;
type Draft = { [K in Key]: number | undefined };
let { initial }: { initial: FleetSettings } = $props();
let { initial, readOnly = false }: { initial: FleetSettings; readOnly?: boolean } = $props();
const id = $props.id();
const keys = Object.keys(defaultFleetSettings) as Key[];
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
@@ -69,7 +69,7 @@
return '';
}
function toggleMode(key: Key, enabled: boolean) {
if (saving) return;
if (readOnly || saving) return;
const current = draft[key];
if (enabled) {
if (current !== undefined && Number.isSafeInteger(current) && current > 0)
@@ -88,7 +88,7 @@
reviewInvalid = false;
}
function discard() {
if (saving) return;
if (readOnly || saving) return;
draft = { ...baseline };
remembered = Object.fromEntries(
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
@@ -98,7 +98,7 @@
reviewInvalid = false;
}
async function save() {
if (saving || !changed.length || invalid.length) return;
if (readOnly || saving || !changed.length || invalid.length) return;
saving = true;
error = '';
notice = '';
@@ -124,6 +124,7 @@
beforeNavigate(({ cancel, to, from, type }) => {
if (
readOnly ||
!changed.length ||
allowNavigation ||
type === 'leave' ||
@@ -148,7 +149,7 @@
});
});
function beforeUnload(event: BeforeUnloadEvent) {
if (changed.length) {
if (!readOnly && changed.length) {
event.preventDefault();
event.returnValue = '';
}
@@ -227,7 +228,7 @@
void save();
}}
>
<fieldset disabled={saving}>
<fieldset disabled={readOnly || saving}>
<legend class="sr-only">Fleet settings</legend>
{#each visibleGroups as group (group.id)}
<section
@@ -352,42 +353,42 @@
<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} />
<RetentionTools settings={draft as FleetSettings} {readOnly} />
</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>
{#if !readOnly}<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>{/if}
<style>
.settings-layout {