This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user