1124 lines
42 KiB
Svelte
1124 lines
42 KiB
Svelte
<script lang="ts">
|
||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||
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';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
|
||
import ServerIdentity from './ServerIdentity.svelte';
|
||
import ListFooter from './primitives/ListFooter.svelte';
|
||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
|
||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||
import ConfigEditor from './ConfigEditor.svelte';
|
||
import type { PluginSource } from '$lib/plugin-github.js';
|
||
import { goto } from '$app/navigation';
|
||
import Card from './primitives/Card.svelte';
|
||
import Badge from './primitives/Badge.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||
import Breadcrumbs from './primitives/Breadcrumbs.svelte';
|
||
import EmptyState from './primitives/EmptyState.svelte';
|
||
import Field from './primitives/Field.svelte';
|
||
import Input from './primitives/Input.svelte';
|
||
import Select from './primitives/Select.svelte';
|
||
|
||
type Family = 'script' | 'config' | 'plugin';
|
||
|
||
interface Props {
|
||
id: number;
|
||
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');
|
||
|
||
interface Artifact {
|
||
id: number;
|
||
name: string;
|
||
target_rel_path: string;
|
||
scope_type: 'game' | 'map' | null;
|
||
scope_value: string | null;
|
||
current_version_id: number | null;
|
||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||
created_at: string;
|
||
}
|
||
interface Version {
|
||
id: number;
|
||
version: number;
|
||
sha256: string;
|
||
size: number;
|
||
release_version: string | null;
|
||
source: PluginSource | null;
|
||
notes: string | null;
|
||
state: 'untested' | 'tested' | 'test-failed';
|
||
uploaded_at: string;
|
||
}
|
||
interface ScopeOptions {
|
||
games: string[];
|
||
mapsByGame: Record<string, string[]>;
|
||
}
|
||
|
||
let artifact = $state<Artifact | null>(null);
|
||
let versions = $state<Version[]>([]);
|
||
let matchedServers = $state<string[]>([]);
|
||
let installedVersions = $state<
|
||
Array<{
|
||
server: string;
|
||
version: number;
|
||
versionId: number;
|
||
release_version: string | null;
|
||
recordedAt: string;
|
||
}>
|
||
>([]);
|
||
interface ScopeImpact {
|
||
before: string[];
|
||
after: string[];
|
||
added: string[];
|
||
removed: string[];
|
||
affected: string[];
|
||
composed: boolean;
|
||
detached: string[];
|
||
}
|
||
let scopeReviewOpen = $state(false);
|
||
let scopeImpact = $state<ScopeImpact | null>(null);
|
||
let scopePreviewError = $state('');
|
||
let scopePreviewLoading = $state(false);
|
||
let scopeComplete = $state<{
|
||
count: number;
|
||
composed: boolean;
|
||
detached: number;
|
||
href: string;
|
||
} | null>(null);
|
||
let runningBetaId = $state<number | null>(null);
|
||
let loading = $state(true);
|
||
let flash = $state<string | null>(null);
|
||
|
||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||
let scopeGame = $state('');
|
||
let scopeMap = $state('');
|
||
let updatingScope = $state(false);
|
||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||
|
||
let versionFile = $state<File | null>(null);
|
||
let versionNotes = $state('');
|
||
let releaseVersion = $state('');
|
||
let versionManifest = $state<Record<string, string>>({});
|
||
let uploadingVersion = $state(false);
|
||
|
||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||
let uploadDialog = $state<HTMLDialogElement | null>(null);
|
||
const fleetServers = $derived($sharedFleet);
|
||
let loadError = $state('');
|
||
let limit = $state(25);
|
||
let request = 0;
|
||
|
||
const editedScopeValue = $derived.by(() => {
|
||
if (scopeType === 'game') return scopeGame;
|
||
if (scopeType === 'map') return scopeMap;
|
||
return '';
|
||
});
|
||
|
||
const currentVersion = $derived(
|
||
artifact?.current_version_id
|
||
? (versions.find((v) => v.id === artifact!.current_version_id) ?? null)
|
||
: null
|
||
);
|
||
const latestVersion = $derived(versions[0] ?? null);
|
||
|
||
$effect(() => {
|
||
const type = scopeType;
|
||
const value = editedScopeValue;
|
||
const artifactId = id;
|
||
if (!scopeReviewOpen) return;
|
||
scopeImpact = null;
|
||
scopePreviewError = '';
|
||
if (type !== 'none' && !value) {
|
||
scopePreviewLoading = false;
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
scopePreviewLoading = true;
|
||
void (async () => {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`,
|
||
{ signal: controller.signal }
|
||
);
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
|
||
if (!controller.signal.aborted) scopeImpact = body;
|
||
} catch (error) {
|
||
if (!controller.signal.aborted) scopePreviewError = (error as Error).message;
|
||
} finally {
|
||
if (!controller.signal.aborted) scopePreviewLoading = false;
|
||
}
|
||
})();
|
||
return () => controller.abort();
|
||
});
|
||
|
||
function scopeLabel(s: Artifact): string {
|
||
if (!s.scope_type) return 'unscoped';
|
||
return s.scope_type === 'map'
|
||
? mapLabel(s.scope_value ?? '')
|
||
: s.scope_value === 't5'
|
||
? 'Black Ops I'
|
||
: s.scope_value === 't6'
|
||
? 'Black Ops II'
|
||
: (s.scope_value ?? 'Unscoped');
|
||
}
|
||
|
||
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
|
||
const requestedId = id;
|
||
const current = ++request;
|
||
try {
|
||
const [detailRes, scopeRes] = await Promise.all([
|
||
fetch('/api/scripts/' + requestedId),
|
||
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
|
||
]);
|
||
if (!detailRes.ok)
|
||
throw new Error(
|
||
detailRes.status === 404
|
||
? 'This file no longer exists.'
|
||
: 'Could not refresh file details.'
|
||
);
|
||
const body = await detailRes.json();
|
||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||
if (requestedId !== id || current !== request) return;
|
||
artifact = body.script;
|
||
versions = body.versions;
|
||
matchedServers = body.matchedServers;
|
||
runningBetaId = body.runningBetaId;
|
||
installedVersions = body.installedVersions ?? [];
|
||
if (scopes) scopeOptions = scopes;
|
||
if (seedForm) seedScopeForm();
|
||
loadError = '';
|
||
} catch (error) {
|
||
if (requestedId === id && current === request) loadError = (error as Error).message;
|
||
} finally {
|
||
if (requestedId === id && current === request) loading = false;
|
||
}
|
||
}
|
||
|
||
function seedScopeForm(): void {
|
||
if (!artifact) return;
|
||
scopeType = artifact.scope_type ?? 'none';
|
||
if (scopeType === 'game') {
|
||
scopeGame = artifact.scope_value ?? '';
|
||
scopeMap = '';
|
||
} else if (scopeType === 'map') {
|
||
scopeMap = artifact.scope_value ?? '';
|
||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||
scopeGame =
|
||
owner?.game ??
|
||
Object.keys(scopeOptions.mapsByGame).find((game) =>
|
||
scopeOptions.mapsByGame[game].includes(scopeMap)
|
||
) ??
|
||
'';
|
||
} else {
|
||
scopeGame = '';
|
||
scopeMap = '';
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
void id;
|
||
loading = true;
|
||
artifact = null;
|
||
limit = 25;
|
||
void load();
|
||
});
|
||
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
|
||
|
||
function openScopeModal(): void {
|
||
if (!editable || runningBetaId !== null) return;
|
||
seedScopeForm();
|
||
flash = null;
|
||
scopeReviewOpen = true;
|
||
scopeDialog?.showModal();
|
||
}
|
||
function cancelScopeModal(): void {
|
||
seedScopeForm();
|
||
scopeReviewOpen = false;
|
||
scopeDialog?.close();
|
||
}
|
||
function openUploadModal(): void {
|
||
if (!editable || runningBetaId !== null) return;
|
||
releaseVersion = '';
|
||
versionManifest = {};
|
||
versionFile = null;
|
||
versionNotes = '';
|
||
flash = null;
|
||
uploadDialog?.showModal();
|
||
}
|
||
function cancelUploadModal(): void {
|
||
versionFile = null;
|
||
versionNotes = '';
|
||
uploadDialog?.close();
|
||
}
|
||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||
if (!dialog) return;
|
||
if (e.target === dialog && !uploadingVersion && !updatingScope) {
|
||
const bounds = dialog.getBoundingClientRect();
|
||
if (
|
||
e.clientX < bounds.left ||
|
||
e.clientX > bounds.right ||
|
||
e.clientY < bounds.top ||
|
||
e.clientY > bounds.bottom
|
||
)
|
||
dialog.close();
|
||
}
|
||
}
|
||
|
||
function pickVersionFile(e: Event): void {
|
||
const t = e.target as HTMLInputElement;
|
||
versionFile = t.files?.[0] ?? null;
|
||
if (!releaseVersion.trim() && versionFile)
|
||
releaseVersion = manifestVersion(versionManifest, versionFile.name);
|
||
}
|
||
|
||
async function uploadVersion(e: Event): Promise<void> {
|
||
e.preventDefault();
|
||
if (!editable || !versionFile || uploadingVersion) return;
|
||
if (family === 'script' && scriptVersionError(releaseVersion)) {
|
||
flash = scriptVersionError(releaseVersion);
|
||
return;
|
||
}
|
||
uploadingVersion = true;
|
||
flash = null;
|
||
const form = new FormData();
|
||
form.append('content', versionFile);
|
||
if (family === 'plugin' || family === 'script')
|
||
form.append('releaseVersion', releaseVersion.trim());
|
||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||
try {
|
||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||
const body = (await res.json()) as {
|
||
ok: boolean;
|
||
error?: string;
|
||
betaRequired?: boolean;
|
||
};
|
||
if (!res.ok || !body.ok) {
|
||
flash = body.error ?? `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
versionFile = null;
|
||
versionNotes = '';
|
||
uploadDialog?.close();
|
||
await load();
|
||
} catch (error) {
|
||
flash = (error as Error).message;
|
||
} finally {
|
||
uploadingVersion = false;
|
||
}
|
||
}
|
||
|
||
async function saveScope(e: Event): Promise<void> {
|
||
e.preventDefault();
|
||
if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return;
|
||
if (scopeType !== 'none' && !editedScopeValue) {
|
||
flash = `Pick a ${scopeType}`;
|
||
return;
|
||
}
|
||
updatingScope = true;
|
||
flash = null;
|
||
try {
|
||
const res = await fetch(`/api/scripts/${id}/scope`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
scopeType: scopeType === 'none' ? null : scopeType,
|
||
scopeValue: scopeType === 'none' ? null : editedScopeValue
|
||
})
|
||
});
|
||
const body = (await res.json()) as {
|
||
ok: boolean;
|
||
error?: string;
|
||
kind?: string;
|
||
detached?: string[];
|
||
};
|
||
if (!res.ok || !body.ok) {
|
||
flash = body.error ?? `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
scopeComplete = {
|
||
count: scopeImpact.affected.length,
|
||
composed: scopeImpact.composed,
|
||
detached: body.detached?.length ?? 0,
|
||
href:
|
||
scopeImpact.affected.length === 1
|
||
? '/maintenance?kind=drift&server=' +
|
||
encodeURIComponent(scopeImpact.affected[0])
|
||
: '/maintenance?kind=drift'
|
||
};
|
||
scopeReviewOpen = false;
|
||
scopeDialog?.close();
|
||
await load();
|
||
} catch (error) {
|
||
flash = (error as Error).message;
|
||
} finally {
|
||
updatingScope = false;
|
||
}
|
||
}
|
||
|
||
async function deleteArtifact(): Promise<void> {
|
||
if (!editable) return;
|
||
if (
|
||
!(await confirmAction({
|
||
title: 'Delete ' + family + '?',
|
||
message: 'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?',
|
||
confirmLabel: 'Delete',
|
||
danger: true
|
||
}))
|
||
)
|
||
return;
|
||
try {
|
||
const response = await fetch('/api/scripts/' + id, { method: 'DELETE' });
|
||
const body = await response.json();
|
||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
|
||
await goto(basePath);
|
||
} catch (error) {
|
||
flash = (error as Error).message;
|
||
}
|
||
}
|
||
|
||
let gameOptions = $derived([
|
||
{ value: '', label: '— Pick a game —', disabled: true },
|
||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
||
]);
|
||
let mapOptions = $derived([
|
||
{ value: '', label: '— Pick a map —', disabled: true },
|
||
...(scopeOptions.mapsByGame[scopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
||
]);
|
||
let scopeTypeOptions = [
|
||
{ value: 'none', label: 'Unscoped' },
|
||
{ value: 'game', label: 'Game' },
|
||
{ value: 'map', label: 'Map' }
|
||
];
|
||
</script>
|
||
|
||
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
|
||
|
||
{#if loadError}<div class="ui-error" role="alert">
|
||
{loadError}<Button size="sm" onclick={() => load()}>Retry</Button>
|
||
</div>{/if}
|
||
{#if loading}
|
||
<div class="loading">Loading…</div>
|
||
{:else if !artifact && !loadError}
|
||
<EmptyState title="Not found" description="This {family} may have been deleted." />
|
||
{:else if artifact}
|
||
{@const a = artifact}
|
||
<div class="page">
|
||
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
|
||
{#snippet crumbs()}
|
||
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
|
||
{/snippet}
|
||
</SectionHeader>
|
||
|
||
{#if scopeComplete}<div class="scope-complete" role="status">
|
||
<strong>Scope saved.</strong>
|
||
{scopeComplete.composed
|
||
? `Intended configuration may change on ${scopeComplete.count} eligible servers.`
|
||
: scopeComplete.detached
|
||
? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.`
|
||
: `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects
|
||
resulting drift and rebuilds eligible servers when automation is enabled.
|
||
<a href={scopeComplete.href}>View drift recovery →</a>
|
||
</div>{/if}
|
||
{#if latestVersion}<Card padding="lg"
|
||
><div class="release-summary">
|
||
<div>
|
||
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
|
||
<StatusBadge
|
||
state={latestVersion.id === a.current_version_id
|
||
? 'promoted'
|
||
: latestVersion.state}
|
||
/>
|
||
<p>{latestVersion.notes || 'No version notes provided.'}</p>
|
||
</div>
|
||
{#if runningBetaId !== null}<Button
|
||
variant="primary"
|
||
href={contextHref(`/betas/${runningBetaId}`, $page.url)}
|
||
>View active beta</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 if latestVersion.id === a.current_version_id}<span class="dim"
|
||
>Latest version is promoted</span
|
||
>{/if}
|
||
</div></Card
|
||
>{/if}
|
||
|
||
{#if runningBetaId}
|
||
<div class="warn-banner">
|
||
Beta <a href={contextHref(`/betas/${runningBetaId}`, $page.url)}
|
||
><strong>#{runningBetaId}</strong></a
|
||
>
|
||
is running for this {family}. Rollback or promote it before making changes.
|
||
</div>
|
||
{/if}
|
||
|
||
<Card padding="lg">
|
||
<div class="panel-head">
|
||
<h2>Details</h2>
|
||
{#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">
|
||
<summary>Target file, scope, and eligible servers</summary>
|
||
<div class="kv-grid">
|
||
<div class="kv">
|
||
<span class="k">Target file</span>
|
||
<span class="v mono">{a.target_rel_path}</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Scope</span>
|
||
<span class="v">
|
||
<Badge
|
||
variant={a.scope_type === 'game'
|
||
? 'info'
|
||
: a.scope_type === 'map'
|
||
? 'warning'
|
||
: 'neutral'}
|
||
size="sm"
|
||
>
|
||
{scopeLabel(a)}
|
||
</Badge>
|
||
</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Eligible servers</span>
|
||
<span class="v mono">
|
||
{#if !a.scope_type}
|
||
<span class="dim">Unscoped — deploy from a beta</span>
|
||
{:else if matchedServers.length === 0}
|
||
<span class="warn">No matching servers</span>
|
||
{:else}
|
||
{#each matchedServers as name}<a
|
||
class="matched-server"
|
||
href={'/servers/' + encodeURIComponent(name)}
|
||
><ServerIdentity {name} /></a
|
||
>{/each}
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Promoted version</span>
|
||
<span class="v mono">
|
||
{#if currentVersion}
|
||
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
|
||
0,
|
||
8
|
||
)}
|
||
<Badge variant="online" size="sm">Promoted</Badge>
|
||
{:else}
|
||
<span class="dim">Never promoted — test a version first</span>
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</Card>
|
||
|
||
{#if installedVersions.length}<details class="installed">
|
||
<summary>Recorded installed files · {installedVersions.length} servers</summary>
|
||
<p>
|
||
Last recorded file writes, not a verification of the currently running process.
|
||
</p>
|
||
<ul>
|
||
{#each installedVersions as installed}<li>
|
||
<a href={'/servers/' + encodeURIComponent(installed.server)}
|
||
>{serverTitle(installed.server)}</a
|
||
>
|
||
· {artifactVersionLabel(installed, family)} · <RelativeTime
|
||
value={installed.recordedAt}
|
||
/>
|
||
</li>{/each}
|
||
</ul>
|
||
</details>{:else if family !== 'config'}<p class="dim">
|
||
No installed file versions recorded. Eligibility does not mean a file is installed.
|
||
</p>{/if}
|
||
|
||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||
<ConfigEditor
|
||
{id}
|
||
kind={a.kind}
|
||
{versions}
|
||
currentVersionId={a.current_version_id}
|
||
{runningBetaId}
|
||
scopeType={a.scope_type}
|
||
scopeValue={a.scope_value}
|
||
onsaved={() => load({ seedForm: false })}
|
||
/>
|
||
{/if}
|
||
|
||
<Card padding="lg">
|
||
<div class="panel-head">
|
||
<h2>Version history</h2>
|
||
<Badge variant="neutral" size="sm">{versions.length}</Badge>
|
||
</div>
|
||
{#if versions.length === 0}
|
||
<EmptyState
|
||
title="No versions yet"
|
||
description="Upload a first version to get started."
|
||
/>
|
||
{:else}
|
||
<div class="version-list">
|
||
{#each versions.slice(0, limit) as v (v.id)}
|
||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||
<span class="v-num">{artifactVersionLabel(v, family)}</span>
|
||
<span class="v-notes"
|
||
>{v.notes ?? ''}
|
||
{#if v.source}<a
|
||
href={v.source.release_url}
|
||
target="_blank"
|
||
rel="noreferrer">GitHub · {v.source.asset_name} ↗</a
|
||
>{/if}
|
||
</span>
|
||
<span class="v-when"><RelativeTime value={v.uploaded_at} /></span>
|
||
{#if a.current_version_id === v.id}
|
||
<Badge variant="online" size="sm">Promoted</Badge>
|
||
{:else}
|
||
<StatusBadge state={v.state} size="sm" />
|
||
{#if editable && runningBetaId === null}<Button
|
||
size="sm"
|
||
href={contextHref(
|
||
betaSelectionHref([{ scriptId: id, versionId: v.id }]),
|
||
$page.url
|
||
)}>Test version</Button
|
||
>{/if}
|
||
{/if}
|
||
<details class="version-technical">
|
||
<summary>File details</summary><span>SHA-256: {v.sha256}</span><span
|
||
>Size: {v.size.toLocaleString()} bytes</span
|
||
>
|
||
</details>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<ListFooter total={versions.length} bind:limit label="versions" />
|
||
</Card>
|
||
|
||
{#if editable && family === 'plugin'}
|
||
<div id="github-releases">
|
||
<PluginGithubImport
|
||
pluginId={id}
|
||
targetPath={a.target_rel_path}
|
||
disabled={runningBetaId !== null}
|
||
onimport={() => load()}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
{#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 -->
|
||
{#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}
|
||
onclick={cancelScopeModal}
|
||
aria-label="Close">×</button
|
||
>
|
||
</header>
|
||
<Field label="Scope">
|
||
<div class="scope-row">
|
||
<Select
|
||
ariaLabel="Scope type"
|
||
bind:value={scopeType}
|
||
options={scopeTypeOptions}
|
||
disabled={updatingScope}
|
||
/>
|
||
{#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>
|
||
{#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 === 'script'}
|
||
<ScriptVersionManifest
|
||
disabled={uploadingVersion}
|
||
onload={(versions) => {
|
||
versionManifest = versions;
|
||
if (versionFile && !releaseVersion.trim())
|
||
releaseVersion = manifestVersion(versions, versionFile.name);
|
||
}}
|
||
/>
|
||
{/if}
|
||
{#if family === 'plugin' || family === 'script'}
|
||
<Field
|
||
label="Release version"
|
||
hint={family === 'script'
|
||
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
|
||
: 'Optional. Use the publisher’s version or release tag, such as v3.1.0.'}
|
||
>
|
||
<Input
|
||
bind:value={releaseVersion}
|
||
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
|
||
required={family === 'script'}
|
||
maxlength={80}
|
||
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}
|
||
|
||
<style>
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-6);
|
||
}
|
||
.loading {
|
||
text-align: center;
|
||
padding: var(--space-16);
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.warn-banner {
|
||
padding: var(--space-3) var(--space-4);
|
||
background: rgba(255, 176, 32, 0.06);
|
||
border: 1px solid rgba(255, 176, 32, 0.28);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-primary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.warn-banner a {
|
||
color: var(--state-warning);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-3);
|
||
margin-bottom: var(--space-4);
|
||
}
|
||
.panel-head h2 {
|
||
margin: 0;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-md);
|
||
font-weight: var(--weight-semibold);
|
||
color: var(--text-primary);
|
||
}
|
||
.head-actions {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
.kv-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||
gap: var(--space-3);
|
||
}
|
||
.kv {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: var(--space-3) var(--space-4);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.k {
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: var(--tracking-label);
|
||
color: var(--text-tertiary);
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
.v {
|
||
font-size: var(--text-sm);
|
||
color: var(--text-primary);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
word-break: break-word;
|
||
}
|
||
.mono {
|
||
font-family: var(--font-mono);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.dim {
|
||
color: var(--text-tertiary);
|
||
}
|
||
.warn {
|
||
color: var(--state-warning);
|
||
}
|
||
.v a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
|
||
.version-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-1);
|
||
}
|
||
.version-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
|
||
gap: var(--space-3);
|
||
align-items: center;
|
||
padding: var(--space-2) var(--space-3);
|
||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.version-row.current {
|
||
border-color: rgba(124, 212, 255, 0.35);
|
||
background: rgba(124, 212, 255, 0.04);
|
||
}
|
||
.v-num {
|
||
color: var(--text-primary);
|
||
font-weight: var(--weight-semibold);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.v-when {
|
||
color: var(--text-tertiary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.version-technical {
|
||
grid-column: 1 / -1;
|
||
color: var(--text-secondary);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.version-technical span {
|
||
display: block;
|
||
margin-top: 8px;
|
||
}
|
||
.scope-complete {
|
||
padding: 16px;
|
||
border: 1px solid var(--state-online);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.release-summary {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
.release-summary h2 {
|
||
font-size: var(--text-lg);
|
||
margin: 0 0 8px;
|
||
}
|
||
.release-summary p,
|
||
.installed p,
|
||
.scope-impact p {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.scope-impact {
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
padding: 12px;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.installed {
|
||
font-size: var(--text-sm);
|
||
}
|
||
.file-settings {
|
||
font-size: var(--text-sm);
|
||
}
|
||
.file-settings .kv-grid {
|
||
margin-top: 16px;
|
||
}
|
||
.v-notes {
|
||
color: var(--text-secondary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.v-notes {
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
}
|
||
.v-notes a {
|
||
display: block;
|
||
color: var(--accent-primary-hover);
|
||
margin-top: var(--space-2);
|
||
}
|
||
|
||
.alert {
|
||
padding: var(--space-3) var(--space-4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--state-danger);
|
||
background: rgba(255, 86, 112, 0.06);
|
||
border: 1px solid rgba(255, 86, 112, 0.28);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.alert-danger {
|
||
background: rgba(255, 86, 112, 0.08);
|
||
}
|
||
|
||
/* Dialogs */
|
||
dialog {
|
||
background: var(--surface);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-lg);
|
||
padding: 0;
|
||
max-width: 560px;
|
||
width: calc(100vw - 40px);
|
||
box-shadow: var(--shadow-raised);
|
||
}
|
||
dialog[open] {
|
||
animation: dlg-in var(--dur-base) var(--ease-standard);
|
||
}
|
||
@keyframes dlg-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(6px) scale(0.98);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
dialog::backdrop {
|
||
background: rgba(0, 0, 0, 0.55);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
}
|
||
.dlg {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
padding: var(--space-6);
|
||
}
|
||
.dlg-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.dlg h3 {
|
||
margin: 0;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-md);
|
||
font-weight: var(--weight-semibold);
|
||
color: var(--text-primary);
|
||
}
|
||
.close {
|
||
appearance: none;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xl);
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.close:hover {
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
}
|
||
|
||
.scope-row {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
flex-wrap: wrap;
|
||
}
|
||
.scope-row :global(.wrap) {
|
||
flex: 1 1 140px;
|
||
min-width: 140px;
|
||
}
|
||
|
||
.dlg-actions {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
@media (max-width: 1000px) {
|
||
.version-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
@media (max-width: 720px) {
|
||
.panel-head,
|
||
.head-actions,
|
||
.dlg-actions {
|
||
flex-wrap: wrap;
|
||
}
|
||
.dlg {
|
||
padding: var(--space-4);
|
||
}
|
||
}
|
||
.matched-server {
|
||
display: block;
|
||
padding: 5px 0;
|
||
color: var(--text-primary);
|
||
text-decoration: none;
|
||
font-family: var(--font-body);
|
||
}
|
||
</style>
|