Simplify library lists and game file readiness

This commit is contained in:
2026-10-06 17:09:36 -04:00
parent ad5609cd23
commit a991198d69
10 changed files with 253 additions and 199 deletions
+1 -1
View File
@@ -74,7 +74,7 @@
</script>
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Effective configuration'}</h2></div>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div>
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div>
<p class="intro">{artifactId ? '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>
+20 -44
View File
@@ -8,7 +8,8 @@
import { afterNavigate, goto } from '$app/navigation';
import SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import LibraryVersions from './LibraryVersions.svelte';
import '$ui/library.css';
import EmptyState from './primitives/EmptyState.svelte';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
@@ -46,8 +47,6 @@
let saving = $state(false);
let createError = $state('');
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
const overlays = $derived(artifacts.filter(a => a.kind === 'config'));
const pending = $derived(artifacts.filter(a => a.latestVersion && a.latestVersion.id !== a.current_version_id).length);
const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort());
const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame));
const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap);
@@ -106,18 +105,17 @@
</script>
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/snippet}
</SectionHeader>
{#if artifacts.length}<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>{/if}
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
{#if loading || artifacts.length || error}<div class="filters">
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
{#if loading || artifacts.length || error}<div class="library-toolbar">
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
@@ -129,30 +127,27 @@
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
{:else}
{#if filteredBaselines.length}
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
<a class="baseline-card" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
{/each}</div>
</section>
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
{/if}
{#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
<a class="overlay-row" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div>
</section>
<section class="section"><h2>Overlays <span>{filteredOverlays.length}</span></h2>{@render configTable(filteredOverlays.slice(0,limit))}</section>
{/if}
{/if}
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
{/if}
</div>
</div>
{#snippet versionBadges(artifact: Artifact)}
{#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if}
{#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}<Badge variant={artifact.latestVersion.state === 'test-failed' ? 'danger' : artifact.latestVersion.state === 'tested' ? 'online' : 'neutral'} size="sm">v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}</Badge>{/if}
{#if artifact.runningBetaId}<Badge variant="warning" size="sm">β{artifact.runningBetaId} active</Badge>{/if}
{#snippet configTable(rows: Artifact[])}
<div class="library-table"><table>
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
<tbody>{#each rows as artifact (artifact.id)}<tr>
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
<td>{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
<td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody>
</table></div>
{/snippet}
<dialog bind:this={dialog} aria-labelledby="new-config-title" oncancel={event => { if (saving) event.preventDefault(); }}>
@@ -175,32 +170,16 @@
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
.navigation { display:flex; align-items:center; gap:12px; justify-content:space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.filters { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; }
input, select { box-sizing: border-box; min-width: 0; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 12px; font: var(--text-sm) var(--font-body); }
.section { display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
.section-heading p { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.baseline-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; }
.baseline-card { padding: 24px; background: linear-gradient(140deg, color-mix(in oklab, var(--accent-primary) 7%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); text-decoration: none; color: var(--text-primary); transition: border-color var(--dur-fast); }
.baseline-card:hover, .overlay-row:hover { border-color: var(--accent-primary); }
.game-label { font: 600 var(--text-xs) var(--font-mono); letter-spacing: .08em; color: var(--accent-primary-hover); }
.open-arrow { color: var(--text-secondary); }
h3 { font-size: var(--text-md); margin: 16px 0; font-weight: 600; } .versions { flex-wrap: wrap; gap: 6px; }
.card-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 24px; font-size: var(--text-xs); color: var(--text-secondary); }
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-md); background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
@@ -218,11 +197,8 @@
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width: 900px) { .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
@media (max-width: 600px) { .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
@media (max-width:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
.config-panel { display:grid; gap:20px; }
.baseline-card { padding:16px; border-radius:var(--radius-md); background:var(--surface); }
.card-footer { padding-top:12px; margin-top:12px; }
.welcome { padding:24px; }
.loading { padding:24px 0; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HostLibraryCache } from '$lib/game-files.js';
import type { Host } from '$lib/hosts.js';
import GameFileTransferProgress from './GameFileTransferProgress.svelte';
import Badge from './primitives/Badge.svelte';
let { caches, hosts, latest = false }: { caches: HostLibraryCache[]; hosts: Host[]; latest?: boolean } = $props();
const healthy = $derived(caches.filter(cache => cache.status === 'ready' && hosts.some(host => host.id === cache.hostId && host.connected) && !cache.error));
const attention = $derived(caches.filter(cache => !healthy.includes(cache)));
function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.';
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
return 'Download starts automatically after runtime preparation and earlier work finish.';
}
</script>
{#if !hosts.length}<p class="muted"><a href="/hosts">Add a host</a> to prepare these files automatically.</p>{/if}
{#if attention.length}{@render hostTable(attention)}{/if}
{#if healthy.length}<details><summary>Ready hosts ({healthy.length})</summary>{@render hostTable(healthy)}</details>{/if}
{#snippet hostTable(rows: HostLibraryCache[])}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Details</th></tr></thead><tbody>
{#each rows as cache (cache.hostId)}
{@const host = hosts.find(host => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted"> · Offline</span>{/if}</td>
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
{#if cache.error}<p class="error">{cache.error}</p>{:else if waiting}<p class="muted">{automaticDownloadDetail(host)}</p>{/if}
{#if waiting || cache.status === 'failed'}<a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}
</td>
</tr>
{/each}
</tbody></table></div>
{/snippet}
<style>
.table-wrap { overflow-x:auto; margin-top:10px; }
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
th { color:var(--text-secondary); font-weight:500; font-size:var(--text-xs); }
summary { cursor:pointer; font-size:var(--text-sm); }
a { color:var(--accent-primary-hover); } .muted { color:var(--text-secondary); font-size:var(--text-sm); }
p { margin:6px 0; } .error { color:var(--state-danger); }
summary:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import Badge from './primitives/Badge.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
interface Version { id: number; version: number; release_version?: string | null; state?: string }
let { current, latest, betaId = null }: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
const ambiguous = $derived(!!newer?.release_version && newer.release_version === current?.release_version);
function label(version: Version) { return ambiguous ? artifactVersionLabel(version) : version.release_version || `v${version.version}`; }
const state = $derived(betaId !== null ? 'Beta active' : newer?.state === 'test-failed' ? 'Test failed' : newer?.state === 'tested' ? 'Test passed' : 'Needs testing');
</script>
<div class="versions">
{#if newer}<div><span>{label(newer)}</span><Badge size="sm" variant={state === 'Test failed' ? 'danger' : state === 'Test passed' ? 'online' : 'neutral'}>{state}</Badge></div>{/if}
{#if current}<div class:secondary={!!newer}><span>{label(current)}</span>{#if newer}<span>Promoted</span>{:else}<Badge size="sm" variant="online">Promoted</Badge>{/if}</div>
{:else}<span class="secondary">{latest ? 'Never promoted' : 'No versions'}</span>{/if}
{#if betaId !== null && !newer}<Badge size="sm" variant="neutral">Beta active</Badge>{/if}
</div>
<style>
.versions { display:grid; gap:5px; font-size:var(--text-sm); }
.versions>div { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.secondary { color:var(--text-secondary); font-size:var(--text-xs); }
</style>
+2 -1
View File
@@ -3,6 +3,7 @@
import type { PluginUpdate } from '$lib/plugin-github.js';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
let { onchecked }: { onchecked?: (updates: PluginUpdate[]) => void } = $props();
let updates = $state<PluginUpdate[]>([]);
let checking = $state(false);
let checked = $state(false);
@@ -15,7 +16,7 @@
const response = await fetch('/api/plugins/updates');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check updates.');
updates = body.updates; checked = true;
updates = body.updates; checked = true; onchecked?.(updates);
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
}
+19
View File
@@ -0,0 +1,19 @@
.library-toolbar { display:flex; align-items:center; flex-wrap:wrap; gap:10px; }
.library-toolbar .search { flex:1 1 260px; min-width:0; }
.library-toolbar input, .library-toolbar select { box-sizing:border-box; width:100%; min-height:40px; padding:9px 12px; border:1px solid var(--border-control); border-radius:var(--radius-sm); background:var(--surface-sunken); color:var(--text-primary); font:var(--text-sm) var(--font-body); }
.library-toolbar>select { width:auto; }
.library-table { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.library-table table { width:100%; min-width:680px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
.library-table th { padding:10px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
.library-table td { padding:12px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); vertical-align:middle; }
.library-table tr:hover td { background:var(--surface-raised); }
.library-table .name { color:var(--text-primary); font-weight:600; text-decoration:none; overflow-wrap:anywhere; }
.library-table a { color:var(--accent-primary-hover); }
.library-table .name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.library-table .secondary { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:5px; overflow-wrap:anywhere; }
.library-table .numeric { font-variant-numeric:tabular-nums; }
.library-table .select-cell { width:20px; padding-right:0; }
.library-table tr.selected td { background:color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
.library-table input[type='checkbox'] { width:16px; height:16px; accent-color:var(--accent-primary); }
.library-table a:focus-visible, .library-table input:focus-visible, .library-toolbar :focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
@media(max-width:640px) { .library-toolbar>select, .library-toolbar>label { flex:1 1 140px; } .library-toolbar .search { flex-basis:100%; } }