Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -2,7 +2,14 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
|
||||
import {
|
||||
activityTypes,
|
||||
activitySources,
|
||||
activityStates,
|
||||
type ActivityPage,
|
||||
type ActivityQuery,
|
||||
type ActivitySource
|
||||
} from '$lib/activity.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
@@ -17,7 +24,19 @@
|
||||
import Freshness from './Freshness.svelte';
|
||||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||||
|
||||
let { server, source, initial, excludeProvisioning = false, excludeMaintenance = false }: { server?: string; source?: ActivitySource; initial?: ActivityPage; excludeProvisioning?: boolean; excludeMaintenance?: boolean } = $props();
|
||||
let {
|
||||
server,
|
||||
source,
|
||||
initial,
|
||||
excludeProvisioning = false,
|
||||
excludeMaintenance = false
|
||||
}: {
|
||||
server?: string;
|
||||
source?: ActivitySource;
|
||||
initial?: ActivityPage;
|
||||
excludeProvisioning?: boolean;
|
||||
excludeMaintenance?: boolean;
|
||||
} = $props();
|
||||
const id = $props.id();
|
||||
const headingId = `${id}-activity-heading`;
|
||||
let data = $state<ActivityPage | undefined>(untrack(() => initial));
|
||||
@@ -25,169 +44,588 @@
|
||||
let error = $state('');
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
|
||||
let results = $state<HTMLOListElement>();
|
||||
let search = $state(untrack(() => activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }).q ?? ''));
|
||||
let search = $state(
|
||||
untrack(
|
||||
() =>
|
||||
activityQuery($page.url, {
|
||||
server,
|
||||
source,
|
||||
excludeProvisioning,
|
||||
excludeMaintenance
|
||||
}).q ?? ''
|
||||
)
|
||||
);
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
const filters = $derived(activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
|
||||
const apiUrl = $derived(activityApiHref($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const typeOptions = $derived(Object.entries(activityTypes).filter(([key]) =>
|
||||
(!excludeProvisioning || !['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key))
|
||||
&& (!excludeMaintenance || !['update', 'health'].includes(key))));
|
||||
const sourceOptions = $derived(Object.entries(activitySources).filter(([key]) =>
|
||||
(!excludeProvisioning || !['provisioning', 'autoscaling'].includes(key))
|
||||
&& (!excludeMaintenance || !['maintenance', 'recovery'].includes(key))));
|
||||
const historyDescription = $derived(excludeMaintenance
|
||||
? 'File changes and host work appear here.'
|
||||
: excludeProvisioning ? 'File changes, maintenance, and host work appear here.'
|
||||
: 'Server operations, file changes, maintenance, and host work appear here.');
|
||||
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
|
||||
const filters = $derived(
|
||||
activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance })
|
||||
);
|
||||
const apiUrl = $derived(
|
||||
activityApiHref($page.url, { server, source, excludeProvisioning, excludeMaintenance })
|
||||
);
|
||||
let loadedUrl = $state(untrack(() => (initial ? apiUrl : '')));
|
||||
const typeOptions = $derived(
|
||||
Object.entries(activityTypes).filter(
|
||||
([key]) =>
|
||||
(!excludeProvisioning ||
|
||||
!['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key)) &&
|
||||
(!excludeMaintenance || !['update', 'health'].includes(key))
|
||||
)
|
||||
);
|
||||
const sourceOptions = $derived(
|
||||
Object.entries(activitySources).filter(
|
||||
([key]) =>
|
||||
(!excludeProvisioning || !['provisioning', 'autoscaling'].includes(key)) &&
|
||||
(!excludeMaintenance || !['maintenance', 'recovery'].includes(key))
|
||||
)
|
||||
);
|
||||
const historyDescription = $derived(
|
||||
excludeMaintenance
|
||||
? 'File changes and host work appear here.'
|
||||
: excludeProvisioning
|
||||
? 'File changes, maintenance, and host work appear here.'
|
||||
: 'Server operations, file changes, maintenance, and host work appear here.'
|
||||
);
|
||||
const filterCount = $derived(
|
||||
[
|
||||
filters.type,
|
||||
filters.state,
|
||||
source === undefined ? filters.source : '',
|
||||
server === undefined ? filters.server : '',
|
||||
filters.host,
|
||||
filters.days,
|
||||
filters.q
|
||||
].filter(Boolean).length
|
||||
);
|
||||
const serverOptions = $derived.by(() => {
|
||||
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
|
||||
const names = new Set([
|
||||
...$fleet.servers.map((item) => item.name),
|
||||
...(data?.rows.flatMap((row) => (row.server ? [row.server] : [])) ?? [])
|
||||
]);
|
||||
if (filters.server) names.add(filters.server);
|
||||
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
|
||||
});
|
||||
const hostOptions = $derived.by(() => {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
|
||||
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
|
||||
for (const row of data?.rows ?? [])
|
||||
if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
|
||||
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
|
||||
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
});
|
||||
|
||||
$effect(() => { search = filters.q ?? ''; });
|
||||
$effect(() => {
|
||||
search = filters.q ?? '';
|
||||
});
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
if (report)
|
||||
untrack(() => {
|
||||
data = report;
|
||||
loadedUrl = apiUrl;
|
||||
loading = false;
|
||||
error = '';
|
||||
checkedAt = Date.now();
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
retry;
|
||||
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
|
||||
return pollVisible(async signal => {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load activity.');
|
||||
const body: ActivityPage = await response.json();
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
// Keep a focused operation link in place when newer rows arrive.
|
||||
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
|
||||
loadedUrl = url; loading = false; error = '';
|
||||
} catch (cause) {
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load activity.';
|
||||
loading = false;
|
||||
return false;
|
||||
}
|
||||
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
|
||||
void retry;
|
||||
untrack(() => {
|
||||
if (loadedUrl !== url) loading = true;
|
||||
error = '';
|
||||
});
|
||||
return pollVisible(
|
||||
async (signal) => {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load activity.');
|
||||
const body: ActivityPage = await response.json();
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
// Keep a focused operation link in place when newer rows arrive.
|
||||
if (loadedUrl !== url || !results?.contains(document.activeElement)) {
|
||||
data = body;
|
||||
checkedAt = Date.now();
|
||||
}
|
||||
loadedUrl = url;
|
||||
loading = false;
|
||||
error = '';
|
||||
} catch (cause) {
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load activity.';
|
||||
loading = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
untrack(() => loadedUrl !== url || retry > 0)
|
||||
);
|
||||
});
|
||||
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
|
||||
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
|
||||
pendingUrl = new URL(href, $page.url);
|
||||
const id = ++navigationId;
|
||||
void goto(href, { noScroll: true, keepFocus: true })
|
||||
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
|
||||
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
|
||||
.catch(() => {
|
||||
error = 'Could not update activity filters. Please try again.';
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === navigationId) pendingUrl = undefined;
|
||||
});
|
||||
}
|
||||
function reset() {
|
||||
update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' });
|
||||
}
|
||||
function submitSearch() {
|
||||
update({ q: search.trim() });
|
||||
}
|
||||
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
|
||||
function submitSearch() { update({ q: search.trim() }); }
|
||||
</script>
|
||||
|
||||
<section class="activity-log" aria-labelledby={headingId}>
|
||||
<h2 class="sr-only" id={headingId}>Activity history</h2>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each typeOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each sourceOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
|
||||
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
|
||||
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
|
||||
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
|
||||
<label for={`activity-type${suffix}`}
|
||||
>Type<select
|
||||
id={`activity-type${suffix}`}
|
||||
value={filters.type ?? ''}
|
||||
onchange={(event) => update({ type: event.currentTarget.value })}
|
||||
><option value="">All types</option>{#each typeOptions as [value, label]}<option
|
||||
{value}>{label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label for={`activity-state${suffix}`}
|
||||
>State<select
|
||||
id={`activity-state${suffix}`}
|
||||
value={filters.state ?? ''}
|
||||
onchange={(event) => update({ state: event.currentTarget.value })}
|
||||
><option value="">All states</option
|
||||
>{#each Object.entries(activityStates) as [value, label]}<option {value}
|
||||
>{label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
{#if source === undefined}<label for={`activity-source${suffix}`}
|
||||
>Source<select
|
||||
id={`activity-source${suffix}`}
|
||||
value={filters.source ?? ''}
|
||||
onchange={(event) => update({ source: event.currentTarget.value })}
|
||||
><option value="">All sources</option
|
||||
>{#each sourceOptions as [value, label]}<option {value}>{label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>{/if}
|
||||
{#if server === undefined}<label for={`activity-server${suffix}`}
|
||||
>Server<select
|
||||
id={`activity-server${suffix}`}
|
||||
value={filters.server ?? ''}
|
||||
onchange={(event) => update({ server: event.currentTarget.value })}
|
||||
><option value="">All servers</option>{#each serverOptions as name}<option
|
||||
value={name}>{serverTitle(name)}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>{/if}
|
||||
<label for={`activity-host${suffix}`}
|
||||
>Host<select
|
||||
id={`activity-host${suffix}`}
|
||||
value={filters.host ?? ''}
|
||||
onchange={(event) => update({ host: event.currentTarget.value })}
|
||||
><option value="">All hosts</option>{#each hostOptions as [value, label]}<option
|
||||
{value}>{label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label for={`activity-days${suffix}`}
|
||||
>Date range<select
|
||||
id={`activity-days${suffix}`}
|
||||
value={filters.days ?? 0}
|
||||
onchange={(event) => update({ days: event.currentTarget.value })}
|
||||
><option value="0">All retained history</option><option value="1"
|
||||
>Last 24 hours</option
|
||||
><option value="7">Last 7 days</option><option value="30">Last 30 days</option
|
||||
><option value="90">Last 90 days</option
|
||||
>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option
|
||||
value={filters.days}>Last {filters.days} days</option
|
||||
>{/if}</select
|
||||
></label
|
||||
>
|
||||
<form
|
||||
class="search"
|
||||
role="search"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitSearch();
|
||||
}}
|
||||
>
|
||||
<label for={`activity-search${suffix}`}
|
||||
>Search activity<input
|
||||
id={`activity-search${suffix}`}
|
||||
type="search"
|
||||
bind:value={search}
|
||||
maxlength="200"
|
||||
placeholder="Server, host, or activity…"
|
||||
/></label
|
||||
><Button type="submit" size="sm">Search</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
|
||||
<div class="mobile-filters">
|
||||
<span
|
||||
>{data?.total ?? '—'}
|
||||
{data?.total === 1 ? 'activity' : 'activities'}{source
|
||||
? ` · ${activitySources[source]}`
|
||||
: ''}</span
|
||||
><Button size="sm" onclick={() => (filtersOpen = true)}
|
||||
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
|
||||
>
|
||||
</div>
|
||||
<Dialog
|
||||
bind:open={filtersOpen}
|
||||
title="Activity filters"
|
||||
description="Choose the activity to show."
|
||||
drawer
|
||||
>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
if (search.trim() !== (filters.q ?? '')) submitSearch();
|
||||
filtersOpen = false;
|
||||
}}>Show results</Button
|
||||
>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
<div class="context">
|
||||
<span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button
|
||||
class="text-button"
|
||||
onclick={reset}>Clear filters</button
|
||||
>{/if}{#if !error}<Freshness
|
||||
updatedAt={checkedAt}
|
||||
intervalLabel="Updates every 10 seconds"
|
||||
/>{/if}
|
||||
</div>
|
||||
{#if error}<div class="error" role="alert">
|
||||
<span
|
||||
>{error}{loadedUrl === apiUrl && data
|
||||
? ' Showing the last successful update.'
|
||||
: ''}</span
|
||||
><Button size="sm" onclick={() => retry++}>Retry</Button>
|
||||
</div>{/if}
|
||||
{#if loading}<EmptyState title="Loading activity…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState
|
||||
title="Activity unavailable"
|
||||
description="Retry to load this selection."
|
||||
/>
|
||||
{:else if data?.rows.length}
|
||||
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
|
||||
{#each data.rows as row (row.id)}
|
||||
<li class="activity-row">
|
||||
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
|
||||
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
|
||||
<div class="event">
|
||||
<h3>
|
||||
<a
|
||||
href={contextHref(row.href, $page.url)}
|
||||
aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}
|
||||
>{row.title}</a
|
||||
>
|
||||
</h3>
|
||||
{#if row.detail}<p>{row.detail}</p>{/if}
|
||||
<div class="resources">
|
||||
{#if row.server && server === undefined}<HistoricalServerIdentity
|
||||
name={row.server}
|
||||
deletedAt={row.serverDeletedAt}
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(row.server)}`,
|
||||
$page.url
|
||||
)}
|
||||
/>{/if}
|
||||
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{$timeFormat.relative(row.at, $uiNow)}</time>
|
||||
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
|
||||
<div class="outcome">
|
||||
<StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time
|
||||
datetime={row.at}
|
||||
title={$timeFormat.dateTime(row.at)}
|
||||
aria-label={$timeFormat.dateTime(row.at)}
|
||||
>{$timeFormat.relative(row.at, $uiNow)}</time
|
||||
>
|
||||
<span class="sources"
|
||||
>{(row.sources.length ? row.sources : [row.source])
|
||||
.map((item) => activitySources[item])
|
||||
.join(' · ')}</span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : historyDescription}>
|
||||
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
{:else if !error}<EmptyState
|
||||
title={filterCount ? 'No matching activity' : 'No activity recorded yet'}
|
||||
description={filterCount
|
||||
? 'Try another type, state, server, host, or date range.'
|
||||
: historyDescription}
|
||||
>
|
||||
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}
|
||||
>Clear filters</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
|
||||
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
|
||||
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
|
||||
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
|
||||
<div class="pagination">
|
||||
<span
|
||||
>{data.rows.length
|
||||
? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}`
|
||||
: 'No activities on this page'}</span
|
||||
>
|
||||
<label
|
||||
>Per page<select
|
||||
aria-label="Activities per page"
|
||||
value={data.limit}
|
||||
onchange={(event) => update({ limit: event.currentTarget.value })}
|
||||
>{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option
|
||||
value={size}>{size}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={data.offset === 0}
|
||||
href={activityHistoryHref($page.url, {
|
||||
offset: String(Math.max(0, data.offset - data.limit))
|
||||
})}>Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
href={activityHistoryHref($page.url, {
|
||||
offset: String(data.offset + data.limit)
|
||||
})}>Next</Button
|
||||
>
|
||||
</nav>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
|
||||
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
|
||||
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
|
||||
.search label { flex: 1; }
|
||||
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.context > :last-child { margin-left: auto; }
|
||||
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
|
||||
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
|
||||
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
|
||||
.activity-row:focus-within { background: var(--surface-raised); }
|
||||
h3, p { margin: 0; }
|
||||
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
a { color: var(--accent-primary-hover); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.event { min-width: 0; overflow-wrap: anywhere; }
|
||||
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
|
||||
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
|
||||
time { font-variant-numeric: tabular-nums; }
|
||||
.sources { max-width: 32ch; overflow-wrap: anywhere; }
|
||||
.pagination { justify-content: space-between; }
|
||||
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.pagination select { width: auto; min-height: 36px; }
|
||||
.pagination nav { display: flex; gap: var(--space-2); }
|
||||
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.mobile-filters { display: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.activity-log {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
select,
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.search {
|
||||
grid-column: span 2;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.search label {
|
||||
flex: 1;
|
||||
}
|
||||
.context,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.context > :last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
.text-button {
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent-primary-hover);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.activity-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, auto);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.activity-row + .activity-row {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.activity-row:focus-within {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.event {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.event p {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.resources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.host-name,
|
||||
.sources,
|
||||
time {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.outcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
text-align: right;
|
||||
}
|
||||
time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sources {
|
||||
max-width: 32ch;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pagination label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pagination select {
|
||||
width: auto;
|
||||
min-height: 36px;
|
||||
}
|
||||
.pagination nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.mobile-filters {
|
||||
display: none;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { grid-template-columns: minmax(0, 1fr); }
|
||||
.search { grid-column: auto; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
.context > :last-child { margin-left: 0; }
|
||||
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
|
||||
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
|
||||
.sources { max-width: none; }
|
||||
.pagination { gap: var(--space-3); }
|
||||
.pagination nav { margin-left: auto; }
|
||||
.desktop-filters {
|
||||
display: none;
|
||||
}
|
||||
.mobile-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.search {
|
||||
grid-column: auto;
|
||||
}
|
||||
.context {
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
.context > :last-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.activity-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.outcome {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
.sources {
|
||||
max-width: none;
|
||||
}
|
||||
.pagination {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pagination nav {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,25 +2,46 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
|
||||
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } =
|
||||
$props();
|
||||
let name = $state('');
|
||||
let portMin = $state(28960); let portMax = $state(28980);
|
||||
let busy = $state(false); let error = $state(''); let copied = $state(false);
|
||||
let portMin = $state(28960);
|
||||
let portMax = $state(28980);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
|
||||
async function add(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy) return;
|
||||
busy = true; error = ''; copied = false;
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
copied = false;
|
||||
try {
|
||||
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
|
||||
const response = await fetch('/api/hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, portMin, portMax })
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
|
||||
enrollment = body.enrollment;
|
||||
await onadded();
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
} catch (cause) {
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function copy() {
|
||||
if (!enrollment) return;
|
||||
try { await navigator.clipboard.writeText(enrollment.command); copied = true; error = ''; }
|
||||
catch { error = 'Could not copy automatically. Select and copy the command below.'; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(enrollment.command);
|
||||
copied = true;
|
||||
error = '';
|
||||
} catch {
|
||||
error = 'Could not copy automatically. Select and copy the command below.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,35 +49,142 @@
|
||||
<div class="content">
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
{#if enrollment}
|
||||
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
|
||||
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
|
||||
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
|
||||
<p class="muted">This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}. Generate a new installer from host settings if it expires.</p>
|
||||
<Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
|
||||
<p role="status">
|
||||
{name} created. Run this command with administrator access on that machine.
|
||||
</p>
|
||||
<textarea
|
||||
readonly
|
||||
aria-label="Host installation command"
|
||||
rows="6"
|
||||
value={enrollment.command}
|
||||
></textarea>
|
||||
<div class="actions">
|
||||
<Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button
|
||||
variant="primary"
|
||||
href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button
|
||||
>
|
||||
</div>
|
||||
<p class="muted">
|
||||
This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}.
|
||||
Generate a new installer from host settings if it expires.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
enrollment = null;
|
||||
name = '';
|
||||
copied = false;
|
||||
error = '';
|
||||
}}>Add another host</Button
|
||||
>
|
||||
{:else}
|
||||
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
|
||||
<form onsubmit={add}><fieldset disabled={busy}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
|
||||
<details><summary>Advanced settings</summary><div class="ports">
|
||||
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
|
||||
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label>
|
||||
</div></details>
|
||||
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
|
||||
</fieldset></form>
|
||||
<p>
|
||||
Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the
|
||||
runtime and game files automatically.
|
||||
</p>
|
||||
<form onsubmit={add}>
|
||||
<fieldset disabled={busy}>
|
||||
<label
|
||||
>Friendly name<input
|
||||
required
|
||||
maxlength="200"
|
||||
bind:value={name}
|
||||
placeholder="Game host 1"
|
||||
/></label
|
||||
>
|
||||
<details>
|
||||
<summary>Advanced settings</summary>
|
||||
<div class="ports">
|
||||
<label
|
||||
>First game port<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={portMin}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Last game port<input
|
||||
type="number"
|
||||
required
|
||||
min={portMin}
|
||||
max="65535"
|
||||
bind:value={portMax}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
<Button type="submit" variant="primary" disabled={busy}
|
||||
>{busy ? 'Creating…' : 'Add host'}</Button
|
||||
>
|
||||
</fieldset>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.content,fieldset { display:grid; gap:20px; }
|
||||
fieldset { border:0; padding:0; margin:0; min-width:0; }
|
||||
p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
|
||||
.muted { font-size:var(--text-xs); }
|
||||
label { display:grid; gap:8px; font-size:var(--text-sm); }
|
||||
input,textarea { box-sizing:border-box; width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
textarea { font:var(--text-xs)/1.6 var(--font-mono); resize:vertical; }
|
||||
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
|
||||
.ports { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:12px; }
|
||||
input:focus-visible,textarea:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.content,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.muted {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
textarea {
|
||||
font: var(--text-xs)/1.6 var(--font-mono);
|
||||
resize: vertical;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.ports {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,15 @@
|
||||
<a class="back-link" {href}>← {returnLabel(href)}</a>
|
||||
|
||||
<style>
|
||||
.back-link { display: inline-block; align-self: start; width: fit-content; font-size: var(--text-sm); color: var(--accent-primary-hover); text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
align-self: start;
|
||||
width: fit-content;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,96 +5,243 @@
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
|
||||
interface FileEntry { id: number; name: string; kind: string; target_rel_path: string;
|
||||
version: { version: number; release_version: string | null; sha256: string } | null }
|
||||
let { open = $bindable(false), betaId, revision, files, servers, timeoutHours, onUpdate }:
|
||||
{ open?: boolean; betaId: number; revision: number; files: FileEntry[]; servers: string[];
|
||||
timeoutHours?: number; onUpdate: () => Promise<void> } = $props();
|
||||
interface Replacement { file: File; sha: string; before: string | null; after: string | null }
|
||||
interface FileEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
target_rel_path: string;
|
||||
version: { version: number; release_version: string | null; sha256: string } | null;
|
||||
}
|
||||
let {
|
||||
open = $bindable(false),
|
||||
betaId,
|
||||
revision,
|
||||
files,
|
||||
servers,
|
||||
timeoutHours,
|
||||
onUpdate
|
||||
}: {
|
||||
open?: boolean;
|
||||
betaId: number;
|
||||
revision: number;
|
||||
files: FileEntry[];
|
||||
servers: string[];
|
||||
timeoutHours?: number;
|
||||
onUpdate: () => Promise<void>;
|
||||
} = $props();
|
||||
interface Replacement {
|
||||
file: File;
|
||||
sha: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
}
|
||||
let replacements = $state<Record<number, Replacement>>({});
|
||||
let notes = $state(''), error = $state(''), reviewing = $state(false), busy = $state(false);
|
||||
let notes = $state(''),
|
||||
error = $state(''),
|
||||
reviewing = $state(false),
|
||||
busy = $state(false);
|
||||
const count = $derived(Object.keys(replacements).length);
|
||||
$effect(() => { if (open) { replacements = {}; notes = ''; error = ''; reviewing = false; } });
|
||||
function fileUrl(scriptId: number) { return `/api/betas/${betaId}/revisions/${revision}/files/${scriptId}`; }
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
replacements = {};
|
||||
notes = '';
|
||||
error = '';
|
||||
reviewing = false;
|
||||
}
|
||||
});
|
||||
function fileUrl(scriptId: number) {
|
||||
return `/api/betas/${betaId}/revisions/${revision}/files/${scriptId}`;
|
||||
}
|
||||
async function choose(entry: FileEntry, file?: File) {
|
||||
delete replacements[entry.id]; error = '';
|
||||
delete replacements[entry.id];
|
||||
error = '';
|
||||
if (!file) return;
|
||||
busy = true;
|
||||
try {
|
||||
if (!file.size) throw new Error('Choose a non-empty file.');
|
||||
const bytes = await file.arrayBuffer();
|
||||
const inspection = new FormData(); inspection.set('content', file);
|
||||
const inspected = await fetch('/api/scripts/inspect', { method: 'POST', body: inspection });
|
||||
const inspection = new FormData();
|
||||
inspection.set('content', file);
|
||||
const inspected = await fetch('/api/scripts/inspect', {
|
||||
method: 'POST',
|
||||
body: inspection
|
||||
});
|
||||
const inspectionBody = await inspected.json();
|
||||
if (!inspected.ok) throw new Error(inspectionBody.error ?? 'Could not inspect replacement file.');
|
||||
if (!inspected.ok)
|
||||
throw new Error(inspectionBody.error ?? 'Could not inspect replacement file.');
|
||||
const sha = inspectionBody.sha256 as string;
|
||||
if (sha === entry.version?.sha256) throw new Error(`${entry.name}: this file is identical to the current version.`);
|
||||
let before: string | null = null, after: string | null = null;
|
||||
if (sha === entry.version?.sha256)
|
||||
throw new Error(`${entry.name}: this file is identical to the current version.`);
|
||||
let before: string | null = null,
|
||||
after: string | null = null;
|
||||
if (entry.kind !== 'plugin' && bytes.byteLength <= 128 * 1024) {
|
||||
const response = await fetch(fileUrl(entry.id));
|
||||
if (!response.ok) throw new Error('Could not load the current file for review. Reload the beta and try again.');
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
'Could not load the current file for review. Reload the beta and try again.'
|
||||
);
|
||||
const old = await response.arrayBuffer();
|
||||
if (old.byteLength <= 128 * 1024 && !new Uint8Array(old).includes(0) && !new Uint8Array(bytes).includes(0)) {
|
||||
before = new TextDecoder().decode(old); after = new TextDecoder().decode(bytes);
|
||||
if (
|
||||
old.byteLength <= 128 * 1024 &&
|
||||
!new Uint8Array(old).includes(0) &&
|
||||
!new Uint8Array(bytes).includes(0)
|
||||
) {
|
||||
before = new TextDecoder().decode(old);
|
||||
after = new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
replacements[entry.id] = { file, sha, before, after };
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function deploy() {
|
||||
busy = true; error = '';
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('expectedRevision', String(revision)); form.set('notes', notes);
|
||||
for (const [scriptId, replacement] of Object.entries(replacements)) form.set(`file:${scriptId}`, replacement.file);
|
||||
const response = await fetch(`/api/betas/${betaId}/revisions`, { method: 'POST', body: form });
|
||||
form.set('expectedRevision', String(revision));
|
||||
form.set('notes', notes);
|
||||
for (const [scriptId, replacement] of Object.entries(replacements))
|
||||
form.set(`file:${scriptId}`, replacement.file);
|
||||
const response = await fetch(`/api/betas/${betaId}/revisions`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not update beta.');
|
||||
open = false; await onUpdate();
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
open = false;
|
||||
await onUpdate();
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title={reviewing ? `Review revision ${revision + 1}` : 'Update beta'}
|
||||
description={`Replace files in beta #${betaId} and repeat its tests on the same servers.`} {busy} wide>
|
||||
<Dialog
|
||||
bind:open
|
||||
title={reviewing ? `Review revision ${revision + 1}` : 'Update beta'}
|
||||
description={`Replace files in beta #${betaId} and repeat its tests on the same servers.`}
|
||||
{busy}
|
||||
wide
|
||||
>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if !reviewing}
|
||||
<p>Choose replacements for the files you changed.</p>
|
||||
<div class="files">{#each files as entry}
|
||||
<label><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code>{entry.target_rel_path}</code>
|
||||
<input type="file" disabled={busy} onchange={event => choose(entry, event.currentTarget.files?.[0])} />
|
||||
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
||||
</label>
|
||||
{/each}</div>
|
||||
<label class="notes">Change note (optional)<textarea bind:value={notes} maxlength="1000" rows="3" disabled={busy}></textarea></label>
|
||||
<div class="files">
|
||||
{#each files as entry}
|
||||
<label
|
||||
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
|
||||
>{entry.target_rel_path}</code
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
disabled={busy}
|
||||
onchange={(event) => choose(entry, event.currentTarget.files?.[0])}
|
||||
/>
|
||||
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="notes"
|
||||
>Change note (optional)<textarea
|
||||
bind:value={notes}
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
disabled={busy}
|
||||
></textarea></label
|
||||
>
|
||||
{:else}
|
||||
<p><strong>{count} file{count === 1 ? '' : 's'} changed.</strong> All {servers.length} selected servers will repeat the test policy, using its existing wave order. Each server installs the bundle and restarts when empty.</p>
|
||||
<p>Previous results move to revision history. Promotion requires this revision to pass.
|
||||
{#if timeoutHours}A fresh {timeoutHours}-hour test window starts when you deploy, including time waiting for empty servers.{/if}
|
||||
Rejecting the beta still restores the files from before revision 1.</p>
|
||||
<details open><summary>Servers ({servers.length})</summary><ul>{#each servers as name}<li><ServerIdentity {name}/></li>{/each}</ul></details>
|
||||
{#each files.filter(entry => replacements[entry.id]) as entry}
|
||||
<p>
|
||||
<strong>{count} file{count === 1 ? '' : 's'} changed.</strong> All {servers.length} selected
|
||||
servers will repeat the test policy, using its existing wave order. Each server installs the
|
||||
bundle and restarts when empty.
|
||||
</p>
|
||||
<p>
|
||||
Previous results move to revision history. Promotion requires this revision to pass.
|
||||
{#if timeoutHours}A fresh {timeoutHours}-hour test window starts when you deploy,
|
||||
including time waiting for empty servers.{/if}
|
||||
Rejecting the beta still restores the files from before revision 1.
|
||||
</p>
|
||||
<details open>
|
||||
<summary>Servers ({servers.length})</summary>
|
||||
<ul>
|
||||
{#each servers as name}<li><ServerIdentity {name} /></li>{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{#each files.filter((entry) => replacements[entry.id]) as entry}
|
||||
{@const replacement = replacements[entry.id]}
|
||||
<section><h3>{entry.name}</h3><p><code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()} bytes)</p>
|
||||
<p class="hash">SHA {entry.version?.sha256.slice(0,12)} → {replacement.sha.slice(0,12)}</p>
|
||||
{#if replacement.after !== null}<DiffViewer before={replacement.before} after={replacement.after} verb="updated" relPath={entry.target_rel_path}/>
|
||||
<section>
|
||||
<h3>{entry.name}</h3>
|
||||
<p>
|
||||
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
|
||||
bytes)
|
||||
</p>
|
||||
<p class="hash">
|
||||
SHA {entry.version?.sha256.slice(0, 12)} → {replacement.sha.slice(0, 12)}
|
||||
</p>
|
||||
{#if replacement.after !== null}<DiffViewer
|
||||
before={replacement.before}
|
||||
after={replacement.after}
|
||||
verb="updated"
|
||||
relPath={entry.target_rel_path}
|
||||
/>
|
||||
{:else}<p>Binary or large file: review the uploaded file and checksum.</p>{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{#if notes}<p>Change note: {notes}</p>{/if}
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
||||
{#if reviewing}<Button onclick={() => reviewing = false} disabled={busy}>Back</Button><Button variant="primary" onclick={deploy} disabled={busy} loading={busy}>Deploy revision {revision + 1}</Button>
|
||||
{:else}<Button variant="primary" onclick={() => reviewing = true} disabled={busy || !count}>Review update</Button>{/if}
|
||||
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||
{#if reviewing}<Button onclick={() => (reviewing = false)} disabled={busy}>Back</Button
|
||||
><Button variant="primary" onclick={deploy} disabled={busy} loading={busy}
|
||||
>Deploy revision {revision + 1}</Button
|
||||
>
|
||||
{:else}<Button
|
||||
variant="primary"
|
||||
onclick={() => (reviewing = true)}
|
||||
disabled={busy || !count}>Review update</Button
|
||||
>{/if}
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.files, label { display:grid; gap:12px; } .files { margin:20px 0; }
|
||||
.files label { padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size: var(--text-sm); }
|
||||
li { margin:8px 0; } textarea { width:100%; } section { margin-top:24px; }
|
||||
.files,
|
||||
label {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.files {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.files label {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
code,
|
||||
.hash {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hash,
|
||||
label span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,162 +12,723 @@
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
|
||||
import {
|
||||
coverageSummary,
|
||||
requirementSummary,
|
||||
needsGameTelemetry
|
||||
} from '$lib/beta-plan-summary.js';
|
||||
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
|
||||
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, deadline, retryBlocked, deletedParticipants = false, deployments, busy = $bindable(false), onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; deletedParticipants?:boolean; deployments?:BetaDeploymentServer[]; busy?:boolean; onUpdate:()=>Promise<void> } = $props();
|
||||
let notes = $state<Record<string,string>>({});
|
||||
let evidenceOpen = $state(false), evidenceLimit = $state(25), error = $state(''), message = $state('');
|
||||
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
|
||||
let {
|
||||
test,
|
||||
betaId,
|
||||
closed,
|
||||
deadline,
|
||||
retryBlocked,
|
||||
deletedParticipants = false,
|
||||
deployments,
|
||||
busy = $bindable(false),
|
||||
onUpdate
|
||||
}: {
|
||||
test: BetaTestView;
|
||||
betaId: number;
|
||||
closed: boolean;
|
||||
deadline?: string;
|
||||
retryBlocked?: string;
|
||||
deletedParticipants?: boolean;
|
||||
deployments?: BetaDeploymentServer[];
|
||||
busy?: boolean;
|
||||
onUpdate: () => Promise<void>;
|
||||
} = $props();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let evidenceOpen = $state(false),
|
||||
evidenceLimit = $state(25),
|
||||
error = $state(''),
|
||||
message = $state('');
|
||||
let evidenceServer = $state(''),
|
||||
evidenceKind = $state(''),
|
||||
evidenceQuery = $state('');
|
||||
let evidenceSection = $state<HTMLDetailsElement>();
|
||||
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));
|
||||
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
|
||||
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||
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)
|
||||
);
|
||||
const expired = $derived(
|
||||
test.outcome === 'inconclusive' ||
|
||||
(!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now())
|
||||
);
|
||||
const ranked = $derived(
|
||||
[...test.servers].sort(
|
||||
(a, b) =>
|
||||
(a.progress.some((r) => r.failed) ? 0 : a.passed ? 2 : 1) -
|
||||
(b.progress.some((r) => r.failed) ? 0 : b.passed ? 2 : 1) ||
|
||||
a.server.localeCompare(b.server)
|
||||
)
|
||||
);
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
const combined = $derived.by(() => {
|
||||
const rows = [...(deployments ?? [])];
|
||||
for (const server of test.servers) {
|
||||
if (!rows.some(row => row.server === server.server)) rows.push({ server: server.server, server_deleted_at: server.server_deleted_at,
|
||||
stage: 'unknown', error: null, deployed_at: null, restarted_at: null, healthy_at: null, match_seen_at: null, provisioning_id: null });
|
||||
if (!rows.some((row) => row.server === server.server))
|
||||
rows.push({
|
||||
server: server.server,
|
||||
server_deleted_at: server.server_deleted_at,
|
||||
stage: 'unknown',
|
||||
error: null,
|
||||
deployed_at: null,
|
||||
restarted_at: null,
|
||||
healthy_at: null,
|
||||
match_seen_at: null,
|
||||
provisioning_id: null
|
||||
});
|
||||
}
|
||||
return rows.map(deployment => {
|
||||
const result = test.servers.find(server => server.server === deployment.server);
|
||||
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
|
||||
return { deployment: { ...deployment, server_deleted_at: deletedAt }, result: result ? { ...result, server_deleted_at: deletedAt } : undefined };
|
||||
})
|
||||
.sort((a,b) => rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server));
|
||||
return rows
|
||||
.map((deployment) => {
|
||||
const result = test.servers.find((server) => server.server === deployment.server);
|
||||
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
|
||||
return {
|
||||
deployment: { ...deployment, server_deleted_at: deletedAt },
|
||||
result: result ? { ...result, server_deleted_at: deletedAt } : undefined
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server)
|
||||
);
|
||||
});
|
||||
function rank(row: { deployment: BetaDeploymentServer; result?: BetaTestView['servers'][number] }) {
|
||||
return row.deployment.stage === 'failed' || row.result?.progress.some(check => check.failed) ? 0 : row.result?.passed ? 2 : 1;
|
||||
function rank(row: {
|
||||
deployment: BetaDeploymentServer;
|
||||
result?: BetaTestView['servers'][number];
|
||||
}) {
|
||||
return row.deployment.stage === 'failed' ||
|
||||
row.result?.progress.some((check) => check.failed)
|
||||
? 0
|
||||
: row.result?.passed
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
|
||||
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||
export async function action(action:string, extra:Record<string,string>={}) {
|
||||
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||
busy = true; error = ''; message = '';
|
||||
const filteredEvidence = $derived(
|
||||
test.evidence.filter(
|
||||
(event) =>
|
||||
(!evidenceServer || event.server === evidenceServer) &&
|
||||
(!evidenceKind ||
|
||||
(evidenceKind === 'gameplay'
|
||||
? ['game_start', 'round_change', 'heartbeat', 'game_end'].includes(
|
||||
event.kind
|
||||
)
|
||||
: evidenceKind === 'health'
|
||||
? ['health', 'status-sample'].includes(event.kind)
|
||||
: event.kind === evidenceKind)) &&
|
||||
(!evidenceQuery ||
|
||||
`${betaEvidenceSummary(event)} ${event.payload}`
|
||||
.toLowerCase()
|
||||
.includes(evidenceQuery.toLowerCase()))
|
||||
)
|
||||
);
|
||||
$effect(() => {
|
||||
void evidenceServer;
|
||||
void evidenceKind;
|
||||
void evidenceQuery;
|
||||
evidenceLimit = 25;
|
||||
});
|
||||
export async function action(action: string, extra: Record<string, string> = {}) {
|
||||
if (
|
||||
action === 'retry' &&
|
||||
!(await confirmAction({
|
||||
title: 'Start a fresh test attempt?',
|
||||
message: `Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,
|
||||
confirmLabel: 'Reset and restart testing'
|
||||
}))
|
||||
)
|
||||
return;
|
||||
busy = true;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
|
||||
const response = await fetch(`/api/betas/${betaId}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, ...extra })
|
||||
}),
|
||||
body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
|
||||
await onUpdate();
|
||||
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||||
} catch(e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
message =
|
||||
action === 'sign-off'
|
||||
? 'Observation recorded.'
|
||||
: action === 'retry'
|
||||
? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.'
|
||||
: action === 'pause'
|
||||
? 'Testing paused. The original deadline still applies.'
|
||||
: 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function showEvidence(server:string, requirement:BetaRequirement) {
|
||||
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
|
||||
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
|
||||
async function showEvidence(server: string, requirement: BetaRequirement) {
|
||||
evidenceServer = server;
|
||||
evidenceKind = needsGameTelemetry(requirement)
|
||||
? 'gameplay'
|
||||
: requirement.kind === 'manual'
|
||||
? 'manual-sign-off'
|
||||
: ['failure-budget', 'healthy-minutes'].includes(requirement.kind)
|
||||
? 'health'
|
||||
: '';
|
||||
evidenceQuery = '';
|
||||
evidenceOpen = true;
|
||||
await tick();
|
||||
evidenceSection?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
evidenceSection?.querySelector('summary')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Test plan progress">
|
||||
<div class="heading"><h2>{deployments ? 'Test servers' : 'Test results'}</h2>{#if !deployments}<StatusBadge state={test.outcome}/>{/if}</div>
|
||||
<div class="heading">
|
||||
<h2>{deployments ? 'Test servers' : 'Test results'}</h2>
|
||||
{#if !deployments}<StatusBadge state={test.outcome} />{/if}
|
||||
</div>
|
||||
{#if !closed && !deployments}
|
||||
{#if !hasDeletedServers}<div class="actions">
|
||||
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
|
||||
{#if test.outcome === 'active'}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
|
||||
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
|
||||
</div>{/if}
|
||||
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||||
{#if expired}<p class="guidance">The testing window ended. Use “Update beta” to test changed files in a fresh revision, or roll back and reuse the plan with a longer window.</p>
|
||||
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
|
||||
{#if !hasDeletedServers}<div class="actions">
|
||||
{#if test.outcome === 'paused' && !expired}<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() => action('resume')}>Resume testing</Button
|
||||
>{/if}
|
||||
{#if test.outcome === 'active'}<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() => action('pause')}>Pause testing</Button
|
||||
>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome) && !expired}<Button
|
||||
size="sm"
|
||||
disabled={busy || !!retryBlocked}
|
||||
onclick={() => action('retry')}>Start fresh attempt</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
{#if retryBlocked && ['paused', 'failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||||
{#if expired}<p class="guidance">
|
||||
The testing window ended. Use “Update beta” to test changed files in a fresh
|
||||
revision, or roll back and reuse the plan with a longer window.
|
||||
</p>
|
||||
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">
|
||||
Resume retains completed milestones; continuous health and in-progress timing
|
||||
intervals start fresh. Pausing does not extend the deadline.
|
||||
</p>{/if}
|
||||
{/if}
|
||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
{#if !test.servers.length}<p>{closed ? 'Test results were not recorded.' : hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Awaiting the first test run.'}</p>{/if}
|
||||
{#if !test.servers.length}<p>
|
||||
{closed
|
||||
? 'Test results were not recorded.'
|
||||
: hasDeletedServers
|
||||
? 'No test run was recorded before a participating server was deleted.'
|
||||
: 'Awaiting the first test run.'}
|
||||
</p>{/if}
|
||||
{#if deployments}
|
||||
<div class="table-scroll"><table>
|
||||
<thead><tr><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col">Test result</th><th scope="col" class="align-right">Details</th></tr></thead>
|
||||
<tbody>{#each combined as { deployment, result } (deployment.server)}
|
||||
{@const failed = deployment.stage === 'failed' || result?.progress.some(check => check.failed)}
|
||||
{@const open = expanded[deployment.server] ?? Boolean(failed)}
|
||||
<tr>
|
||||
<td><HistoricalServerIdentity name={deployment.server} deletedAt={deployment.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(deployment.server)}`, $page.url)} /></td>
|
||||
<td><StatusBadge state={deployment.stage === 'observing' ? 'ready' : deployment.stage} /></td>
|
||||
<td>{#if result?.passed}<Badge variant="online">Passed</Badge>
|
||||
{:else if result?.progress.some(check => check.failed)}<Badge variant="danger">Failed</Badge>
|
||||
{:else if !result}<span>{closed || deployment.server_deleted_at ? 'Not recorded' : 'Awaiting test run'}</span>
|
||||
{:else}<Badge variant="neutral">{closed ? 'Incomplete' : test.outcome === 'paused' ? 'Paused' : 'Waiting'}</Badge>{/if}
|
||||
</td>
|
||||
<td class="align-right"><button class="disclosure" aria-expanded={open} aria-controls={`beta-server-${betaId}-${deployment.server}`} aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`} onclick={() => expanded = { ...expanded, [deployment.server]: !open }}>{open ? 'Hide' : 'Details'} <span aria-hidden="true">{open ? '−' : '+'}</span></button></td>
|
||||
</tr>
|
||||
<tr hidden={!open} id={`beta-server-${betaId}-${deployment.server}`} class="server-detail"><td colspan="4">
|
||||
{#if deployment.error}<p class:failed={deployment.stage === 'failed'}>{deployment.error}</p>{/if}
|
||||
<div class="deployment-details"><span>Files: {deployment.deployed_at ? 'installed' : 'pending'}</span><span>Restart: {deployment.restarted_at ? 'submitted' : 'pending'}</span><span>Health: {deployment.healthy_at ? 'verified' : 'not recorded'}</span>
|
||||
{#if deployment.match_seen_at}<span>Match observed {$timeFormat.dateTime(deployment.match_seen_at)}</span>{/if}
|
||||
</div>
|
||||
<div class="deployment-links">
|
||||
{#if !deployment.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}
|
||||
{#if deployment.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}
|
||||
</div>
|
||||
{#if result}
|
||||
{#if result.progress.length}<p>{result.progress.filter(check => check.passed).length} of {result.progress.length} checks passed{test.plan.requirements.some(requirement => requirement.kind === 'recovery') ? ` · Recovery cycle ${result.phase}` : ''}</p>{/if}
|
||||
{@render checks(result)}
|
||||
{:else}<p>{closed || deployment.server_deleted_at ? 'No test results were recorded for this server.' : 'Checks become available after the server restarts.'}</p>{/if}
|
||||
</td></tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col"
|
||||
>Test result</th
|
||||
><th scope="col" class="align-right">Details</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each combined as { deployment, result } (deployment.server)}
|
||||
{@const failed =
|
||||
deployment.stage === 'failed' ||
|
||||
result?.progress.some((check) => check.failed)}
|
||||
{@const open = expanded[deployment.server] ?? Boolean(failed)}
|
||||
<tr>
|
||||
<td
|
||||
><HistoricalServerIdentity
|
||||
name={deployment.server}
|
||||
deletedAt={deployment.server_deleted_at}
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(deployment.server)}`,
|
||||
$page.url
|
||||
)}
|
||||
/></td
|
||||
>
|
||||
<td
|
||||
><StatusBadge
|
||||
state={deployment.stage === 'observing'
|
||||
? 'ready'
|
||||
: deployment.stage}
|
||||
/></td
|
||||
>
|
||||
<td
|
||||
>{#if result?.passed}<Badge variant="online">Passed</Badge>
|
||||
{:else if result?.progress.some((check) => check.failed)}<Badge
|
||||
variant="danger">Failed</Badge
|
||||
>
|
||||
{:else if !result}<span
|
||||
>{closed || deployment.server_deleted_at
|
||||
? 'Not recorded'
|
||||
: 'Awaiting test run'}</span
|
||||
>
|
||||
{:else}<Badge variant="neutral"
|
||||
>{closed
|
||||
? 'Incomplete'
|
||||
: test.outcome === 'paused'
|
||||
? 'Paused'
|
||||
: 'Waiting'}</Badge
|
||||
>{/if}
|
||||
</td>
|
||||
<td class="align-right"
|
||||
><button
|
||||
class="disclosure"
|
||||
aria-expanded={open}
|
||||
aria-controls={`beta-server-${betaId}-${deployment.server}`}
|
||||
aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`}
|
||||
onclick={() =>
|
||||
(expanded = { ...expanded, [deployment.server]: !open })}
|
||||
>{open ? 'Hide' : 'Details'}
|
||||
<span aria-hidden="true">{open ? '−' : '+'}</span></button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr
|
||||
hidden={!open}
|
||||
id={`beta-server-${betaId}-${deployment.server}`}
|
||||
class="server-detail"
|
||||
><td colspan="4">
|
||||
{#if deployment.error}<p
|
||||
class:failed={deployment.stage === 'failed'}
|
||||
>
|
||||
{deployment.error}
|
||||
</p>{/if}
|
||||
<div class="deployment-details">
|
||||
<span
|
||||
>Files: {deployment.deployed_at
|
||||
? 'installed'
|
||||
: 'pending'}</span
|
||||
><span
|
||||
>Restart: {deployment.restarted_at
|
||||
? 'submitted'
|
||||
: 'pending'}</span
|
||||
><span
|
||||
>Health: {deployment.healthy_at
|
||||
? 'verified'
|
||||
: 'not recorded'}</span
|
||||
>
|
||||
{#if deployment.match_seen_at}<span
|
||||
>Match observed {$timeFormat.dateTime(
|
||||
deployment.match_seen_at
|
||||
)}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="deployment-links">
|
||||
{#if !deployment.server_deleted_at}<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`,
|
||||
$page.url
|
||||
)}>Diagnostics</a
|
||||
><a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(deployment.server)}?tab=logs`,
|
||||
$page.url
|
||||
)}>Open logs</a
|
||||
>{/if}
|
||||
{#if deployment.provisioning_id}<a
|
||||
href={contextHref(
|
||||
`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`,
|
||||
$page.url
|
||||
)}>View provisioning</a
|
||||
>{/if}
|
||||
</div>
|
||||
{#if result}
|
||||
{#if result.progress.length}<p>
|
||||
{result.progress.filter((check) => check.passed).length} of
|
||||
{result.progress.length} checks passed{test.plan.requirements.some(
|
||||
(requirement) => requirement.kind === 'recovery'
|
||||
)
|
||||
? ` · Recovery cycle ${result.phase}`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
{@render checks(result)}
|
||||
{:else}<p>
|
||||
{closed || deployment.server_deleted_at
|
||||
? 'No test results were recorded for this server.'
|
||||
: 'Checks become available after the server restarts.'}
|
||||
</p>{/if}
|
||||
</td></tr
|
||||
>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
{#each ranked as server (server.server)}
|
||||
<details class="server" open={!server.passed}>
|
||||
<summary><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
|
||||
{@render checks(server)}
|
||||
</details>
|
||||
{/each}
|
||||
{#each ranked as server (server.server)}
|
||||
<details class="server" open={!server.passed}>
|
||||
<summary
|
||||
><HistoricalServerIdentity
|
||||
name={server.server}
|
||||
deletedAt={server.server_deleted_at}
|
||||
/><span
|
||||
>{server.progress.filter((p) => p.passed).length} / {server.progress.length} checks
|
||||
passed</span
|
||||
>{#if test.plan.requirements.some((r) => r.kind === 'recovery')}<span
|
||||
>Recovery cycles: {server.phase}</span
|
||||
>{/if}</summary
|
||||
>
|
||||
{@render checks(server)}
|
||||
</details>
|
||||
{/each}
|
||||
{/if}
|
||||
<details><summary>Test policy</summary><p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p><p>{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback changes wait until the rollback campaign is resumed.'} Health checks continue after passing until promotion.</p></details>
|
||||
<details>
|
||||
<summary>Test policy</summary>
|
||||
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
|
||||
<p>
|
||||
{test.plan.waveSize
|
||||
? `${test.plan.waveSize} servers per wave.`
|
||||
: 'All selected servers deployed together.'}
|
||||
{test.plan.failureAction === 'rollback'
|
||||
? 'Failed checks trigger automatic rollback.'
|
||||
: test.plan.failureAction === 'pause'
|
||||
? 'Failed checks pause testing for investigation.'
|
||||
: 'Failed checks hold testing for review.'}
|
||||
{test.plan.rollbackRestart === 'when-empty'
|
||||
? 'Rollback restarts wait for empty servers.'
|
||||
: 'Rollback changes wait until the rollback campaign is resumed.'} Health checks continue
|
||||
after passing until promotion.
|
||||
</p>
|
||||
</details>
|
||||
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||
<summary
|
||||
>Evidence ({test.evidence.length}{test.evidence.length === 200
|
||||
? ' most recent'
|
||||
: ''})</summary
|
||||
>
|
||||
{#if evidenceOpen}
|
||||
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
|
||||
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{serverTitle(server)}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><span class="time"><RelativeTime value={event.received_at} /></span></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
|
||||
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
|
||||
<p>
|
||||
Newest first. Includes retained evidence from earlier attempts and recovery cycles;
|
||||
current progress uses only the current attempt.
|
||||
</p>
|
||||
<div class="filters">
|
||||
<label
|
||||
>Server<select bind:value={evidenceServer}
|
||||
><option value="">All servers</option
|
||||
>{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option
|
||||
value={server}>{serverTitle(server)}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><label
|
||||
>Event type<select bind:value={evidenceKind}
|
||||
><option value="">All events</option><option value="gameplay"
|
||||
>Gameplay reports</option
|
||||
><option value="health">Health observations</option><option
|
||||
value="manual-sign-off">Tester sign-offs</option
|
||||
></select
|
||||
></label
|
||||
><label
|
||||
>Search evidence<input
|
||||
type="search"
|
||||
bind:value={evidenceQuery}
|
||||
placeholder="Round, map, tester, observation…"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
|
||||
class="evidence"
|
||||
>
|
||||
<div class="heading">
|
||||
<HistoricalServerIdentity
|
||||
name={event.server}
|
||||
deletedAt={event.server_deleted_at}
|
||||
/><span class="time"><RelativeTime value={event.received_at} /></span>
|
||||
</div>
|
||||
<p>{betaEvidenceSummary(event)}</p>
|
||||
<details>
|
||||
<summary>Raw payload · {event.kind}</summary>
|
||||
<pre>{event.payload}</pre>
|
||||
</details>
|
||||
</article>{/each}
|
||||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events" />
|
||||
{#if !filteredEvidence.length}<p>
|
||||
{test.evidence.length
|
||||
? 'No evidence matches these filters.'
|
||||
: 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}
|
||||
</p>{/if}
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{#snippet checks(server: BetaTestView['servers'][number])}
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
|
||||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
|
||||
{#if !progress.passed && !closed && !hasDeletedServers}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
|
||||
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">
|
||||
{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)} ·
|
||||
<span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span>
|
||||
· {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null}
|
||||
· Round {server.observation.round}{/if}{:else}No gameplay reports in this
|
||||
attempt yet.{/if}
|
||||
{#if !server.server_deleted_at}<a
|
||||
href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}
|
||||
>Telemetry diagnostics</a
|
||||
>{/if}
|
||||
</p>{/if}
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find((r) => r.id === progress.id)}
|
||||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||
<div class="heading">
|
||||
<strong
|
||||
><span class="result"
|
||||
>{progress.passed
|
||||
? 'Passed'
|
||||
: progress.failed
|
||||
? 'Failed'
|
||||
: 'Waiting'}</span
|
||||
>
|
||||
{requirementSummary(requirement)}</strong
|
||||
><span>{progress.detail}</span>
|
||||
</div>
|
||||
{#if !progress.passed && !closed && !hasDeletedServers}<p>
|
||||
{betaWaitingCondition(requirement, server.observation)}
|
||||
</p>{/if}
|
||||
{#if !['manual', 'failure-budget'].includes(requirement.kind)}<progress
|
||||
aria-label={requirementSummary(requirement)}
|
||||
value={Math.min(progress.current, progress.target)}
|
||||
max={progress.target}
|
||||
></progress>{/if}
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
|
||||
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
|
||||
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
|
||||
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
|
||||
{#if canSign && server.observingAt}<div class="signoff">
|
||||
<label
|
||||
>Tester name and observation<textarea
|
||||
aria-label={`Tester and observation for ${progress.label} on ${server.server}`}
|
||||
rows="2"
|
||||
maxlength="1000"
|
||||
bind:value={notes[`${server.server}:${progress.id}`]}
|
||||
placeholder="Name · what you checked and observed"
|
||||
></textarea></label
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()}
|
||||
onclick={() =>
|
||||
action('sign-off', {
|
||||
server: server.server,
|
||||
requirementId: progress.id,
|
||||
note: notes[`${server.server}:${progress.id}`]
|
||||
})}>Sign off</Button
|
||||
>
|
||||
</div>
|
||||
{:else if canSign}<p>
|
||||
Waiting for this server's restart and health verification to finish
|
||||
before tester sign-off.
|
||||
</p>
|
||||
{:else}<p>
|
||||
Sign-off is unavailable while this test is {test.outcome}. {test.outcome ===
|
||||
'paused'
|
||||
? 'Resume testing first.'
|
||||
: 'Use the recovery instructions above.'}
|
||||
</p>{/if}
|
||||
{/if}
|
||||
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
|
||||
<button
|
||||
class="evidence-link"
|
||||
onclick={() => showEvidence(server.server, requirement)}
|
||||
>View relevant evidence</button
|
||||
>
|
||||
</div>{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.table-scroll { overflow-x:auto; }
|
||||
table { width:100%; border-collapse:collapse; font-size:var(--text-sm); text-align:left; }
|
||||
th { padding:10px 12px; border-bottom:1px solid var(--border-strong); color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); }
|
||||
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); }
|
||||
.align-right { text-align:right; }
|
||||
.disclosure { background:transparent; border:0; padding:8px 0; color:var(--text-secondary); cursor:pointer; font:inherit; white-space:nowrap; }
|
||||
.server-detail { background:var(--surface); }
|
||||
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
|
||||
@media(max-width:720px) { table { min-width:620px; } }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
|
||||
p,span,.time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
|
||||
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
|
||||
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size: var(--text-xs); }
|
||||
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||
label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
|
||||
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size: var(--text-xs); } .evidence-section > p { margin-top:12px; }
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
td {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
.disclosure {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.server-detail {
|
||||
background: var(--surface);
|
||||
}
|
||||
.deployment-details,
|
||||
.deployment-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin: 12px 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
table {
|
||||
min-width: 620px;
|
||||
}
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p,
|
||||
span,
|
||||
.time {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.heading,
|
||||
.actions,
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.server {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
.server > summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.server > summary::before {
|
||||
content: '▸';
|
||||
}
|
||||
.server[open] > summary::before {
|
||||
content: '▾';
|
||||
}
|
||||
.requirement {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.result {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
color: inherit;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
accent-color: var(--state-online);
|
||||
}
|
||||
.failed {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.observation,
|
||||
.guidance {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.evidence-link {
|
||||
justify-self: start;
|
||||
padding: 6px 0;
|
||||
color: var(--accent-primary-hover);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.signoff {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.signoff label {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.filters {
|
||||
margin: 16px 0;
|
||||
align-items: end;
|
||||
}
|
||||
.filters label {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
.evidence {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.evidence-section {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.evidence-section > p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
||||
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false), blueprint = null, onsaved }:
|
||||
{ open?: boolean; blueprint?: BlueprintSummary | null; onsaved: (blueprint: BlueprintSummary, launch: boolean) => void } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
blueprint = null,
|
||||
onsaved
|
||||
}: {
|
||||
open?: boolean;
|
||||
blueprint?: BlueprintSummary | null;
|
||||
onsaved: (blueprint: BlueprintSummary, launch: boolean) => void;
|
||||
} = $props();
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -15,25 +22,73 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
|
||||
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
|
||||
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 15 && !duplicate(row)));
|
||||
let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–15 target lobbies for every row.' : '');
|
||||
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
|
||||
const duplicate = (row: (typeof rows)[number]) =>
|
||||
rows.some(
|
||||
(other) =>
|
||||
other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug
|
||||
);
|
||||
let valid = $derived(
|
||||
name.trim().length > 0 &&
|
||||
rows.length > 0 &&
|
||||
total <= 100 &&
|
||||
rows.every(
|
||||
(row) =>
|
||||
row.mapSlug &&
|
||||
Number.isInteger(row.count) &&
|
||||
row.count >= 1 &&
|
||||
row.count <= 15 &&
|
||||
!duplicate(row)
|
||||
)
|
||||
);
|
||||
let reason = $derived(
|
||||
!name.trim()
|
||||
? 'Enter a collection name to save.'
|
||||
: total > 100
|
||||
? `Reduce the total by ${total - 100} to stay within 100 target lobbies.`
|
||||
: rows.some(duplicate)
|
||||
? 'Choose each game and map once; increase its lobby count instead.'
|
||||
: !valid
|
||||
? 'Choose a map and 1–15 target lobbies for every row.'
|
||||
: ''
|
||||
);
|
||||
let canAdd = $derived(
|
||||
(['t5', 't6'] as const).some((game) =>
|
||||
catalogs[game].some(
|
||||
(map) => !rows.some((row) => row.game === game && row.mapSlug === map.slug)
|
||||
)
|
||||
)
|
||||
);
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let nextKey = 0;
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
if (open && !wasOpen) {
|
||||
wasOpen = true;
|
||||
dialog.showModal();
|
||||
void initialize();
|
||||
} else if (!open && wasOpen) {
|
||||
wasOpen = false;
|
||||
session++;
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
const current = ++session;
|
||||
name = blueprint?.name ?? ''; description = blueprint?.description ?? '';
|
||||
rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.game, mapSlug: entry.mapSlug, count: entry.count })) ?? [];
|
||||
error = ''; loaded = false; busy = false;
|
||||
name = blueprint?.name ?? '';
|
||||
description = blueprint?.description ?? '';
|
||||
rows =
|
||||
blueprint?.entries.map((entry) => ({
|
||||
key: nextKey++,
|
||||
game: entry.game,
|
||||
mapSlug: entry.mapSlug,
|
||||
count: entry.count
|
||||
})) ?? [];
|
||||
error = '';
|
||||
loaded = false;
|
||||
busy = false;
|
||||
try {
|
||||
const res = await fetch('/api/maps');
|
||||
if (!res.ok) throw new Error('Could not load maps. Try again.');
|
||||
@@ -42,77 +97,373 @@
|
||||
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||
if (!rows.length) addMap();
|
||||
loaded = true;
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
} catch (err) {
|
||||
if (current === session) error = (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function addMap() {
|
||||
for (const game of ['t5', 't6'] as const) {
|
||||
const map = catalogs[game].find(map => !rows.some(row => row.game === game && row.mapSlug === map.slug));
|
||||
if (map) { rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }]; return; }
|
||||
const map = catalogs[game].find(
|
||||
(map) => !rows.some((row) => row.game === game && row.mapSlug === map.slug)
|
||||
);
|
||||
if (map) {
|
||||
rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save(launch: boolean) {
|
||||
if (busy || !valid) return;
|
||||
busy = true; error = '';
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(blueprint ? `/api/blueprints/${encodeURIComponent(blueprint.id)}` : '/api/blueprints', {
|
||||
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
|
||||
const res = await fetch(
|
||||
blueprint
|
||||
? `/api/blueprints/${encodeURIComponent(blueprint.id)}`
|
||||
: '/api/blueprints',
|
||||
{
|
||||
method: blueprint ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count }))
|
||||
})
|
||||
}
|
||||
);
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
|
||||
open = false; onsaved(body.blueprint, launch);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
open = false;
|
||||
onsaved(body.blueprint, launch);
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Collection builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
aria-label="Collection builder"
|
||||
onclick={(event) => {
|
||||
if (isDialogBackdrop(event, dialog) && !busy) open = false;
|
||||
}}
|
||||
oncancel={(event) => {
|
||||
if (busy) event.preventDefault();
|
||||
}}
|
||||
onclose={() => (open = false)}
|
||||
>
|
||||
<div class="builder">
|
||||
<header><div><span class="eyebrow">One collection. Your whole lineup.</span><h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection builder">×</button></header>
|
||||
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same collection.</p>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">One collection. Your whole lineup.</span>
|
||||
<h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="close"
|
||||
disabled={busy}
|
||||
onclick={() => (open = false)}
|
||||
aria-label="Close collection builder">×</button
|
||||
>
|
||||
</header>
|
||||
<p class="muted">
|
||||
Choose the total lobbies you want for each map. Existing and queued servers count; only
|
||||
missing lobbies are created. Mix Black Ops I and II in the same collection.
|
||||
</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{#if !loaded}<p class="muted">Loading maps…</p>
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
||||
<div class="identity"><label>Collection name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save(true);
|
||||
}}
|
||||
>
|
||||
<div class="identity">
|
||||
<label
|
||||
>Collection name<input
|
||||
bind:value={name}
|
||||
maxlength="80"
|
||||
placeholder="Friday Zombies"
|
||||
required
|
||||
disabled={busy}
|
||||
/></label
|
||||
><label
|
||||
>Description (optional)<input
|
||||
bind:value={description}
|
||||
maxlength="500"
|
||||
placeholder="Our weekend lineup"
|
||||
disabled={busy}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<div class="lineup">
|
||||
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
|
||||
<div class="section-title">
|
||||
<h3>Your lineup</h3>
|
||||
<span class="count" class:error={total > 100} role="status"
|
||||
>{total} / 100 target lobbies</span
|
||||
>
|
||||
</div>
|
||||
{#each rows as row, index (row.key)}
|
||||
<div class="map-row">
|
||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Map<select aria-label={`Map ${index + 1}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
|
||||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 15) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="15" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–15</span></label>
|
||||
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
||||
<label
|
||||
>Game<select
|
||||
aria-label={`Game ${index + 1}`}
|
||||
bind:value={row.game}
|
||||
disabled={busy}
|
||||
onchange={(event) =>
|
||||
(row.mapSlug =
|
||||
catalogs[event.currentTarget.value as ZombieGame][0]
|
||||
?.slug ?? '')}
|
||||
><option value="t5">Black Ops I</option><option value="t6"
|
||||
>Black Ops II</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Map<select
|
||||
aria-label={`Map ${index + 1}`}
|
||||
aria-invalid={duplicate(row) || undefined}
|
||||
aria-describedby={duplicate(row)
|
||||
? `map-error-${row.key}`
|
||||
: undefined}
|
||||
bind:value={row.mapSlug}
|
||||
disabled={busy}
|
||||
>{#each catalogs[row.game] as map}<option
|
||||
value={map.slug}
|
||||
disabled={rows.some(
|
||||
(other) =>
|
||||
other.key !== row.key &&
|
||||
other.game === row.game &&
|
||||
other.mapSlug === map.slug
|
||||
)}>{map.friendly}</option
|
||||
>{/each}</select
|
||||
>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}
|
||||
>Already selected. Choose another map.</span
|
||||
>{/if}</label
|
||||
>
|
||||
<label
|
||||
>Target lobbies<input
|
||||
aria-label={`Lobbies ${index + 1}`}
|
||||
aria-invalid={!(
|
||||
Number.isInteger(row.count) &&
|
||||
row.count >= 1 &&
|
||||
row.count <= 15
|
||||
) || undefined}
|
||||
aria-describedby={`count-hint-${row.key}`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="15"
|
||||
step="1"
|
||||
bind:value={row.count}
|
||||
required
|
||||
disabled={busy}
|
||||
/><span id={`count-hint-${row.key}`}>1–15</span></label
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label={`Remove map ${index + 1}`}
|
||||
disabled={busy || rows.length === 1}
|
||||
onclick={() => (rows = rows.filter((item) => item.key !== row.key))}
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
|
||||
</div>
|
||||
<p class="muted">Collections save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
|
||||
<p class="muted">
|
||||
Collections save your maps and target lobby counts. Every new server uses the
|
||||
latest promoted scripts, plugins, and configuration. Before launch, you’ll
|
||||
review every new server ID, port, and key.
|
||||
</p>
|
||||
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
|
||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save collection</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
||||
<div class="actions">
|
||||
<Button disabled={busy || !valid} onclick={() => save(false)}
|
||||
>Save collection</Button
|
||||
><Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={busy || !valid}
|
||||
loading={busy}>Save & preview missing servers →</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: var(--text-base); line-height: 1.6; }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: var(--text-xl); letter-spacing: -.03em; } h3 { font-size: var(--text-md); }.muted { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: var(--text-xs); min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; font-variant-numeric: tabular-nums; }.count { font-size: var(--text-xs); padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: var(--text-xl); cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: var(--text-sm); }button:disabled { opacity: .5; cursor: default; }
|
||||
.validation {
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--state-warning);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
}
|
||||
dialog {
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 40px);
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.builder {
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
header,
|
||||
.section-title,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.eyebrow {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
form,
|
||||
.lineup {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.identity {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: var(--text-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.lineup {
|
||||
border-block: 1px solid var(--border-subtle);
|
||||
padding: 20px 0;
|
||||
}
|
||||
.map-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 1.5fr 80px 24px;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
padding: 14px;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.number {
|
||||
font-family: var(--font-numeric);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
align-self: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.count {
|
||||
font-size: var(--text-xs);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
}
|
||||
.close,
|
||||
.remove {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xl);
|
||||
cursor: pointer;
|
||||
}
|
||||
.remove {
|
||||
padding: 7px 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.builder { padding: 20px; }
|
||||
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
|
||||
.close, .remove { min-width: 44px; }
|
||||
.identity { grid-template-columns: 1fr; }
|
||||
.map-row { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
.number { display: none; font-variant-numeric: tabular-nums; }
|
||||
.map-row label:first-of-type, .map-row label:nth-of-type(2) { grid-column: 1 / -1; }
|
||||
.actions { align-items: stretch; flex-direction: column; }
|
||||
.builder {
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
align-items: start;
|
||||
}
|
||||
.close,
|
||||
.remove {
|
||||
min-width: 44px;
|
||||
}
|
||||
.identity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.map-row {
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
}
|
||||
.number {
|
||||
display: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.map-row label:first-of-type,
|
||||
.map-row label:nth-of-type(2) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,15 +26,32 @@
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
}
|
||||
.mark-sm .word { font-size: var(--text-sm); letter-spacing: 1.5px; }
|
||||
.mark-md .word { font-size: var(--text-md); }
|
||||
.mark-lg .word { font-size: var(--text-2xl); letter-spacing: 3px; }
|
||||
.mark-sm .word {
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
.mark-md .word {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.mark-lg .word {
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.bolt {
|
||||
fill: var(--accent-primary-hover);
|
||||
filter: drop-shadow(0 0 5px rgba(124, 212, 255, 0.75));
|
||||
}
|
||||
.mark-sm .bolt { width: 12px; height: 20px; }
|
||||
.mark-md .bolt { width: 14px; height: 24px; }
|
||||
.mark-lg .bolt { width: 22px; height: 36px; }
|
||||
.mark-sm .bolt {
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
}
|
||||
.mark-md .bolt {
|
||||
width: 14px;
|
||||
height: 24px;
|
||||
}
|
||||
.mark-lg .bolt {
|
||||
width: 22px;
|
||||
height: 36px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,57 +15,138 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const url = `/api/hosts/${encodeURIComponent(hostId)}/samples?` + new URLSearchParams({ scope, phase, page: String(page) });
|
||||
const url =
|
||||
`/api/hosts/${encodeURIComponent(hostId)}/samples?` +
|
||||
new URLSearchParams({ scope, phase, page: String(page) });
|
||||
void revision;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
let active = true;
|
||||
loading = true; error = ''; result = null;
|
||||
loading = true;
|
||||
error = '';
|
||||
result = null;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.message ?? body.error ?? 'Could not load samples.');
|
||||
if (!response.ok)
|
||||
throw new Error(body.message ?? body.error ?? 'Could not load samples.');
|
||||
if (active) result = body;
|
||||
} catch (cause) {
|
||||
if (active) error = controller.signal.aborted ? 'Sample request timed out. Try refreshing.' : (cause as Error).message;
|
||||
} finally { clearTimeout(timer); if (active) loading = false; }
|
||||
if (active)
|
||||
error = controller.signal.aborted
|
||||
? 'Sample request timed out. Try refreshing.'
|
||||
: (cause as Error).message;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (active) loading = false;
|
||||
}
|
||||
})();
|
||||
return () => { active = false; clearTimeout(timer); controller.abort(); };
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<details bind:open>
|
||||
<summary>View samples</summary>
|
||||
{#if open}
|
||||
<p>Retained minute peaks from the last 24 hours, used without extra allowance padding. CPU and memory peaks may come from different heartbeats. Choose the fleet to inspect measurements from other hosts used by fleet allowances.</p>
|
||||
<p>
|
||||
Retained minute peaks from the last 24 hours, used without extra allowance padding. CPU
|
||||
and memory peaks may come from different heartbeats. Choose the fleet to inspect
|
||||
measurements from other hosts used by fleet allowances.
|
||||
</p>
|
||||
<div class="controls">
|
||||
<label>Source<select bind:value={scope} onchange={() => page = 1}><option value="host">This host</option><option value="fleet">Entire fleet</option></select></label>
|
||||
<label>Phase<select bind:value={phase} onchange={() => page = 1}><option value="all">All phases</option><option value="startup">Starting</option><option value="running">Running</option></select></label>
|
||||
<Button size="sm" disabled={loading} onclick={() => { page = 1; revision++; }}>Refresh samples</Button>
|
||||
<label
|
||||
>Source<select bind:value={scope} onchange={() => (page = 1)}
|
||||
><option value="host">This host</option><option value="fleet"
|
||||
>Entire fleet</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Phase<select bind:value={phase} onchange={() => (page = 1)}
|
||||
><option value="all">All phases</option><option value="startup">Starting</option
|
||||
><option value="running">Running</option></select
|
||||
></label
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onclick={() => {
|
||||
page = 1;
|
||||
revision++;
|
||||
}}>Refresh samples</Button
|
||||
>
|
||||
</div>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading samples…</p>
|
||||
{:else if result}
|
||||
{#if result.total === 0}<p role="status">No {phase === 'all' ? '' : phase === 'startup' ? 'startup ' : 'running '}samples recorded for this source in the last 24 hours.</p>
|
||||
{#if result.total === 0}<p role="status">
|
||||
No {phase === 'all' ? '' : phase === 'startup' ? 'startup ' : 'running '}samples
|
||||
recorded for this source in the last 24 hours.
|
||||
</p>
|
||||
{:else}
|
||||
<p>{result.total.toLocaleString()} samples · newest first. Counts can differ from the allowance above, which is recalculated periodically.</p>
|
||||
<p>
|
||||
{result.total.toLocaleString()} samples · newest first. Counts can differ from the
|
||||
allowance above, which is recalculated periodically.
|
||||
</p>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The overflow region needs keyboard focus for horizontal scrolling.) -->
|
||||
<div class="table-wrap" tabindex="0" role="region" aria-label="Container resource samples">
|
||||
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
|
||||
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
|
||||
<tbody>{#each result.samples as sample}
|
||||
<tr><td><RelativeTime value={sample.timestamp} /></td>
|
||||
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
|
||||
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
|
||||
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
|
||||
{/each}</tbody>
|
||||
<div
|
||||
class="table-wrap"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="Container resource samples"
|
||||
>
|
||||
<table>
|
||||
<caption
|
||||
>CPU and memory peaks per container and minute; times are local.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col"
|
||||
>Host</th
|
||||
>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th
|
||||
scope="col">CPU cores</th
|
||||
><th scope="col">Memory</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each result.samples as sample}
|
||||
<tr
|
||||
><td><RelativeTime value={sample.timestamp} /></td>
|
||||
{#if scope === 'fleet'}<td
|
||||
>{sample.hostName ?? sample.hostId}</td
|
||||
>{/if}
|
||||
<td
|
||||
>{sample.server ?? sample.instanceId}<small
|
||||
title={sample.instanceId}
|
||||
>Generation {sample.generation}</small
|
||||
></td
|
||||
>
|
||||
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td
|
||||
><td>{sample.cpuCores.toFixed(3)}</td><td
|
||||
>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td
|
||||
></tr
|
||||
>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
<nav aria-label="Sample pages">
|
||||
<Button size="sm" disabled={result.page <= 1} onclick={() => page = result!.page - 1}>Previous</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={result.page <= 1}
|
||||
onclick={() => (page = result!.page - 1)}>Previous</Button
|
||||
>
|
||||
<span>Page {result.page} of {Math.ceil(result.total / result.pageSize)}</span>
|
||||
<Button size="sm" disabled={result.page * result.pageSize >= result.total} onclick={() => page = result!.page + 1}>Next</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={result.page * result.pageSize >= result.total}
|
||||
onclick={() => (page = result!.page + 1)}>Next</Button
|
||||
>
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -73,13 +154,75 @@
|
||||
</details>
|
||||
|
||||
<style>
|
||||
details { margin-top:20px; min-width:0; } summary { cursor:pointer; color:var(--accent-primary); font-weight:600; }
|
||||
p, caption, small, nav { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
|
||||
.controls, nav { display:flex; flex-wrap:wrap; align-items:end; gap:12px; margin:16px 0; }
|
||||
nav { align-items:center; } label { display:grid; gap:6px; font-size:var(--text-sm); }
|
||||
select { padding:8px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
.table-wrap { max-width:100%; overflow:auto; } .table-wrap:focus-visible, summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
|
||||
caption { text-align:left; padding-bottom:8px; } td,th { padding:10px; border-bottom:1px solid var(--border-subtle); }
|
||||
td { white-space:nowrap; } small { display:block; font-size:var(--text-xs); }
|
||||
details {
|
||||
margin-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
p,
|
||||
caption,
|
||||
small,
|
||||
nav {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.controls,
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
nav {
|
||||
align-items: center;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
select {
|
||||
padding: 8px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.table-wrap {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.table-wrap:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,12 +8,22 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
|
||||
let { server, history, hostOffline = false, hostName }: { server?: string; history?: ContextualChatEntry[]; hostOffline?: boolean; hostName?: string } = $props();
|
||||
let {
|
||||
server,
|
||||
history,
|
||||
hostOffline = false,
|
||||
hostName
|
||||
}: {
|
||||
server?: string;
|
||||
history?: ContextualChatEntry[];
|
||||
hostOffline?: boolean;
|
||||
hostName?: string;
|
||||
} = $props();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
const entries = $derived(history ?? liveEntries);
|
||||
let timeline = $derived(groupChatTimeline(entries));
|
||||
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
|
||||
let hasMessages = $derived(entries.some((entry) => entry.type === 'message'));
|
||||
let liveLoaded = $state(false);
|
||||
const loaded = $derived(history !== undefined || liveLoaded);
|
||||
let loading = $state(false);
|
||||
@@ -36,23 +46,33 @@
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, {
|
||||
signal: request.signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
|
||||
if (!response.ok || !data?.ok)
|
||||
throw new Error('Could not refresh chat. Try again shortly.');
|
||||
if (request.signal.aborted || hostOffline) return;
|
||||
if (paused && !manual) return;
|
||||
liveEntries = data.entries ?? [];
|
||||
missing = !!data.missing;
|
||||
liveLoaded = true; updatedAt = Date.now();
|
||||
liveLoaded = true;
|
||||
updatedAt = Date.now();
|
||||
error = '';
|
||||
await tick();
|
||||
if (follow && body) body.scrollTop = body.scrollHeight;
|
||||
} catch (err) {
|
||||
if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.';
|
||||
} finally { loading = false; manualRefreshing = false; }
|
||||
if (!request.signal.aborted)
|
||||
error = err instanceof Error ? err.message : 'Could not refresh chat.';
|
||||
} finally {
|
||||
loading = false;
|
||||
manualRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { if (hostOffline) controller?.abort(); });
|
||||
$effect(() => {
|
||||
if (hostOffline) controller?.abort();
|
||||
});
|
||||
|
||||
function onScroll() {
|
||||
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
||||
@@ -64,8 +84,11 @@
|
||||
}
|
||||
onMount(() => {
|
||||
if (!server) return;
|
||||
const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000);
|
||||
return () => { stop(); controller?.abort(); };
|
||||
const stop = pollVisible(() => (paused ? Promise.resolve() : refresh()), 5000);
|
||||
return () => {
|
||||
stop();
|
||||
controller?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -74,54 +97,128 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet clockTime(value: string)}
|
||||
<time datetime={value} title={$timeFormat.dateTime(value)} aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time>
|
||||
<time
|
||||
datetime={value}
|
||||
title={$timeFormat.dateTime(value)}
|
||||
aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet chatContext(context: ChatContext)}
|
||||
<dl class="context" aria-label="Message context">
|
||||
<div><dt>Match</dt><dd>{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(context.matchElapsedMs)}{/if}</dd></div>
|
||||
<div><dt>Round</dt><dd>{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}</dd></div>
|
||||
<div>
|
||||
<dt>Match</dt>
|
||||
<dd>
|
||||
{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(
|
||||
context.matchElapsedMs
|
||||
)}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Round</dt>
|
||||
<dd>
|
||||
{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/snippet}
|
||||
|
||||
{#snippet matchBoundary(entry: ChatBoundaryEntry)}
|
||||
<div class="match-divider">
|
||||
<div class="match-label">
|
||||
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match'}</span>
|
||||
{#if entry.context.realTime}<span class="boundary-context">{@render clockTime(entry.context.realTime)}</span>{/if}
|
||||
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context">Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span></span>{/if}
|
||||
{#if entry.context.round !== null}<span class="boundary-context">Round <span class="number">{entry.context.round}</span></span>{/if}
|
||||
<span
|
||||
>{entry.event === 'start'
|
||||
? 'Match started'
|
||||
: entry.event === 'end'
|
||||
? 'Match ended'
|
||||
: 'New match'}</span
|
||||
>
|
||||
{#if entry.context.realTime}<span class="boundary-context"
|
||||
>{@render clockTime(entry.context.realTime)}</span
|
||||
>{/if}
|
||||
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context"
|
||||
>Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span
|
||||
></span
|
||||
>{/if}
|
||||
{#if entry.context.round !== null}<span class="boundary-context"
|
||||
>Round <span class="number">{entry.context.round}</span></span
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section aria-label="Player chat">
|
||||
<div class="chat-head">
|
||||
<div class="chat-title"><h2>Chat</h2>{#if !server}<span class="update-cadence">Match history</span>{:else if hostOffline}<span class="update-cadence">Host offline</span>{:else if paused}<span class="update-cadence">Paused</span>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}</div>
|
||||
<div class="chat-title">
|
||||
<h2>Chat</h2>
|
||||
{#if !server}<span class="update-cadence">Match history</span
|
||||
>{:else if hostOffline}<span class="update-cadence">Host offline</span
|
||||
>{:else if paused}<span class="update-cadence">Paused</span
|
||||
>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}
|
||||
</div>
|
||||
{#if server}
|
||||
<div class="actions">
|
||||
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
|
||||
{#if hasMessages}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => (paused = !paused)}>{paused ? 'Resume' : 'Pause'}</Button
|
||||
>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}
|
||||
>Latest messages</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hostOffline}<p class="outage">Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}</p>
|
||||
{:else if error}<p class="ui-error" role="alert">Chat updates paused. {loaded ? 'Showing the last loaded messages.' : 'Could not load messages.'} <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button></p>{/if}
|
||||
{#if hostOffline}<p class="outage">
|
||||
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
|
||||
? ' Showing the last loaded messages.'
|
||||
: ''}
|
||||
</p>
|
||||
{:else if error}<p class="ui-error" role="alert">
|
||||
Chat updates paused. {loaded
|
||||
? 'Showing the last loaded messages.'
|
||||
: 'Could not load messages.'}
|
||||
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button
|
||||
>
|
||||
</p>{/if}
|
||||
{#if !loaded && !error && !hostOffline}
|
||||
<p class="empty" role="status">Loading chat…</p>
|
||||
{:else if loaded && !hasMessages}
|
||||
<p class="empty">{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}</p>
|
||||
<p class="empty">
|
||||
{history !== undefined
|
||||
? 'No chat messages were saved for this match.'
|
||||
: missing
|
||||
? 'Waiting for the game log. Player messages will appear here.'
|
||||
: 'No player messages in this match yet.'}
|
||||
</p>
|
||||
{:else if hasMessages}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
|
||||
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
|
||||
<div
|
||||
class="messages"
|
||||
bind:this={body}
|
||||
onscroll={onScroll}
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="Chat messages"
|
||||
>
|
||||
<ol>
|
||||
{#each timeline as item}
|
||||
{#if item.type === 'boundaries'}
|
||||
<li class="match-events">
|
||||
{#if item.entries.length > 1}
|
||||
{#key JSON.stringify(item.entries)}
|
||||
<details class="earlier-events" ontoggle={(event) => { if (event.currentTarget.open) follow = false; }}>
|
||||
<summary>{item.entries.length - 1} earlier match {item.entries.length === 2 ? 'event' : 'events'}</summary>
|
||||
<details
|
||||
class="earlier-events"
|
||||
ontoggle={(event) => {
|
||||
if (event.currentTarget.open) follow = false;
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
>{item.entries.length - 1} earlier match {item.entries
|
||||
.length === 2
|
||||
? 'event'
|
||||
: 'events'}</summary
|
||||
>
|
||||
<ol aria-label="Earlier match events">
|
||||
{#each item.entries.slice(0, -1) as entry}
|
||||
<li>{@render matchBoundary(entry)}</li>
|
||||
@@ -135,10 +232,17 @@
|
||||
{:else}
|
||||
<li class="message">
|
||||
<span class="message-clock">
|
||||
{#if item.entry.context.realTime}{@render clockTime(item.entry.context.realTime)}{:else}<span class="sr-only">Time: </span>{@render unavailable()}{/if}
|
||||
{#if item.entry.context.realTime}{@render clockTime(
|
||||
item.entry.context.realTime
|
||||
)}{:else}<span class="sr-only"
|
||||
>Time:
|
||||
</span>{@render unavailable()}{/if}
|
||||
</span>
|
||||
<div class="message-content">
|
||||
<div class="message-head"><strong>{item.entry.message.player}</strong>{@render chatContext(item.entry.context)}</div>
|
||||
<div class="message-head">
|
||||
<strong>{item.entry.message.player}</strong
|
||||
>{@render chatContext(item.entry.context)}
|
||||
</div>
|
||||
<p>{item.entry.message.message}</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -150,44 +254,214 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { container-type: inline-size; }
|
||||
.chat-head, .actions, .chat-title { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.chat-head { justify-content: space-between; }
|
||||
.chat-title { gap: var(--space-2) var(--space-3); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outage { color: var(--state-warning); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
|
||||
.messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
|
||||
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
|
||||
ol { list-style: none; padding: 0; margin: 0; }
|
||||
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
|
||||
.message-clock, .number { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
|
||||
.message-content { min-width: 0; }
|
||||
.message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
|
||||
strong { min-width: 0; color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
||||
.context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
|
||||
.context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
|
||||
.context dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.match-events { padding: var(--space-1) 0; }
|
||||
.match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; min-width: var(--space-3); background: var(--border-subtle); }
|
||||
.match-label { min-width: 0; display: flex; align-items: baseline; justify-content: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); }
|
||||
.boundary-context { white-space: nowrap; }
|
||||
.boundary-context::before { content: '·'; margin-right: var(--space-2); color: var(--text-tertiary); }
|
||||
.earlier-events summary { width: fit-content; max-width: 100%; box-sizing: border-box; min-height: 32px; margin-inline: auto; padding: var(--space-2); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
|
||||
.earlier-events summary:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.earlier-events summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
section {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.chat-head,
|
||||
.actions,
|
||||
.chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chat-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.chat-title {
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.update-cadence {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.outage {
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.messages {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.messages:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
column-gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
.message-clock,
|
||||
.number {
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.message-clock {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.message-content {
|
||||
min-width: 0;
|
||||
}
|
||||
.message-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
strong {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.context {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
.context > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.context > div + div::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.context dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.message p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.match-events {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
.match-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.match-divider::before,
|
||||
.match-divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
min-width: var(--space-3);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
.match-label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
.boundary-context {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.boundary-context::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.earlier-events summary {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 32px;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.earlier-events summary:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.earlier-events summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@container (max-width: 560px) {
|
||||
.message { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-3); }
|
||||
.message-clock { grid-column: 2; grid-row: 1; }
|
||||
.message-content, .message-head { display: contents; }
|
||||
strong { grid-column: 1; grid-row: 1; }
|
||||
.context { grid-column: 1 / -1; grid-row: 2; }
|
||||
.message p { grid-column: 1 / -1; grid-row: 3; margin-top: 0; }
|
||||
.message {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
.message-clock {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.message-content,
|
||||
.message-head {
|
||||
display: contents;
|
||||
}
|
||||
strong {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.context {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.message p {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import type { UserRole } from '$lib/auth.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import { loadPaletteDirectory, paletteDirectoryResults, paletteGroups, paletteMatchJump, palettePageResults, searchPaletteRecords,
|
||||
type PaletteDirectory, type PaletteItem } from '$ui/command-palette.js';
|
||||
import {
|
||||
loadPaletteDirectory,
|
||||
paletteDirectoryResults,
|
||||
paletteGroups,
|
||||
paletteMatchJump,
|
||||
palettePageResults,
|
||||
searchPaletteRecords,
|
||||
type PaletteDirectory,
|
||||
type PaletteItem
|
||||
} from '$ui/command-palette.js';
|
||||
|
||||
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
|
||||
const id = $props.id();
|
||||
@@ -20,54 +28,111 @@
|
||||
let navigating = $state(false);
|
||||
const items = $derived.by(() => {
|
||||
const unique = new Map<string, PaletteItem>();
|
||||
for (const item of [...palettePageResults(role, query), ...paletteDirectoryResults(query, directory), ...paletteMatchJump(query), ...records]) unique.set(item.key, item);
|
||||
return paletteGroups.flatMap(group => [...unique.values()].filter(item => item.group === group));
|
||||
for (const item of [
|
||||
...palettePageResults(role, query),
|
||||
...paletteDirectoryResults(query, directory),
|
||||
...paletteMatchJump(query),
|
||||
...records
|
||||
])
|
||||
unique.set(item.key, item);
|
||||
return paletteGroups.flatMap((group) =>
|
||||
[...unique.values()].filter((item) => item.group === group)
|
||||
);
|
||||
});
|
||||
const activeIndex = $derived(Math.max(0, items.findIndex(item => item.key === selected)));
|
||||
const activeIndex = $derived(
|
||||
Math.max(
|
||||
0,
|
||||
items.findIndex((item) => item.key === selected)
|
||||
)
|
||||
);
|
||||
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
|
||||
const searching = $derived(directoryLoading || recordsLoading);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) { query = ''; records = []; directory = null; navigationError = ''; return; }
|
||||
if (!open) {
|
||||
query = '';
|
||||
records = [];
|
||||
directory = null;
|
||||
navigationError = '';
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
directoryLoading = true;
|
||||
void loadPaletteDirectory(controller.signal).then(result => { if (!cancelled) directory = result; })
|
||||
.finally(() => { clearTimeout(timeout); if (!cancelled) directoryLoading = false; });
|
||||
void tick().then(() => { if (!cancelled) input?.focus(); });
|
||||
return () => { cancelled = true; controller.abort(); clearTimeout(timeout); };
|
||||
void loadPaletteDirectory(controller.signal)
|
||||
.then((result) => {
|
||||
if (!cancelled) directory = result;
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (!cancelled) directoryLoading = false;
|
||||
});
|
||||
void tick().then(() => {
|
||||
if (!cancelled) input?.focus();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const term = query.trim();
|
||||
selected = ''; records = []; recordErrors = [];
|
||||
if (!open || term.length < 2) { recordsLoading = false; return; }
|
||||
selected = '';
|
||||
records = [];
|
||||
recordErrors = [];
|
||||
if (!open || term.length < 2) {
|
||||
recordsLoading = false;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
recordsLoading = true;
|
||||
const debounce = setTimeout(() => {
|
||||
timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
void searchPaletteRecords(term, controller.signal).then(result => {
|
||||
if (!cancelled) { records = result.items; recordErrors = result.unavailable; }
|
||||
}).finally(() => { clearTimeout(timeout); if (!cancelled) recordsLoading = false; });
|
||||
void searchPaletteRecords(term, controller.signal)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
records = result.items;
|
||||
recordErrors = result.unavailable;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (!cancelled) recordsLoading = false;
|
||||
});
|
||||
}, 220);
|
||||
return () => { cancelled = true; clearTimeout(debounce); clearTimeout(timeout); controller.abort(); };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const option = activeIndex;
|
||||
if (!open || !items.length) return;
|
||||
void tick().then(() => document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' }));
|
||||
void tick().then(() =>
|
||||
document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' })
|
||||
);
|
||||
});
|
||||
|
||||
async function choose(item: PaletteItem) {
|
||||
if (navigating) return;
|
||||
navigating = true; navigationError = '';
|
||||
try { await goto(item.href); open = false; }
|
||||
catch { navigationError = 'Could not open that result. Try again.'; }
|
||||
finally { navigating = false; }
|
||||
navigating = true;
|
||||
navigationError = '';
|
||||
try {
|
||||
await goto(item.href);
|
||||
open = false;
|
||||
} catch {
|
||||
navigationError = 'Could not open that result. Try again.';
|
||||
} finally {
|
||||
navigating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
@@ -77,54 +142,169 @@
|
||||
const movement = event.key === 'ArrowDown' ? 1 : -1;
|
||||
selected = items[(activeIndex + movement + items.length) % items.length].key;
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault(); void choose(items[activeIndex]);
|
||||
event.preventDefault();
|
||||
void choose(items[activeIndex]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title="Search and commands" description="Jump to a page, server, host, player, or match.">
|
||||
<Dialog
|
||||
bind:open
|
||||
title="Search and commands"
|
||||
description="Jump to a page, server, host, player, or match."
|
||||
>
|
||||
<div class="palette">
|
||||
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
|
||||
<input bind:this={input} bind:value={query} id={`${id}-search`} type="search" maxlength="100" placeholder="Search Electric Cherry…"
|
||||
role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-results`}
|
||||
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined} aria-describedby={`${id}-hint`}
|
||||
autocomplete="off" onkeydown={keydown} />
|
||||
<p class="hint" id={`${id}-hint`}>Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span></p>
|
||||
<div class="results" id={`${id}-results`} role="listbox" aria-label="Search results" aria-busy={searching || navigating}>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
id={`${id}-search`}
|
||||
type="search"
|
||||
maxlength="100"
|
||||
placeholder="Search Electric Cherry…"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-results`}
|
||||
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined}
|
||||
aria-describedby={`${id}-hint`}
|
||||
autocomplete="off"
|
||||
onkeydown={keydown}
|
||||
/>
|
||||
<p class="hint" id={`${id}-hint`}>
|
||||
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
|
||||
</p>
|
||||
<div
|
||||
class="results"
|
||||
id={`${id}-results`}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
aria-busy={searching || navigating}
|
||||
>
|
||||
{#each paletteGroups as group}
|
||||
{@const groupItems = items.filter(item => item.group === group)}
|
||||
{@const groupItems = items.filter((item) => item.group === group)}
|
||||
{#if groupItems.length}
|
||||
<div role="group" aria-labelledby={`${id}-${group}`}>
|
||||
<div class="group-label" id={`${id}-${group}`}>{group}</div>
|
||||
{#each groupItems as item (item.key)}
|
||||
{@const index = items.indexOf(item)}
|
||||
<button type="button" id={`${id}-option-${index}`} role="option" tabindex="-1" aria-selected={index === activeIndex}
|
||||
class:active={index === activeIndex} disabled={navigating} onclick={() => void choose(item)} onpointermove={() => selected = item.key}>
|
||||
<span class="result-label">{item.label}</span><span class="result-detail">{item.detail}</span>
|
||||
<button
|
||||
type="button"
|
||||
id={`${id}-option-${index}`}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={index === activeIndex}
|
||||
class:active={index === activeIndex}
|
||||
disabled={navigating}
|
||||
onclick={() => void choose(item)}
|
||||
onpointermove={() => (selected = item.key)}
|
||||
>
|
||||
<span class="result-label">{item.label}</span><span
|
||||
class="result-detail">{item.detail}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<p class="status" role="status">{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}</p>
|
||||
{#if unavailable.length}<p class="error">{unavailable.join(', ')} search is unavailable. Other results are still available.</p>{/if}
|
||||
<p class="status" role="status">
|
||||
{searching
|
||||
? 'Searching…'
|
||||
: items.length
|
||||
? `${items.length} results`
|
||||
: 'No matches. Try a different name or a match number.'}
|
||||
</p>
|
||||
{#if unavailable.length}<p class="error">
|
||||
{unavailable.join(', ')} search is unavailable. Other results are still available.
|
||||
</p>{/if}
|
||||
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.palette { display:grid; gap:var(--space-3); min-width:0; }
|
||||
input { width:100%; min-height:44px; padding:var(--space-3); font:inherit; font-size:var(--text-base); color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
.hint,.status,.error { margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.5; }
|
||||
.hint span { display:block; }
|
||||
.results { min-height:40px; max-height:min(55dvh,480px); overflow:auto; overscroll-behavior:contain; position:relative; }
|
||||
.group-label { padding:var(--space-3) var(--space-2) var(--space-1); font-size:var(--text-xs); color:var(--text-tertiary); font-weight:var(--weight-semibold); }
|
||||
button { display:grid; gap:var(--space-1); width:100%; min-height:48px; text-align:left; border:1px solid transparent; border-radius:var(--radius-sm); padding:var(--space-2) var(--space-3); background:transparent; color:var(--text-primary); font:inherit; cursor:pointer; }
|
||||
button.active { background:var(--surface-raised); border-color:var(--accent-primary); }
|
||||
button:disabled { cursor:wait; }
|
||||
.result-label { font-size:var(--text-sm); overflow-wrap:anywhere; }
|
||||
.result-detail { color:var(--text-secondary); font-size:var(--text-xs); overflow-wrap:anywhere; }
|
||||
.error { color:var(--state-warning); }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
|
||||
.palette {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: var(--space-3);
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.hint,
|
||||
.status,
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hint span {
|
||||
display: block;
|
||||
}
|
||||
.results {
|
||||
min-height: 40px;
|
||||
max-height: min(55dvh, 480px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
position: relative;
|
||||
}
|
||||
.group-label {
|
||||
padding: var(--space-3) var(--space-2) var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
button {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.active {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
.result-label {
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.result-detail {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,29 @@
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { configWarnings } from '$lib/config-workbench.js';
|
||||
interface Version { id: number; version: number; notes: string | null; }
|
||||
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
||||
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
||||
runningBetaId: number | null; scopeType: string | null; scopeValue: string | null; onsaved: () => Promise<void>;
|
||||
interface Version {
|
||||
id: number;
|
||||
version: number;
|
||||
notes: string | null;
|
||||
}
|
||||
let {
|
||||
id,
|
||||
kind,
|
||||
versions,
|
||||
currentVersionId,
|
||||
runningBetaId,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
onsaved
|
||||
}: {
|
||||
id: number;
|
||||
kind: 'baseline' | 'config';
|
||||
versions: Version[];
|
||||
currentVersionId: number | null;
|
||||
runningBetaId: number | null;
|
||||
scopeType: string | null;
|
||||
scopeValue: string | null;
|
||||
onsaved: () => Promise<void>;
|
||||
} = $props();
|
||||
let selected = $state('');
|
||||
let text = $state('');
|
||||
@@ -24,132 +43,406 @@
|
||||
let loadedVersion = $state('');
|
||||
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
||||
const dirty = $derived(text !== original || notes.trim().length > 0);
|
||||
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
|
||||
const ignoredWarnings = $derived(
|
||||
configWarnings(text, kind === 'config').filter(
|
||||
(warning) => warning.includes('ignored') || warning.includes('no value')
|
||||
)
|
||||
);
|
||||
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
||||
onMount(() => {
|
||||
selected = String(versions[0]?.id ?? ''); ready = true;
|
||||
selected = String(versions[0]?.id ?? '');
|
||||
ready = true;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
|
||||
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
|
||||
} catch { /* Draft storage is optional. */ }
|
||||
if (
|
||||
saved &&
|
||||
typeof saved.text === 'string' &&
|
||||
typeof saved.version === 'string' &&
|
||||
typeof saved.notes === 'string'
|
||||
)
|
||||
recovered = saved;
|
||||
} catch {
|
||||
/* Draft storage is optional. */
|
||||
}
|
||||
});
|
||||
let allowNavigation = false;
|
||||
beforeNavigate(({ cancel, to, type }) => {
|
||||
if (!dirty || allowNavigation || type === 'leave' || !to) return;
|
||||
cancel();
|
||||
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
|
||||
void confirmAction({
|
||||
title: 'Leave this editor?',
|
||||
message:
|
||||
'You have unsaved changes. A draft is kept in this browser when storage is available.',
|
||||
confirmLabel: 'Leave editor'
|
||||
}).then(async (accepted) => {
|
||||
if (!accepted) return;
|
||||
allowNavigation = true;
|
||||
try { await goto(to.url); } finally { allowNavigation = false; }
|
||||
try {
|
||||
await goto(to.url);
|
||||
} finally {
|
||||
allowNavigation = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (dirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
const version = selected;
|
||||
const refresh = retry;
|
||||
void retry;
|
||||
if (!ready || !version) return;
|
||||
const controller = new AbortController();
|
||||
loading = true; error = ''; loadedVersion = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
loadedVersion = '';
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${id}?version=${version}`, { signal: controller.signal });
|
||||
const response = await fetch(`/api/configs/${id}?version=${version}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load config contents.');
|
||||
if (!controller.signal.aborted) { text = body.text; original = body.text; notes = ''; loadedVersion = version; }
|
||||
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) loading = false; }
|
||||
if (!controller.signal.aborted) {
|
||||
text = body.text;
|
||||
original = body.text;
|
||||
notes = '';
|
||||
loadedVersion = version;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) error = (err as Error).message;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) loading = false;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
});
|
||||
$effect(() => {
|
||||
if (!ready || loading || !loadedVersion || !dirty) return;
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ version: loadedVersion, text, notes })
|
||||
);
|
||||
} catch {
|
||||
/* Editing still works without storage. */
|
||||
}
|
||||
});
|
||||
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
|
||||
function clearDraft() {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
recovered = null;
|
||||
}
|
||||
async function changeVersion(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const next = target.value; target.value = selected;
|
||||
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
|
||||
clearDraft(); selected = next; success = '';
|
||||
const next = target.value;
|
||||
target.value = selected;
|
||||
if (
|
||||
dirty &&
|
||||
!(await confirmAction({
|
||||
title: 'Discard current edits?',
|
||||
message: 'Open the selected version and discard your current changes.',
|
||||
confirmLabel: 'Discard edits',
|
||||
danger: true
|
||||
}))
|
||||
)
|
||||
return;
|
||||
clearDraft();
|
||||
selected = next;
|
||||
success = '';
|
||||
}
|
||||
function restoreDraft() {
|
||||
if (!recovered) return;
|
||||
// Keep the current version as the comparison base, including when the
|
||||
// recovered draft was started from a version that has since been deleted.
|
||||
text = recovered.text; notes = recovered.notes; recovered = null;
|
||||
text = recovered.text;
|
||||
notes = recovered.notes;
|
||||
recovered = null;
|
||||
}
|
||||
async function discard() {
|
||||
if (
|
||||
await confirmAction({
|
||||
title: 'Discard unsaved changes?',
|
||||
message: 'Restore the version you opened in this editor.',
|
||||
confirmLabel: 'Discard changes',
|
||||
danger: true
|
||||
})
|
||||
) {
|
||||
text = original;
|
||||
notes = '';
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) { try { text = await file.text(); } catch { error = 'Could not read that file.'; } }
|
||||
if (file) {
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
error = 'Could not read that file.';
|
||||
}
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
async function save() {
|
||||
if (saving || loading || runningBetaId !== null || !text.trim()) return;
|
||||
saving = true; error = ''; success = '';
|
||||
saving = true;
|
||||
error = '';
|
||||
success = '';
|
||||
const form = new FormData();
|
||||
form.append('content', new Blob([text], { type: 'text/plain' }), 'config.cfg');
|
||||
if (notes.trim()) form.append('notes', notes.trim());
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
const response = await fetch(`/api/scripts/${id}/versions`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not save this version.');
|
||||
original = text; notes = ''; clearDraft();
|
||||
original = text;
|
||||
notes = '';
|
||||
clearDraft();
|
||||
success = `v${body.version.version} saved as untested. Open a beta to test and promote it.`;
|
||||
await onsaved(); selected = String(body.version.id);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { saving = false; }
|
||||
await onsaved();
|
||||
selected = String(body.version.id);
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={beforeUnload} />
|
||||
<section class="workbench" aria-label="Config editor">
|
||||
<div class="editor-panel">
|
||||
<div class="heading"><div><span class="eyebrow">Edit · preview · version</span><h2>Configuration editor</h2></div>
|
||||
<div class="heading">
|
||||
<div>
|
||||
<span class="eyebrow">Edit · preview · version</span>
|
||||
<h2>Configuration editor</h2>
|
||||
</div>
|
||||
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
|
||||
</div>
|
||||
<div class="version-toolbar"><label>Starting version<select aria-label="Starting version" value={selected} onchange={changeVersion} disabled={saving || loading}>{#each versions as version}<option value={String(version.id)}>v{version.version}{version.id === currentVersionId ? ' · promoted' : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option>{/each}</select></label>
|
||||
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
|
||||
<div class="version-toolbar">
|
||||
<label
|
||||
>Starting version<select
|
||||
aria-label="Starting version"
|
||||
value={selected}
|
||||
onchange={changeVersion}
|
||||
disabled={saving || loading}
|
||||
>{#each versions as version}<option value={String(version.id)}
|
||||
>v{version.version}{version.id === currentVersionId
|
||||
? ' · promoted'
|
||||
: ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="file-button"
|
||||
>Import .cfg<input
|
||||
type="file"
|
||||
accept=".cfg,text/plain"
|
||||
onchange={importFile}
|
||||
disabled={saving || loading || runningBetaId !== null}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
|
||||
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
|
||||
{#if recovered && !loading}<div class="recovery">
|
||||
<span>A previous draft is available in this browser.</span><Button
|
||||
size="sm"
|
||||
onclick={restoreDraft}
|
||||
disabled={runningBetaId !== null}>Restore draft</Button
|
||||
><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button>
|
||||
</div>{/if}
|
||||
{#if success}<div class="success" role="status">
|
||||
{success}
|
||||
<a href={'/betas/new?artifact=' + id + ':' + selected}>Test this version →</a>
|
||||
</div>{/if}
|
||||
{#if error}<div class="error" role="alert">
|
||||
{error}
|
||||
{#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}
|
||||
</div>{/if}
|
||||
{#if loading}<p class="muted" role="status">Loading config contents…</p>
|
||||
{:else if loadedVersion}
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||
<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>
|
||||
<ConfigTextEditor
|
||||
bind:text
|
||||
overlay={kind === 'config'}
|
||||
disabled={saving || runningBetaId !== null}
|
||||
/>
|
||||
<label class="notes-label"
|
||||
>Version notes<input
|
||||
bind:value={notes}
|
||||
placeholder="What changed, and why?"
|
||||
disabled={saving || runningBetaId !== null}
|
||||
/></label
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
{#if loadedVersion && !loading}<ConfigInspector artifactId={id} draftText={text} {scopeType} {scopeValue} />{/if}
|
||||
{#if loadedVersion && !loading}<ConfigInspector
|
||||
artifactId={id}
|
||||
draftText={text}
|
||||
{scopeType}
|
||||
{scopeValue}
|
||||
/>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.workbench { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
||||
.editor-panel { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; }
|
||||
.heading, .version-toolbar, .actions, .recovery { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 5px 0 0; }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
.draft-tag { color: var(--state-warning); font-size: var(--text-xs); }
|
||||
.version-toolbar { margin: 20px 0; align-items: flex-end; }
|
||||
.version-toolbar > label:first-child { flex: 1; }
|
||||
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
select, input:not([type='file']) { width: 100%; box-sizing: border-box; padding: 10px 12px; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: var(--text-sm) var(--font-body); }
|
||||
input:focus-visible, select:focus-visible, .file-button:focus-within { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.file-button { position: relative; display: block; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px; overflow: hidden; cursor: pointer; }
|
||||
.file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
|
||||
.notes-label { margin-top: 20px; }
|
||||
.actions { margin-top: 16px; }
|
||||
.actions > span { flex: 1 1 200px; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
|
||||
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
||||
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
||||
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
.workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
}
|
||||
.editor-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
min-width: 0;
|
||||
}
|
||||
.heading,
|
||||
.version-toolbar,
|
||||
.actions,
|
||||
.recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.draft-tag {
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.version-toolbar {
|
||||
margin: 20px 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.version-toolbar > label:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
select,
|
||||
input:not([type='file']) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: var(--text-sm) var(--font-body);
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
.file-button:focus-within {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.file-button {
|
||||
position: relative;
|
||||
display: block;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notes-label {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.actions > span {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.save-warning {
|
||||
display: block;
|
||||
color: var(--state-warning);
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.recovery,
|
||||
.success,
|
||||
.error {
|
||||
font-size: var(--text-sm);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.recovery {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.success {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.editor-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
select {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { saveLibraryContext } from '$ui/library-context.js';
|
||||
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let {
|
||||
artifactId,
|
||||
draftText,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
fixedServer
|
||||
}: {
|
||||
fixedServer?: string;
|
||||
artifactId?: number;
|
||||
draftText?: string;
|
||||
scopeType?: string | null;
|
||||
scopeValue?: string | null;
|
||||
} = $props();
|
||||
let servers = $state<ConfigServer[]>([]);
|
||||
let selected = $state('');
|
||||
let inspection = $state<ConfigInspection | null>(null);
|
||||
@@ -19,143 +31,587 @@
|
||||
let reload = $state(0);
|
||||
let ready = $state(false);
|
||||
let desiredServer = '';
|
||||
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
|
||||
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
|
||||
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
|
||||
const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value])));
|
||||
const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []);
|
||||
const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []);
|
||||
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
|
||||
function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; }
|
||||
const eligible = $derived(
|
||||
servers.filter(
|
||||
(s) =>
|
||||
(!fixedServer || s.name === fixedServer) &&
|
||||
(!artifactId ||
|
||||
(scopeType === 'game'
|
||||
? s.game === scopeValue
|
||||
: scopeType === 'map'
|
||||
? s.map === scopeValue
|
||||
: false))
|
||||
)
|
||||
);
|
||||
const settings = $derived(
|
||||
(inspection?.settings ?? []).filter(
|
||||
(s) =>
|
||||
(!onlyOverrides || s.history.length > 1) &&
|
||||
`${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
);
|
||||
const overlaps = $derived(inspection?.settings.filter((s) => s.history.length > 1).length ?? 0);
|
||||
const previousValues = $derived(
|
||||
new Map((promoted?.settings ?? []).map((s) => [s.key, s.value]))
|
||||
);
|
||||
const changes = $derived(
|
||||
promoted && inspection
|
||||
? inspection.settings.filter((s) => previousValues.get(s.key) !== s.value)
|
||||
: []
|
||||
);
|
||||
const removed = $derived(
|
||||
promoted?.settings.filter(
|
||||
(s) => inspection && !inspection.settings.some((current) => current.key === s.key)
|
||||
) ?? []
|
||||
);
|
||||
function label(source: ConfigSource): string {
|
||||
return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`;
|
||||
}
|
||||
function overriddenCount(source: ConfigSource) {
|
||||
return (
|
||||
inspection?.settings.filter(
|
||||
(s) =>
|
||||
(s.source.id !== source.id || s.source.kind !== source.kind) &&
|
||||
s.history.some(
|
||||
(h) => h.source.id === source.id && h.source.kind === source.kind
|
||||
)
|
||||
).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
loading = true; error = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/configs/inspect');
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load servers.');
|
||||
servers = body.servers;
|
||||
ready = true;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
|
||||
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; });
|
||||
$effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); });
|
||||
onMount(() => {
|
||||
desiredServer = new URLSearchParams(window.location.search).get('server') ?? '';
|
||||
void loadServers();
|
||||
});
|
||||
$effect(() => {
|
||||
if (!eligible.some((s) => s.name === selected))
|
||||
selected =
|
||||
eligible.find((s) => s.name === desiredServer)?.name ?? eligible[0]?.name ?? '';
|
||||
});
|
||||
$effect(() => {
|
||||
if (ready && selected && !fixedServer) saveLibraryContext({ server: selected });
|
||||
});
|
||||
$effect(() => {
|
||||
const server = selected;
|
||||
const text = draftText;
|
||||
const id = artifactId;
|
||||
const refresh = reload;
|
||||
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
|
||||
void reload;
|
||||
if (!ready || !server) {
|
||||
inspection = null;
|
||||
if (ready) loading = false;
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
loading = true; error = '';
|
||||
if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; }
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
|
||||
signal: controller.signal,
|
||||
...(id !== undefined && text !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, artifactId: id, text }) } : {})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
|
||||
if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; }
|
||||
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) loading = false; }
|
||||
}, text === undefined ? 0 : 350);
|
||||
return () => { clearTimeout(timer); controller.abort(); };
|
||||
loading = true;
|
||||
error = '';
|
||||
if (inspectedServer !== server) {
|
||||
inspection = null;
|
||||
promoted = null;
|
||||
expanded = [];
|
||||
inspectedServer = server;
|
||||
}
|
||||
const timer = setTimeout(
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
id !== undefined && text !== undefined
|
||||
? '/api/configs/inspect'
|
||||
: `/api/configs/inspect?server=${encodeURIComponent(server)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(id !== undefined && text !== undefined
|
||||
? {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server, artifactId: id, text })
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
const body = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(body.error ?? 'Could not inspect this config.');
|
||||
if (!controller.signal.aborted) {
|
||||
inspection = body.inspection;
|
||||
promoted = body.promoted ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) error = (err as Error).message;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) loading = false;
|
||||
}
|
||||
},
|
||||
text === undefined ? 0 : 350
|
||||
);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
function download() {
|
||||
if (!inspection) return;
|
||||
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.click();
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
|
||||
<div class="heading"><div>{#if !fixedServer}<span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span>{/if}<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div>
|
||||
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
|
||||
<div class="heading">
|
||||
<div>
|
||||
{#if !fixedServer}<span class="eyebrow"
|
||||
>{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span
|
||||
>{/if}
|
||||
<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>
|
||||
{#if !fixedServer}<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label>{/if}
|
||||
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
|
||||
{#if !fixedServer || loading}<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>{/if}
|
||||
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
||||
{:else if inspection?.reason}<p class="empty">{fixedServer && inspection.reason.startsWith('No promoted baseline') ? `No promoted baseline for ${servers.find(item => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find(item => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.` : inspection.reason}</p>{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
|
||||
<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>
|
||||
{#if !fixedServer}<label class="server-label"
|
||||
>Server<select
|
||||
aria-label="Server"
|
||||
bind:value={selected}
|
||||
disabled={!!fixedServer || !eligible.length}
|
||||
>
|
||||
{#each eligible as server}<option value={server.name}
|
||||
>{serverTitle(server.name)}</option
|
||||
>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label
|
||||
>{/if}
|
||||
{#if error}<div class="error" role="alert">
|
||||
{error}{inspection ? ' Showing the last completed preview.' : ''}
|
||||
</div>{/if}
|
||||
{#if !fixedServer || loading}<p class="preview-status" role="status">
|
||||
{loading
|
||||
? inspection
|
||||
? 'Updating preview… Showing the last completed result.'
|
||||
: 'Composing configuration…'
|
||||
: inspection
|
||||
? 'Preview up to date.'
|
||||
: ''}
|
||||
</p>{/if}
|
||||
{#if !eligible.length && !loading}<p class="empty">
|
||||
{artifactId
|
||||
? 'No registered servers match this scope.'
|
||||
: 'Register a server to inspect its configuration.'}
|
||||
</p>
|
||||
{:else if inspection?.reason}<p class="empty">
|
||||
{fixedServer && inspection.reason.startsWith('No promoted baseline')
|
||||
? `No promoted baseline for ${servers.find((item) => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find((item) => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.`
|
||||
: inspection.reason}
|
||||
</p>
|
||||
{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
|
||||
{:else if inspection}
|
||||
<ol class="sources" aria-label="Composition order, later layers take precedence">
|
||||
{#each inspection.sources as source, index}
|
||||
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
||||
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
|
||||
<li>
|
||||
<span class="layer-number">{index + 1}.</span>{#if source.id}<a
|
||||
class:draft={source.draft}
|
||||
href="/configs/{source.id}">{label(source)}</a
|
||||
>
|
||||
{:else}<span class="map-source">{source.name}</span>{/if}<span
|
||||
>{overriddenCount(source)} settings superseded later</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
|
||||
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
|
||||
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
|
||||
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
|
||||
<p class="hint">
|
||||
Overlays apply in creation order; later overlays win for the same key. The selected map
|
||||
takes precedence over map rotation.
|
||||
</p>
|
||||
{#if promoted}<p class="change-summary">
|
||||
Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.
|
||||
</p>
|
||||
{#if removed.length}<details>
|
||||
<summary>Removed settings</summary>
|
||||
<ul>
|
||||
{#each removed as setting}<li>
|
||||
<code>{setting.key}</code> · previously {setting.value}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}{/if}
|
||||
<div class="toolbar">
|
||||
<div class="switch">
|
||||
<button class:active={!raw} aria-pressed={!raw} onclick={() => (raw = false)}
|
||||
>Settings <span>{inspection.settings.length}</span></button
|
||||
><button class:active={raw} aria-pressed={raw} onclick={() => (raw = true)}
|
||||
>Composed .cfg</button
|
||||
>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onclick={download}>Download</Button>
|
||||
</div>
|
||||
{#if raw}<textarea
|
||||
class="composed"
|
||||
readonly
|
||||
aria-label="Composed config"
|
||||
value={inspection.text}
|
||||
rows="18"
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
|
||||
<div class="filters">
|
||||
<input
|
||||
aria-label="Search effective settings"
|
||||
bind:value={query}
|
||||
placeholder="Search settings or sources…"
|
||||
/><label class="check"
|
||||
><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label
|
||||
>
|
||||
</div>
|
||||
<div class="settings">
|
||||
{#each settings as setting (setting.key)}
|
||||
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
|
||||
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
|
||||
<details
|
||||
class="setting"
|
||||
open={expanded.includes(setting.key)}
|
||||
ontoggle={(event) => {
|
||||
const open = event.currentTarget.open;
|
||||
expanded = open
|
||||
? [...new Set([...expanded, setting.key])]
|
||||
: expanded.filter((key) => key !== setting.key);
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
><div class="setting-main">
|
||||
<code>{setting.key}</code><strong>{setting.value}</strong>
|
||||
</div>
|
||||
<span class="source-label"
|
||||
>{label(setting.source)}
|
||||
{#if setting.history.length > 1}<b
|
||||
>{setting.history.length - 1} overridden</b
|
||||
>{/if}</span
|
||||
>{#if promoted && previousValues.get(setting.key) !== setting.value}<span
|
||||
class="change-summary"
|
||||
>{previousValues.has(setting.key)
|
||||
? `Changed from ${previousValues.get(setting.key)}`
|
||||
: 'Added by draft'}</span
|
||||
>{/if}</summary
|
||||
>
|
||||
<ol>
|
||||
{#each setting.history as entry, index}<li
|
||||
class:winner={index === setting.history.length - 1}
|
||||
>
|
||||
<div>
|
||||
<span>{label(entry.source)}</span><small
|
||||
>{entry.source.kind === 'map'
|
||||
? 'Automatic'
|
||||
: `Line ${entry.line}`} · {index ===
|
||||
setting.history.length - 1
|
||||
? 'Final value'
|
||||
: 'Overridden'}</small
|
||||
>
|
||||
</div>
|
||||
<code>{entry.value}</code>
|
||||
</li>{/each}
|
||||
</ol>
|
||||
</details>
|
||||
{:else}<p class="empty">No matching settings.</p>{/each}
|
||||
</div>
|
||||
<p class="hint">Shows set / seta assignments and map rotation. Other commands remain visible in the composed file.</p>
|
||||
<p class="hint">
|
||||
Shows set / seta assignments and map rotation. Other commands remain visible in the
|
||||
composed file.
|
||||
</p>
|
||||
{/if}
|
||||
{#if inspection.warnings.length}<details class="notes"><summary>{inspection.warnings.length} configuration {inspection.warnings.length === 1 ? 'note' : 'notes'}</summary><ul>{#each inspection.warnings as warning}<li>{warning}</li>{/each}</ul></details>{/if}
|
||||
{#if inspection.warnings.length}<details class="notes">
|
||||
<summary
|
||||
>{inspection.warnings.length} configuration {inspection.warnings.length === 1
|
||||
? 'note'
|
||||
: 'notes'}</summary
|
||||
>
|
||||
<ul>
|
||||
{#each inspection.warnings as warning}<li>{warning}</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.inspector { min-width: 0; padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.heading, .toolbar, .filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
h2 { margin: 5px 0 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
.intro { color: var(--text-secondary); font-size: var(--text-sm); margin: 12px 0 20px; }
|
||||
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
|
||||
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; }
|
||||
.sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; }
|
||||
.layer-number { color: var(--text-primary); font-weight: 600; }
|
||||
.preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; }
|
||||
.change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; }
|
||||
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
|
||||
.sources a.draft { color: var(--state-warning); }
|
||||
.map-source { color: var(--text-secondary); }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
|
||||
.switch { display: flex; gap: 4px; }
|
||||
button { border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); padding: 8px 10px; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
button span { color: var(--text-secondary); margin-left: 6px; }
|
||||
.filters { margin: 16px 0 8px; }
|
||||
.filters > input { flex: 1 1 180px; }
|
||||
.check { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||||
.settings { max-height: 620px; overflow: auto; }
|
||||
.setting { border-bottom: 1px solid var(--border-subtle); padding: 14px 0; }
|
||||
.setting summary { cursor: pointer; list-style: none; }
|
||||
.setting summary::-webkit-details-marker { display: none; }
|
||||
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
strong { color: var(--text-primary); }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
|
||||
b { font-weight: 400; color: var(--state-warning); }
|
||||
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
|
||||
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
ol li.winner { color: var(--text-primary); }
|
||||
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
|
||||
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; }
|
||||
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
|
||||
@media (max-width: 600px) { .inspector { padding: 16px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||
.inspector {
|
||||
min-width: 0;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
.heading,
|
||||
.toolbar,
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
h2 {
|
||||
margin: 5px 0 0;
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.intro {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
.server-label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
select,
|
||||
input:not([type='checkbox']) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font: var(--text-sm) var(--font-body);
|
||||
}
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
button:focus-visible,
|
||||
summary:focus-visible,
|
||||
a:focus-visible,
|
||||
.composed:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sources {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 20px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: var(--text-xs);
|
||||
list-style: none;
|
||||
}
|
||||
.sources li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.layer-number {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-status {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
min-height: 20px;
|
||||
}
|
||||
.change-summary {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--state-warning);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.sources a,
|
||||
.map-source {
|
||||
background: var(--surface-raised);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sources a.draft {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.map-source {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.toolbar {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: var(--text-sm) var(--font-body);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
button span {
|
||||
color: var(--text-secondary);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.filters {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.filters > input {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
.check {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.settings {
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
}
|
||||
.setting {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 14px 0;
|
||||
}
|
||||
.setting summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.setting summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.setting-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
code,
|
||||
strong {
|
||||
font: var(--text-xs)/1.6 var(--font-mono);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.source-label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.source-label::before {
|
||||
content: '▸';
|
||||
}
|
||||
.setting[open] .source-label::before {
|
||||
content: '▾';
|
||||
}
|
||||
b {
|
||||
font-weight: 400;
|
||||
color: var(--state-warning);
|
||||
}
|
||||
ol {
|
||||
list-style: none;
|
||||
padding: 0 0 0 14px;
|
||||
margin: 16px 0 0 4px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
}
|
||||
ol li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
ol li.winner {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.composed {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 260px;
|
||||
max-height: 600px;
|
||||
resize: vertical;
|
||||
overflow: auto;
|
||||
font: var(--text-xs)/1.8 var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notes {
|
||||
margin-top: 16px;
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.notes summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.notes li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.inspector {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,14 +16,30 @@
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
|
||||
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||
interface Version {
|
||||
id: number;
|
||||
version: number;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
uploaded_at: string;
|
||||
}
|
||||
interface Artifact {
|
||||
id: number; name: string; kind: 'baseline' | 'config'; scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null; current_version_id: number | null; currentVersion: Version | null;
|
||||
latestVersion: Version | null; versionCount: number; servers: string[]; runningBetaId: number | null;
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'baseline' | 'config';
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
currentVersion: Version | null;
|
||||
latestVersion: Version | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
let artifacts = $state<Artifact[]>([]);
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
|
||||
games: [],
|
||||
mapsByGame: {}
|
||||
});
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
@@ -33,10 +49,23 @@
|
||||
let view = $state('library');
|
||||
let limit = $state(25);
|
||||
let mounted = $state(false);
|
||||
afterNavigate(() => { mounted = true; });
|
||||
afterNavigate(() => {
|
||||
mounted = true;
|
||||
});
|
||||
let lastFilters = '';
|
||||
$effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||
$effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); });
|
||||
$effect(() => {
|
||||
const key = JSON.stringify([query, game, scope, status]);
|
||||
if (!mounted) return;
|
||||
if (lastFilters && lastFilters !== key) limit = 25;
|
||||
lastFilters = key;
|
||||
});
|
||||
$effect(() => {
|
||||
if (mounted)
|
||||
saveLibraryContext(
|
||||
{ q: query, game, scope, status, view, limit },
|
||||
{ view: 'library', limit: 25 }
|
||||
);
|
||||
});
|
||||
let dialog: HTMLDialogElement;
|
||||
let kind = $state<'baseline' | 'config'>('config');
|
||||
let name = $state('');
|
||||
@@ -47,161 +76,585 @@
|
||||
let notes = $state('');
|
||||
let saving = $state(false);
|
||||
let createError = $state('');
|
||||
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
|
||||
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);
|
||||
const filtered = $derived(artifacts.filter(a => {
|
||||
if (query && !`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`.toLowerCase().includes(query.toLowerCase())) return false;
|
||||
if (game && !(a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))) return false;
|
||||
if (scope && a.scope_type !== scope) return false;
|
||||
if (status === 'pending' && (!a.latestVersion || a.latestVersion.id === a.current_version_id)) return false;
|
||||
if (status === 'promoted' && !a.current_version_id) return false;
|
||||
if (status === 'beta' && !a.runningBetaId) return false;
|
||||
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
|
||||
return true;
|
||||
}));
|
||||
const filteredBaselines = $derived(filtered.filter(a => a.kind === 'baseline').sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? '')));
|
||||
const filteredOverlays = $derived(filtered.filter(a => a.kind === 'config'));
|
||||
const baselines = $derived(artifacts.filter((a) => a.kind === 'baseline'));
|
||||
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
|
||||
);
|
||||
const filtered = $derived(
|
||||
artifacts.filter((a) => {
|
||||
if (
|
||||
query &&
|
||||
!`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
game &&
|
||||
!(a.scope_type === 'game'
|
||||
? a.scope_value === game
|
||||
: (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))
|
||||
)
|
||||
return false;
|
||||
if (scope && a.scope_type !== scope) return false;
|
||||
if (
|
||||
status === 'pending' &&
|
||||
(!a.latestVersion || a.latestVersion.id === a.current_version_id)
|
||||
)
|
||||
return false;
|
||||
if (status === 'promoted' && !a.current_version_id) return false;
|
||||
if (status === 'beta' && !a.runningBetaId) return false;
|
||||
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
const filteredBaselines = $derived(
|
||||
filtered
|
||||
.filter((a) => a.kind === 'baseline')
|
||||
.sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? ''))
|
||||
);
|
||||
const filteredOverlays = $derived(filtered.filter((a) => a.kind === 'config'));
|
||||
|
||||
async function load() {
|
||||
loading = true; error = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const responses = await Promise.all([fetch('/api/configs?kind=baseline'), fetch('/api/configs?kind=config'), fetch('/api/scope-options')]);
|
||||
if (responses.some(r => !r.ok)) throw new Error('Could not load the config library. Please try again.');
|
||||
const [base, overlay, scopes] = await Promise.all(responses.map(r => r.json()));
|
||||
artifacts = [...base.scripts, ...overlay.scripts]; scopeOptions = scopes;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
const responses = await Promise.all([
|
||||
fetch('/api/configs?kind=baseline'),
|
||||
fetch('/api/configs?kind=config'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (responses.some((r) => !r.ok))
|
||||
throw new Error('Could not load the config library. Please try again.');
|
||||
const [base, overlay, scopes] = await Promise.all(responses.map((r) => r.json()));
|
||||
artifacts = [...base.scripts, ...overlay.scripts];
|
||||
scopeOptions = scopes;
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
query = params.get('q') ?? '';
|
||||
game = params.get('game') ?? '';
|
||||
scope = params.get('scope') ?? '';
|
||||
status = params.get('status') ?? '';
|
||||
view = params.get('view') === 'inspect' ? 'inspect' : 'library';
|
||||
limit = libraryLimit(params);
|
||||
void load();
|
||||
});
|
||||
function openCreate(type: 'config' | 'baseline' = 'config') {
|
||||
kind = type;
|
||||
createError = '';
|
||||
dialog.showModal();
|
||||
}
|
||||
function resetFilters() {
|
||||
query = '';
|
||||
game = '';
|
||||
scope = '';
|
||||
status = '';
|
||||
}
|
||||
onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); });
|
||||
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
|
||||
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
try { text = await file.text(); if (!name) name = file.name.replace(/\.[^.]+$/, ''); }
|
||||
catch { createError = 'Could not read that file.'; }
|
||||
try {
|
||||
text = await file.text();
|
||||
if (!name) name = file.name.replace(/\.[^.]+$/, '');
|
||||
} catch {
|
||||
createError = 'Could not read that file.';
|
||||
}
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
async function create(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
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.'; return; }
|
||||
saving = true; createError = '';
|
||||
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) {
|
||||
createError = 'Add at least one set KEY VALUE line.';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
createError = '';
|
||||
const form = new FormData();
|
||||
form.append('name', name.trim()); form.append('kind', kind);
|
||||
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType); form.append('scopeValue', selectedScope);
|
||||
form.append('text', text); if (notes.trim()) form.append('notes', notes.trim());
|
||||
form.append('name', name.trim());
|
||||
form.append('kind', kind);
|
||||
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType);
|
||||
form.append('scopeValue', selectedScope);
|
||||
form.append('text', text);
|
||||
if (notes.trim()) form.append('notes', notes.trim());
|
||||
try {
|
||||
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(); await goto(`/configs/${body.script.id}`);
|
||||
} catch (err) { createError = (err as Error).message; }
|
||||
finally { saving = false; }
|
||||
dialog.close();
|
||||
await goto(`/configs/${body.script.id}`);
|
||||
} catch (err) {
|
||||
createError = (err as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
|
||||
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/if}{/snippet}
|
||||
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button
|
||||
variant="primary"
|
||||
onclick={() => openCreate()}>New config</Button
|
||||
>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<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="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}>{gameTitle(option)}</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>
|
||||
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
</div>{/if}
|
||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||
{:else if !artifacts.length && !error}
|
||||
<EmptyState 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}
|
||||
</EmptyState>
|
||||
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters.">
|
||||
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
|
||||
</EmptyState>
|
||||
<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}>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 filteredBaselines.length}
|
||||
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
|
||||
{/if}
|
||||
{#if filteredOverlays.length}
|
||||
<section class="section"><h2>Overlays <span>{filteredOverlays.length}</span></h2>{@render configTable(filteredOverlays.slice(0,limit))}</section>
|
||||
{#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}>{gameTitle(option)}</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
|
||||
>
|
||||
{#if filtered.length && (query || game || scope || status)}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={resetFilters}>Clear</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||
{:else if !artifacts.length && !error}
|
||||
<EmptyState
|
||||
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}
|
||||
</EmptyState>
|
||||
{:else if !filtered.length && !error}<EmptyState
|
||||
title="No matching configs"
|
||||
description="Try a different search or clear your filters."
|
||||
>
|
||||
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button
|
||||
>{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#if filteredBaselines.length}
|
||||
<section class="section">
|
||||
<h2>Baselines <span>{filteredBaselines.length}</span></h2>
|
||||
{@render configTable(filteredBaselines)}
|
||||
</section>
|
||||
{/if}
|
||||
{#if filteredOverlays.length}
|
||||
<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}
|
||||
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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 ${gameTitle(artifact.scope_value)} 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>
|
||||
<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 ${gameTitle(artifact.scope_value)} 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(); }}>
|
||||
<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>
|
||||
<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"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><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></label>
|
||||
{#if kind === 'config'}<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></label>{#if scopeType === 'map'}<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></label>{/if}{/if}
|
||||
<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>
|
||||
{#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 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">
|
||||
<label
|
||||
>Name<input
|
||||
bind:value={name}
|
||||
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
|
||||
required
|
||||
disabled={saving}
|
||||
/></label
|
||||
><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
|
||||
></label
|
||||
>
|
||||
{#if kind === 'config'}<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
|
||||
></label
|
||||
>{#if scopeType === 'map'}<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
|
||||
></label
|
||||
>{/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} />
|
||||
<label class="notes">Notes<input bind:value={notes} placeholder="What is this config for?" disabled={saving} /></label>
|
||||
<label class="notes"
|
||||
>Notes<input
|
||||
bind:value={notes}
|
||||
placeholder="What is this config for?"
|
||||
disabled={saving}
|
||||
/></label
|
||||
>
|
||||
{#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>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.navigation { display:flex; align-items:center; gap:12px; justify-content:space-between; }
|
||||
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
|
||||
.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; }
|
||||
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; }
|
||||
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
||||
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
|
||||
.warning { color: var(--state-warning); font-size: var(--text-sm); }
|
||||
dialog { box-sizing: border-box; width: min(860px, calc(100vw - 32px)); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.dialog-heading, .dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .dialog-heading h2 { margin-top: 6px; font-size: var(--text-xl); }
|
||||
.kind-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.kind-picker button { padding: 16px; border: 1px solid var(--border-strong); text-align: left; }
|
||||
.kind-picker button.active { border-color: var(--accent-primary); } .kind-picker span { display: block; margin: 6px 0 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
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:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
|
||||
.config-panel { display:grid; gap:20px; }
|
||||
.loading { padding:24px 0; }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
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: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
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;
|
||||
}
|
||||
.loading {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent-primary-hover);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
padding: 12px;
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.warning {
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
form {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.dialog-heading,
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.dialog-heading h2 {
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.kind-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.kind-picker button {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
text-align: left;
|
||||
}
|
||||
.kind-picker button.active {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.kind-picker span {
|
||||
display: block;
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
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: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
form {
|
||||
padding: 18px;
|
||||
}
|
||||
.navigation {
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
.config-panel {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.loading {
|
||||
padding: 24px 0;
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
select {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { addConfigSetting, changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
||||
import {
|
||||
addConfigSetting,
|
||||
changeConfigValue,
|
||||
configLines,
|
||||
configWarnings
|
||||
} from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
||||
let {
|
||||
text = $bindable(''),
|
||||
overlay = false,
|
||||
disabled = false
|
||||
}: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
||||
let mode = $state<'raw' | 'settings'>('raw');
|
||||
let search = $state('');
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let addError = $state('');
|
||||
function addSetting() {
|
||||
try { text = addConfigSetting(text, newKey, newValue); search = ''; newKey = ''; newValue = ''; addError = ''; }
|
||||
catch (error) { addError = (error as Error).message; }
|
||||
try {
|
||||
text = addConfigSetting(text, newKey, newValue);
|
||||
search = '';
|
||||
newKey = '';
|
||||
newValue = '';
|
||||
addError = '';
|
||||
} catch (error) {
|
||||
addError = (error as Error).message;
|
||||
}
|
||||
}
|
||||
function addOnEnter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!disabled && newKey.trim() && newValue.trim()) addSetting();
|
||||
}
|
||||
}
|
||||
function addOnEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); if (!disabled && newKey.trim() && newValue.trim()) addSetting(); } }
|
||||
const entries = $derived(configLines(text));
|
||||
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
|
||||
const visible = $derived(
|
||||
entries.filter((e) => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
const warnings = $derived(configWarnings(text, overlay));
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="toolbar">
|
||||
<div class="switch" aria-label="Editor view">
|
||||
<button type="button" class:active={mode === 'raw'} aria-pressed={mode === 'raw'} onclick={() => mode = 'raw'}>Raw .cfg</button>
|
||||
<button type="button" class:active={mode === 'settings'} aria-pressed={mode === 'settings'} onclick={() => mode = 'settings'}>Settings <span>{entries.length}</span></button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === 'raw'}
|
||||
aria-pressed={mode === 'raw'}
|
||||
onclick={() => (mode = 'raw')}>Raw .cfg</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === 'settings'}
|
||||
aria-pressed={mode === 'settings'}
|
||||
onclick={() => (mode = 'settings')}>Settings <span>{entries.length}</span></button
|
||||
>
|
||||
</div>
|
||||
<span class="hint">{text.split(/\r?\n/).length} lines</span>
|
||||
</div>
|
||||
{#if mode === 'raw'}
|
||||
<textarea aria-label="Config contents" bind:value={text} {disabled} spellcheck="false" rows="12" placeholder={'// Add your settings\nset g_inactivity "0"'}></textarea>
|
||||
<textarea
|
||||
aria-label="Config contents"
|
||||
bind:value={text}
|
||||
{disabled}
|
||||
spellcheck="false"
|
||||
rows="12"
|
||||
placeholder={'// Add your settings\nset g_inactivity "0"'}
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="settings">
|
||||
<input class="search" aria-label="Find a setting" bind:value={search} placeholder="Find a setting…" />
|
||||
<p class="hint">Edit values and inline comments. Other commands and formatting stay in the raw file.</p>
|
||||
<input
|
||||
class="search"
|
||||
aria-label="Find a setting"
|
||||
bind:value={search}
|
||||
placeholder="Find a setting…"
|
||||
/>
|
||||
<p class="hint">
|
||||
Edit values and inline comments. Other commands and formatting stay in the raw file.
|
||||
</p>
|
||||
{#each visible as entry (entry.index)}
|
||||
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
||||
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
|
||||
<label class="setting"
|
||||
><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
||||
<input
|
||||
value={entry.value}
|
||||
{disabled}
|
||||
aria-label={`${entry.key}, line ${entry.index + 1}`}
|
||||
oninput={(e) =>
|
||||
(text = changeConfigValue(text, entry.index, e.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add your first setting below.'}</p>{/each}
|
||||
<div class="add-setting"><label>Setting name<input bind:value={newKey} onkeydown={addOnEnter} placeholder="g_inactivity" {disabled} /></label><label>Value<input bind:value={newValue} onkeydown={addOnEnter} placeholder="0" {disabled} /></label><Button size="sm" onclick={addSetting} disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button></div>
|
||||
{:else}<p class="hint">
|
||||
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
|
||||
</p>{/each}
|
||||
<div class="add-setting">
|
||||
<label
|
||||
>Setting name<input
|
||||
bind:value={newKey}
|
||||
onkeydown={addOnEnter}
|
||||
placeholder="g_inactivity"
|
||||
{disabled}
|
||||
/></label
|
||||
><label
|
||||
>Value<input
|
||||
bind:value={newValue}
|
||||
onkeydown={addOnEnter}
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
/></label
|
||||
><Button
|
||||
size="sm"
|
||||
onclick={addSetting}
|
||||
disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button
|
||||
>
|
||||
</div>
|
||||
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if warnings.length}
|
||||
<details class="warnings" open={warnings.some(w => w.includes('ignored') || w.includes('no value'))}><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before saving</summary>
|
||||
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
|
||||
<details
|
||||
class="warnings"
|
||||
open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))}
|
||||
>
|
||||
<summary
|
||||
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before
|
||||
saving</summary
|
||||
>
|
||||
<ul>
|
||||
{#each warnings as warning}<li>{warning}</li>{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; background: var(--surface-sunken); }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; gap: 12px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.switch { display: flex; gap: 4px; }
|
||||
button { font: inherit; font-size: var(--text-sm); color: var(--text-secondary); border: 0; border-radius: var(--radius-sm); padding: 8px 12px; background: transparent; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
button span { margin-left: 8px; color: var(--text-secondary); }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||
textarea { display: block; width: 100%; height: 360px; min-height: 240px; box-sizing: border-box; resize: vertical; border: 0; padding: var(--space-5); background: transparent; color: var(--text-primary); font: var(--text-sm)/1.8 var(--font-mono); tab-size: 4; }
|
||||
textarea:disabled, input:disabled { opacity: .65; }
|
||||
.settings { padding: var(--space-4); max-height: 520px; overflow: auto; }
|
||||
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
|
||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.add-setting { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-strong); padding-top: 16px; }
|
||||
.add-setting label { flex: 1 1 140px; display: grid; gap: 6px; font-size: var(--text-sm); }
|
||||
code { font: var(--text-sm) var(--font-mono); }
|
||||
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
|
||||
summary { cursor: pointer; }
|
||||
li { margin: 6px 0; }
|
||||
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
|
||||
input:not([type='checkbox']):not([type='file']), textarea { border-color: var(--border-control); }
|
||||
.editor {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
button span {
|
||||
margin-left: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
min-height: 240px;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
padding: var(--space-5);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: var(--text-sm)/1.8 var(--font-mono);
|
||||
tab-size: 4;
|
||||
}
|
||||
textarea:disabled,
|
||||
input:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.settings {
|
||||
padding: var(--space-4);
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
font: var(--text-sm) var(--font-mono);
|
||||
width: 100%;
|
||||
}
|
||||
.setting {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.setting span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.add-setting {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.add-setting label {
|
||||
flex: 1 1 140px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
code {
|
||||
font: var(--text-sm) var(--font-mono);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.warnings {
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.setting {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar > .hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
textarea {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,31 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={() => $confirmation !== null, (open) => { if (!open) resolveConfirmation(false); }} title={$confirmation?.title ?? 'Confirm action'}>
|
||||
<Dialog
|
||||
bind:open={
|
||||
() => $confirmation !== null,
|
||||
(open) => {
|
||||
if (!open) resolveConfirmation(false);
|
||||
}
|
||||
}
|
||||
title={$confirmation?.title ?? 'Confirm action'}
|
||||
>
|
||||
<p>{$confirmation?.message}</p>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={() => resolveConfirmation(false)}>Cancel</Button>
|
||||
<Button variant={$confirmation?.danger ? 'danger' : 'primary'} onclick={() => resolveConfirmation(true)}>{$confirmation?.confirmLabel ?? 'Continue'}</Button>
|
||||
<Button
|
||||
variant={$confirmation?.danger ? 'danger' : 'primary'}
|
||||
onclick={() => resolveConfirmation(true)}
|
||||
>{$confirmation?.confirmLabel ?? 'Continue'}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
<style>p { margin: 0; white-space: pre-line; color: var(--text-secondary); line-height: var(--leading-body); }</style>
|
||||
|
||||
<style>
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,41 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { value, label = 'ID' }: { value: string; label?: string } = $props();
|
||||
let copyStatus = $state('');
|
||||
$effect(() => { value; copyStatus = ''; });
|
||||
$effect(() => {
|
||||
void value;
|
||||
copyStatus = '';
|
||||
});
|
||||
async function copy() {
|
||||
copyStatus = '';
|
||||
try { await navigator.clipboard.writeText(value); copyStatus = `${label} copied.`; }
|
||||
catch { copyStatus = 'Copy unavailable. Select and copy the text directly.'; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copyStatus = `${label} copied.`;
|
||||
} catch {
|
||||
copyStatus = 'Copy unavailable. Select and copy the text directly.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button>{#if copyStatus}<span class="copy-status" role="status">{copyStatus}</span>{/if}</div>
|
||||
<style>.copy-status { font-size: var(--text-xs); } .copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
|
||||
|
||||
<div class="copy">
|
||||
<code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}
|
||||
>Copy</Button
|
||||
>{#if copyStatus}<span class="copy-status" role="status">{copyStatus}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.copy-status {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
code {
|
||||
font: var(--text-xs) var(--font-mono);
|
||||
user-select: all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,20 +3,35 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
|
||||
let { open = $bindable(false), host, ondeleted }:
|
||||
{ open?: boolean; host: Pick<Host, 'id' | 'name'> | null; ondeleted: (result: { deleted: boolean; operationId?: string }) => void | Promise<void> } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
host,
|
||||
ondeleted
|
||||
}: {
|
||||
open?: boolean;
|
||||
host: Pick<Host, 'id' | 'name'> | null;
|
||||
ondeleted: (result: { deleted: boolean; operationId?: string }) => void | Promise<void>;
|
||||
} = $props();
|
||||
let deletion = $state<HostDeletion | null>(null);
|
||||
let checking = $state(false); let deleting = $state(false); let error = $state('');
|
||||
let checking = $state(false);
|
||||
let deleting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function inspect(id: string, signal?: AbortSignal) {
|
||||
checking = true; deletion = null; error = '';
|
||||
checking = true;
|
||||
deletion = null;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not check this host.');
|
||||
if (!response.ok)
|
||||
throw new Error(body.error ?? body.message ?? 'Could not check this host.');
|
||||
if (!signal?.aborted) deletion = body.deletion;
|
||||
} catch (cause) { if (!signal?.aborted) error = (cause as Error).message; }
|
||||
finally { if (!signal?.aborted) checking = false; }
|
||||
} catch (cause) {
|
||||
if (!signal?.aborted) error = (cause as Error).message;
|
||||
} finally {
|
||||
if (!signal?.aborted) checking = false;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (!open || !host) return;
|
||||
@@ -27,9 +42,12 @@
|
||||
async function remove() {
|
||||
if (!host || deleting || checking || !deletion?.allowed) return;
|
||||
const id = host.id;
|
||||
deleting = true; error = '';
|
||||
deleting = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
await inspect(id);
|
||||
@@ -37,30 +55,95 @@
|
||||
}
|
||||
open = false;
|
||||
await ondeleted(body);
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
finally { deleting = false; }
|
||||
} catch (cause) {
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title={`Delete ${host?.name ?? 'host'}?`} busy={deleting}
|
||||
description="Permanently remove Electric Cherry's files from this host, including its downloaded game files, then uninstall the agent.">
|
||||
<Dialog
|
||||
bind:open
|
||||
title={`Delete ${host?.name ?? 'host'}?`}
|
||||
busy={deleting}
|
||||
description="Permanently remove Electric Cherry's files from this host, including its downloaded game files, then uninstall the agent."
|
||||
>
|
||||
<div class="content">
|
||||
<p>This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.</p>
|
||||
<p>The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.</p>
|
||||
<p>
|
||||
This deletes the host's game-library copies, server files, agent data, credentials, and
|
||||
service installation. Electric Cherry's uploaded libraries on the controller and its
|
||||
operation history are kept.
|
||||
</p>
|
||||
<p>
|
||||
The host remains visible until cleanup is confirmed. Offline hosts wait for
|
||||
reconnection. Unsent commands will be cancelled.
|
||||
</p>
|
||||
{#if checking}<p role="status">Checking servers and unfinished operations…</p>
|
||||
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Review <a href="/hosts/operations" onclick={() => open = false}>operation history</a> or open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to drain its servers.</p></div>
|
||||
{:else if deletion?.allowed}<p>Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.</p>{/if}
|
||||
{:else if deletion && !deletion.allowed}<div role="status" class="blocked">
|
||||
<strong>This host still has work to finish:</strong>
|
||||
<ul>
|
||||
{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}
|
||||
</ul>
|
||||
<p>
|
||||
Review <a href="/hosts/operations" onclick={() => (open = false)}
|
||||
>operation history</a
|
||||
>
|
||||
or open
|
||||
<a href={`/hosts/${host?.id}`} onclick={() => (open = false)}>host details</a> to
|
||||
drain its servers.
|
||||
</p>
|
||||
</div>
|
||||
{:else if deletion?.allowed}<p>
|
||||
Cleanup cannot be undone once it starts. Adding this machine again requires
|
||||
installing and enrolling the agent again.
|
||||
</p>{/if}
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" disabled={deleting} onclick={() => open = false}>Cancel</Button>
|
||||
{#if !deletion?.allowed}<Button disabled={checking || deleting || !host} onclick={() => { if (host) void inspect(host.id); }}>Check again</Button>{/if}
|
||||
<Button variant="danger" loading={deleting} disabled={checking || !deletion?.allowed} onclick={remove}>Delete host</Button>
|
||||
<Button variant="ghost" disabled={deleting} onclick={() => (open = false)}>Cancel</Button>
|
||||
{#if !deletion?.allowed}<Button
|
||||
disabled={checking || deleting || !host}
|
||||
onclick={() => {
|
||||
if (host) void inspect(host.id);
|
||||
}}>Check again</Button
|
||||
>{/if}
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={deleting}
|
||||
disabled={checking || !deletion?.allowed}
|
||||
onclick={remove}>Delete host</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.content { display:grid; gap:16px; } p,li { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.65; }
|
||||
.blocked { display:grid; gap:12px; } strong { font-size:var(--text-sm); } ul { margin:0; padding-left:20px; }
|
||||
a { color:var(--accent-primary-hover); } .ui-error { color:var(--state-danger); }
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
p,
|
||||
li {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.blocked {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.ui-error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,35 @@
|
||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
|
||||
{ open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
sourceDeploymentId = '',
|
||||
initialGame = 't5',
|
||||
initialMapSlug = ''
|
||||
}: {
|
||||
open?: boolean;
|
||||
sourceDeploymentId?: string;
|
||||
initialGame?: ZombieGame;
|
||||
initialMapSlug?: string;
|
||||
} = $props();
|
||||
let game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('');
|
||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||
let gameSelect = $state<HTMLSelectElement>();
|
||||
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||
let hostReady = $state(false);
|
||||
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
|
||||
let hostStatus = $state<NativeHostSelectionStatus>({
|
||||
loading: true,
|
||||
error: '',
|
||||
hasHosts: false,
|
||||
filesReady: false,
|
||||
missingGames: [],
|
||||
uploadedAt: null
|
||||
});
|
||||
let view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
|
||||
const controller = createServerLaunchController(state => { view = state; });
|
||||
const controller = createServerLaunchController((state) => {
|
||||
view = state;
|
||||
});
|
||||
let loaded = $state(false);
|
||||
let loadingError = $state('');
|
||||
let launchError = $state('');
|
||||
@@ -37,39 +55,89 @@
|
||||
const preview = $derived(view.preview);
|
||||
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
|
||||
const checksBlocked = $derived(preview?.checks.filter(check => check.state === 'blocked') ?? []);
|
||||
const checksBlocked = $derived(
|
||||
preview?.checks.filter((check) => check.state === 'blocked') ?? []
|
||||
);
|
||||
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
|
||||
const canLaunch = $derived(!launching && !!preview?.name && (unconfirmedLaunch ||
|
||||
(hostReady && !hostStatus.loading && !view.checking && !expired && preview.ready && preview.hasSavedKey)));
|
||||
const checking = $derived(!loaded && !loadingError || hostStatus.loading || view.checking);
|
||||
const footerStatus = $derived(launching ? 'Queueing server…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
|
||||
: loadingError ? 'Could not load launch options.' : checking ? 'Checking availability…'
|
||||
: missingKey ? 'No game key available.' : canLaunch ? 'Ready to launch' : 'Launch unavailable');
|
||||
const canLaunch = $derived(
|
||||
!launching &&
|
||||
!!preview?.name &&
|
||||
(unconfirmedLaunch ||
|
||||
(hostReady &&
|
||||
!hostStatus.loading &&
|
||||
!view.checking &&
|
||||
!expired &&
|
||||
preview.ready &&
|
||||
preview.hasSavedKey))
|
||||
);
|
||||
const checking = $derived((!loaded && !loadingError) || hostStatus.loading || view.checking);
|
||||
const footerStatus = $derived(
|
||||
launching
|
||||
? 'Queueing server…'
|
||||
: unconfirmedLaunch
|
||||
? 'Retry to confirm this launch.'
|
||||
: loadingError
|
||||
? 'Could not load launch options.'
|
||||
: checking
|
||||
? 'Checking availability…'
|
||||
: missingKey
|
||||
? 'No game key available.'
|
||||
: canLaunch
|
||||
? 'Ready to launch'
|
||||
: 'Launch unavailable'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) { wasOpen = true; void initialize(); }
|
||||
else if (!open && wasOpen) {
|
||||
wasOpen = false; visit++; initialization?.abort(); controller.clear();
|
||||
if (open && !wasOpen) {
|
||||
wasOpen = true;
|
||||
void initialize();
|
||||
} else if (!open && wasOpen) {
|
||||
wasOpen = false;
|
||||
visit++;
|
||||
initialization?.abort();
|
||||
controller.clear();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
// Selection changes invalidate the old preview before another launch can use it.
|
||||
const input = open && loaded && hostReady && !hostStatus.loading ? {
|
||||
game, mapSlug, libraryId: selection.libraryIds[game]!, image: selection.image,
|
||||
bindAddress: selection.bindAddress || undefined, publicAddress: selection.publicAddress || undefined,
|
||||
publicPort: selection.publicPort
|
||||
} : null;
|
||||
const input =
|
||||
open && loaded && hostReady && !hostStatus.loading
|
||||
? {
|
||||
game,
|
||||
mapSlug,
|
||||
libraryId: selection.libraryIds[game]!,
|
||||
image: selection.image,
|
||||
bindAddress: selection.bindAddress || undefined,
|
||||
publicAddress: selection.publicAddress || undefined,
|
||||
publicPort: selection.publicPort
|
||||
}
|
||||
: null;
|
||||
if (!launching && !unconfirmedLaunch) controller.select(input);
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
const current = ++visit;
|
||||
initialization?.abort(); initialization = new AbortController();
|
||||
initialization?.abort();
|
||||
initialization = new AbortController();
|
||||
const signal = initialization.signal;
|
||||
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
|
||||
game = initialGame; mapSlug = initialMapSlug;
|
||||
selection = { libraryIds: {}, image: '' }; hostReady = false;
|
||||
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
|
||||
controller.clear();
|
||||
loaded = false;
|
||||
loadingError = '';
|
||||
launchError = '';
|
||||
launching = false;
|
||||
unconfirmedLaunch = false;
|
||||
game = initialGame;
|
||||
mapSlug = initialMapSlug;
|
||||
selection = { libraryIds: {}, image: '' };
|
||||
hostReady = false;
|
||||
hostStatus = {
|
||||
loading: true,
|
||||
error: '',
|
||||
hasHosts: false,
|
||||
filesReady: false,
|
||||
missingGames: [],
|
||||
uploadedAt: null
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/maps', { signal });
|
||||
if (!response.ok) throw new Error('Could not load maps. Try again.');
|
||||
@@ -77,75 +145,117 @@
|
||||
if (current !== visit) return;
|
||||
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||
if (sourceDeploymentId) {
|
||||
const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
|
||||
const sourceResponse = await fetch(
|
||||
`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`,
|
||||
{ signal }
|
||||
);
|
||||
const source = await sourceResponse.json();
|
||||
if (current !== visit) return;
|
||||
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|
||||
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
|
||||
if (
|
||||
!sourceResponse.ok ||
|
||||
!source.recipe ||
|
||||
!['t5', 't6'].includes(source.recipe.game) ||
|
||||
!catalogs[source.recipe.game as ZombieGame].some(
|
||||
(map) => map.slug === source.recipe.mapSlug
|
||||
)
|
||||
) {
|
||||
throw new Error('The original launch map is unavailable.');
|
||||
}
|
||||
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
||||
game = source.recipe.game;
|
||||
mapSlug = source.recipe.mapSlug;
|
||||
}
|
||||
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
if (!catalogs[game].some((map) => map.slug === mapSlug))
|
||||
mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
loaded = true;
|
||||
await tick();
|
||||
if (current === visit && open) gameSelect?.focus();
|
||||
} catch (error) {
|
||||
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
|
||||
if (current === visit && !signal.aborted)
|
||||
loadingError =
|
||||
error instanceof Error ? error.message : 'Could not load launch options.';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshChecks() {
|
||||
if (!open || launching || unconfirmedLaunch) return;
|
||||
if (!loaded) { void initialize(); return; }
|
||||
if (!loaded) {
|
||||
void initialize();
|
||||
return;
|
||||
}
|
||||
launchError = '';
|
||||
controller.clear();
|
||||
refreshVersion++;
|
||||
}
|
||||
function selectionChanged() { launchError = ''; }
|
||||
function selectionChanged() {
|
||||
launchError = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const refreshOnReturn = () => { if (document.visibilityState !== 'hidden') refreshChecks(); };
|
||||
const refreshOnReturn = () => {
|
||||
if (document.visibilityState !== 'hidden') refreshChecks();
|
||||
};
|
||||
window.addEventListener('focus', refreshOnReturn);
|
||||
document.addEventListener('visibilitychange', refreshOnReturn);
|
||||
const timer = setInterval(() => { now = Date.now(); }, 1000);
|
||||
const timer = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshOnReturn);
|
||||
document.removeEventListener('visibilitychange', refreshOnReturn);
|
||||
clearInterval(timer);
|
||||
};
|
||||
});
|
||||
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
|
||||
onDestroy(() => {
|
||||
visit++;
|
||||
initialization?.abort();
|
||||
submission?.abort();
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
async function launch() {
|
||||
if (!canLaunch || !preview) return;
|
||||
const current = visit;
|
||||
const reviewed = preview;
|
||||
controller.suspend(); launching = true; launchError = '';
|
||||
controller.suspend();
|
||||
launching = true;
|
||||
launchError = '';
|
||||
submission = new AbortController();
|
||||
try {
|
||||
const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
|
||||
expectedName: reviewed.name, previewId: reviewed.id
|
||||
}) });
|
||||
const response = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
signal: submission.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
game: reviewed.recipe.game,
|
||||
gameMode: 'zombie',
|
||||
mapSlug: reviewed.recipe.mapSlug,
|
||||
expectedName: reviewed.name,
|
||||
previewId: reviewed.id
|
||||
})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (current !== visit) return;
|
||||
if (!response.ok || !body.ok) {
|
||||
unconfirmedLaunch = response.status >= 500;
|
||||
launchError = body.error ?? 'Could not queue this launch.';
|
||||
if (!unconfirmedLaunch) { controller.clear(); refreshVersion++; }
|
||||
if (!unconfirmedLaunch) {
|
||||
controller.clear();
|
||||
refreshVersion++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (current === visit) {
|
||||
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
|
||||
unconfirmedLaunch = true;
|
||||
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
|
||||
launchError =
|
||||
'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
|
||||
}
|
||||
} finally { if (current === visit) launching = false; }
|
||||
} finally {
|
||||
if (current === visit) launching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -158,92 +268,326 @@
|
||||
<p class="muted" role="status">Loading maps…</p>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
|
||||
onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
|
||||
<option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
|
||||
</select></label>
|
||||
<label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
|
||||
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||
</select></label>
|
||||
<label
|
||||
>Game<select
|
||||
bind:this={gameSelect}
|
||||
bind:value={game}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={(event) => {
|
||||
game = event.currentTarget.value as ZombieGame;
|
||||
mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
selectionChanged();
|
||||
}}
|
||||
>
|
||||
<option value="t5">Black Ops I</option><option value="t6"
|
||||
>Black Ops II</option
|
||||
>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Map<select
|
||||
bind:value={mapSlug}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={selectionChanged}
|
||||
>
|
||||
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
</div>
|
||||
|
||||
<section class="summary" aria-label="Server summary" aria-busy={checking}>
|
||||
<strong>{preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'}</strong>
|
||||
<strong
|
||||
>{preview?.name
|
||||
? serverTitle(preview.name)
|
||||
: (maps.find((map) => map.slug === mapSlug)?.friendly ??
|
||||
'Choose a map')}</strong
|
||||
>
|
||||
<p class="muted">Uses the current production setup when the server starts.</p>
|
||||
<div class="readiness" aria-label="Launch readiness">
|
||||
{#if checking}<span role="status">Checking availability…</span>
|
||||
{:else}
|
||||
<span class:ready={hostStatus.filesReady}>{hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>
|
||||
<span class:ready={hostStatus.filesReady}
|
||||
>{hostStatus.missingGames.length
|
||||
? 'Game files missing'
|
||||
: hostStatus.filesReady
|
||||
? 'Game files ready'
|
||||
: 'Game files preparing'}</span
|
||||
>
|
||||
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
|
||||
<span>Automatic host & port</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener">View Provisioning ↗</a>{/if}</div>{/if}
|
||||
{#if missingKey}<div class="issue" role="status"><p>No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.</p><a href={`/server-keys?game=${game}`} target="_blank" rel="noopener">Manage keys ↗</a></div>{/if}
|
||||
{#if launchError}<div class="issue" role="alert">
|
||||
<p>{launchError}</p>
|
||||
{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener"
|
||||
>View Provisioning ↗</a
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
{#if missingKey}<div class="issue" role="status">
|
||||
<p>
|
||||
No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.
|
||||
</p>
|
||||
<a href={`/server-keys?game=${game}`} target="_blank" rel="noopener"
|
||||
>Manage keys ↗</a
|
||||
>
|
||||
</div>{/if}
|
||||
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
|
||||
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
|
||||
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
|
||||
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">{view.error}</p>{/if}
|
||||
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div class="issue" role="status"><p>Add a host to run your server.</p><a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a></div>{/if}
|
||||
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div
|
||||
class="issue"
|
||||
role="status"
|
||||
>
|
||||
<p>Upload game files before launching.</p>
|
||||
<a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a>
|
||||
</div>
|
||||
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">
|
||||
Enter a valid runtime image in Advanced settings.
|
||||
</p>
|
||||
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">
|
||||
{view.error}
|
||||
</p>{/if}
|
||||
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div
|
||||
class="issue"
|
||||
role="status"
|
||||
>
|
||||
<p>Add a host to run your server.</p>
|
||||
<a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a>
|
||||
</div>{/if}
|
||||
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
|
||||
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button class="text-button retry" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
|
||||
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button
|
||||
class="text-button retry"
|
||||
disabled={launching}
|
||||
onclick={refreshChecks}>Retry checks</button
|
||||
>{/if}
|
||||
|
||||
{#if open}<NativeHostSelection games={[game]} bind:selection bind:ready={hostReady} bind:status={hostStatus}
|
||||
compact network {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
|
||||
{#if open}<NativeHostSelection
|
||||
games={[game]}
|
||||
bind:selection
|
||||
bind:ready={hostReady}
|
||||
bind:status={hostStatus}
|
||||
compact
|
||||
network
|
||||
{refreshVersion}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={selectionChanged}
|
||||
/>{/if}
|
||||
|
||||
{#if preview}<details class="setup-details">
|
||||
<summary>Setup details</summary>
|
||||
<dl>
|
||||
<div><dt>Server ID</dt><dd><code>{preview.name ?? 'No lobby available'}</code></dd></div>
|
||||
<div><dt>Configuration</dt><dd>{preview.recipe.configSource}</dd></div>
|
||||
<div><dt>Container image</dt><dd><code>{preview.recipe.image}</code></dd></div>
|
||||
{#if hostStatus.uploadedAt}<div><dt>Game files uploaded</dt><dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd></div>{/if}
|
||||
</dl>
|
||||
<h3>{preview.recipe.files.length} managed files</h3>
|
||||
<ul class="files">{#each preview.recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
|
||||
</details>{/if}
|
||||
<summary>Setup details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Server ID</dt>
|
||||
<dd><code>{preview.name ?? 'No lobby available'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Configuration</dt>
|
||||
<dd>{preview.recipe.configSource}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Container image</dt>
|
||||
<dd><code>{preview.recipe.image}</code></dd>
|
||||
</div>
|
||||
{#if hostStatus.uploadedAt}<div>
|
||||
<dt>Game files uploaded</dt>
|
||||
<dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd>
|
||||
</div>{/if}
|
||||
</dl>
|
||||
<h3>{preview.recipe.files.length} managed files</h3>
|
||||
<ul class="files">
|
||||
{#each preview.recipe.files as file}<li>
|
||||
<div>
|
||||
<strong>{file.name}</strong><span
|
||||
>{file.version === null ? 'Generated' : `v${file.version}`} ·
|
||||
{file.kind}</span
|
||||
>
|
||||
</div>
|
||||
<code>{file.path}</code>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<div class="launch-actions">
|
||||
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
|
||||
<div>
|
||||
<Button variant="ghost" disabled={launching} onclick={() => (open = false)}
|
||||
>Cancel</Button
|
||||
><Button
|
||||
variant="primary"
|
||||
loading={launching}
|
||||
disabled={!canLaunch}
|
||||
onclick={launch}
|
||||
>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.launch-content { display: grid; gap: 18px; }
|
||||
.fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
|
||||
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
select { width: 100%; }
|
||||
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
|
||||
.summary > strong { font-size: var(--text-md); line-height: 1.4; }
|
||||
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.ready { color: var(--state-online); }
|
||||
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
|
||||
.text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
|
||||
.retry { justify-self: start; }
|
||||
.setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
dl { display: grid; gap: 14px; margin: 16px 0; }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); line-height: 1.5; }
|
||||
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||
h3 { font-size: var(--text-sm); margin: 18px 0 8px; }
|
||||
.files { list-style: none; padding: 0; margin: 0; }
|
||||
.files li { display: grid; gap: 5px; padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.files li > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.files strong { font-size: var(--text-sm); font-weight: 500; }
|
||||
.files span, .files code { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.launch-actions > p.ready { color: var(--state-online); }
|
||||
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 440px) { .fields { grid-template-columns: 1fr; } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
|
||||
.launch-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.3fr;
|
||||
gap: 14px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.summary > strong {
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.4;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.readiness {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ready {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.issue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
a,
|
||||
.text-button {
|
||||
color: var(--accent-primary-hover);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry {
|
||||
justify-self: start;
|
||||
}
|
||||
.setup-details {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.files {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.files li {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.files li > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.files strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.files span,
|
||||
.files code {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.launch-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.launch-actions > p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.launch-actions > p.ready {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.launch-actions > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 440px) {
|
||||
.fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.launch-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.launch-actions > div {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
|
||||
let { open = $bindable(false), name, players, round = null }: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
name,
|
||||
players,
|
||||
round = null
|
||||
}: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
$effect(() => {
|
||||
@@ -17,34 +22,66 @@
|
||||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server destruction. Please retry.');
|
||||
if (!res.ok || !body.ok || !body.deploymentId)
|
||||
throw new Error(body.error ?? 'Could not queue server destruction. Please retry.');
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { submitting = false; }
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title={`Destroy ${serverTitle(name)}?`} busy={submitting}>
|
||||
<div class="fields">
|
||||
<p class="notice">{lifecycleImpact('destroy', players, round)}</p>
|
||||
<p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p>
|
||||
<p>
|
||||
This stops and removes the container, its writable server files, and registration.
|
||||
Shared game libraries, recorded history, and the saved server key are retained.
|
||||
</p>
|
||||
<p>Follow progress or cancel the waiting request on the provisioning page.</p>
|
||||
<p class="irreversible">You can cancel while waiting. Once destruction begins, it cannot be undone.</p>
|
||||
<p class="irreversible">
|
||||
You can cancel while waiting. Once destruction begins, it cannot be undone.
|
||||
</p>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Destroy server when empty</Button>
|
||||
<Button disabled={submitting} onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}
|
||||
>Destroy server when empty</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.fields { display: grid; gap: 16px; }
|
||||
p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
|
||||
.notice { color: var(--text-primary); }
|
||||
.irreversible { font-size: var(--text-sm); } .error { color: var(--state-danger); }
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notice {
|
||||
padding: 16px;
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notice {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.irreversible {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,9 +31,15 @@
|
||||
)
|
||||
j++;
|
||||
const prefix = alines.slice(0, i).map((t) => ({ kind: 'ctx' as const, text: t }));
|
||||
const suffix = alines.slice(alines.length - j).map((t) => ({ kind: 'ctx' as const, text: t }));
|
||||
const midA = alines.slice(i, alines.length - j).map((t) => ({ kind: 'del' as const, text: t }));
|
||||
const midB = blines.slice(i, blines.length - j).map((t) => ({ kind: 'add' as const, text: t }));
|
||||
const suffix = alines
|
||||
.slice(alines.length - j)
|
||||
.map((t) => ({ kind: 'ctx' as const, text: t }));
|
||||
const midA = alines
|
||||
.slice(i, alines.length - j)
|
||||
.map((t) => ({ kind: 'del' as const, text: t }));
|
||||
const midB = blines
|
||||
.slice(i, blines.length - j)
|
||||
.map((t) => ({ kind: 'add' as const, text: t }));
|
||||
return [...prefix, ...midA, ...midB, ...suffix];
|
||||
}
|
||||
</script>
|
||||
@@ -44,8 +50,8 @@
|
||||
<span class="path">{relPath}</span>
|
||||
</header>
|
||||
<pre>
|
||||
{#each rows as r, idx (idx)}<span
|
||||
class="ln ln-{r.kind}">{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
|
||||
{#each rows as r, idx (idx)}<span class="ln ln-{r.kind}"
|
||||
>{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
|
||||
</span>{/each}</pre>
|
||||
</div>
|
||||
|
||||
@@ -76,12 +82,12 @@
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.verb-added {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
background: rgba(124, 212, 255, 0.1);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--state-online);
|
||||
}
|
||||
.verb-updated {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
background: rgba(124, 192, 245, 0.1);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -95,7 +101,11 @@
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.path { color: var(--text-primary); font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
.path {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
@@ -106,7 +116,15 @@
|
||||
max-height: 540px;
|
||||
overflow: auto;
|
||||
}
|
||||
.ln { display: block; }
|
||||
.ln-add { background: color-mix(in srgb, var(--state-online) 8%, transparent); color: var(--state-online); }
|
||||
.ln-del { background: rgba(255, 48, 80, 0.08); color: #ff8a9a; }
|
||||
.ln {
|
||||
display: block;
|
||||
}
|
||||
.ln-add {
|
||||
background: color-mix(in srgb, var(--state-online) 8%, transparent);
|
||||
color: var(--state-online);
|
||||
}
|
||||
.ln-del {
|
||||
background: rgba(255, 48, 80, 0.08);
|
||||
color: #ff8a9a;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
||||
import { actionLabels } from '$lib/provisioning.js';
|
||||
import { fleetActions, fleetActionAvailable, queueFleetAction, type FleetAction, type FleetActionFailure } from '$ui/fleet-actions.js';
|
||||
import {
|
||||
fleetActions,
|
||||
fleetActionAvailable,
|
||||
queueFleetAction,
|
||||
type FleetAction,
|
||||
type FleetActionFailure
|
||||
} from '$ui/fleet-actions.js';
|
||||
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
|
||||
import { serverHealth } from '$ui/server-health.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
@@ -10,7 +16,10 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
|
||||
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onqueued
|
||||
}: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
|
||||
let servers = $state<FleetServer[]>([]);
|
||||
let action = $state<FleetAction>('restart');
|
||||
let selected = $state<string[]>([]);
|
||||
@@ -25,71 +34,121 @@
|
||||
start: 'Start the selected servers and verify their health.',
|
||||
restart: 'Restart the selected servers once they are empty, then verify their health.',
|
||||
stop: 'Stop the selected servers once they are empty. Their files, keys, and registrations are kept.',
|
||||
rebuild: 'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
|
||||
destroy: 'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
|
||||
rebuild:
|
||||
'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
|
||||
destroy:
|
||||
'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
|
||||
};
|
||||
const destructive = $derived(action === 'rebuild' || action === 'destroy');
|
||||
const eligible = $derived(servers.filter(server => !queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)));
|
||||
const targets = $derived(eligible.filter(server => selected.includes(server.name)));
|
||||
const eligible = $derived(
|
||||
servers.filter(
|
||||
(server) =>
|
||||
!queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)
|
||||
)
|
||||
);
|
||||
const targets = $derived(eligible.filter((server) => selected.includes(server.name)));
|
||||
const allSelected = $derived(eligible.length > 0 && targets.length === eligible.length);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
untrack(() => { action = 'restart'; selected = []; queued = []; failures = []; confirmed = false; });
|
||||
untrack(() => {
|
||||
action = 'restart';
|
||||
selected = [];
|
||||
queued = [];
|
||||
failures = [];
|
||||
confirmed = false;
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
refresh;
|
||||
void refresh;
|
||||
const controller = new AbortController();
|
||||
untrack(() => { void load(controller.signal); });
|
||||
untrack(() => {
|
||||
void load(controller.signal);
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
async function load(signal: AbortSignal) {
|
||||
loading = true; error = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/servers', { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load servers.');
|
||||
const body = await response.json();
|
||||
if (signal.aborted) return;
|
||||
servers = body.servers.sort((a: FleetServer, b: FleetServer) => serverTitle(a.name).localeCompare(serverTitle(b.name)));
|
||||
servers = body.servers.sort((a: FleetServer, b: FleetServer) =>
|
||||
serverTitle(a.name).localeCompare(serverTitle(b.name))
|
||||
);
|
||||
syncServerTime(body.serverNow);
|
||||
} catch (cause) { if (!signal.aborted) error = cause instanceof Error ? cause.message : 'Could not load servers.'; }
|
||||
finally { if (!signal.aborted) loading = false; }
|
||||
} catch (cause) {
|
||||
if (!signal.aborted)
|
||||
error = cause instanceof Error ? cause.message : 'Could not load servers.';
|
||||
} finally {
|
||||
if (!signal.aborted) loading = false;
|
||||
}
|
||||
}
|
||||
function chooseAction() {
|
||||
selected = [];
|
||||
confirmed = false;
|
||||
failures = [];
|
||||
}
|
||||
function chooseAction() { selected = []; confirmed = false; failures = []; }
|
||||
function toggle(name: string, checked: boolean) {
|
||||
selected = checked ? [...selected, name] : selected.filter(value => value !== name);
|
||||
selected = checked ? [...selected, name] : selected.filter((value) => value !== name);
|
||||
confirmed = false;
|
||||
}
|
||||
function selectAll(checked: boolean) {
|
||||
selected = checked ? eligible.map((server) => server.name) : [];
|
||||
confirmed = false;
|
||||
}
|
||||
function selectAll(checked: boolean) { selected = checked ? eligible.map(server => server.name) : []; confirmed = false; }
|
||||
async function submit() {
|
||||
if (busy || loading || error || (destructive && !confirmed)) return;
|
||||
const names = targets.filter(server => fleetActionAvailable(server, action, serverTimeNow())).map(server => server.name);
|
||||
const names = targets
|
||||
.filter((server) => fleetActionAvailable(server, action, serverTimeNow()))
|
||||
.map((server) => server.name);
|
||||
if (!names.length) return;
|
||||
busy = true; failures = [];
|
||||
busy = true;
|
||||
failures = [];
|
||||
try {
|
||||
const result = await queueFleetAction(action, names);
|
||||
queued = [...queued, ...result.queued.map(item => item.server)];
|
||||
selected = selected.filter(name => !queued.includes(name));
|
||||
queued = [...queued, ...result.queued.map((item) => item.server)];
|
||||
selected = selected.filter((name) => !queued.includes(name));
|
||||
failures = result.failed;
|
||||
confirmed = false;
|
||||
if (result.queued.length) onqueued(result.queued.length, action);
|
||||
if (!result.failed.length) open = false;
|
||||
} finally { busy = false; }
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title="Fleet actions" description="Choose an action, then select the servers to apply it to." {busy} wide>
|
||||
<Dialog
|
||||
bind:open
|
||||
title="Fleet actions"
|
||||
description="Choose an action, then select the servers to apply it to."
|
||||
{busy}
|
||||
wide
|
||||
>
|
||||
<div class="fields">
|
||||
<label class="action">Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
|
||||
{#each fleetActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
|
||||
</select></label>
|
||||
<label class="action"
|
||||
>Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
|
||||
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<p>{descriptions[action]}</p>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading the latest server status…</p>
|
||||
{:else}
|
||||
<div class="selection-tools">
|
||||
<label class="check"><input type="checkbox" checked={allSelected} indeterminate={targets.length > 0 && !allSelected} disabled={busy || !!error || !eligible.length} onchange={event => selectAll(event.currentTarget.checked)} />Select all eligible</label>
|
||||
<label class="check"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
indeterminate={targets.length > 0 && !allSelected}
|
||||
disabled={busy || !!error || !eligible.length}
|
||||
onchange={(event) => selectAll(event.currentTarget.checked)}
|
||||
/>Select all eligible</label
|
||||
>
|
||||
<span>{targets.length} selected · {eligible.length} eligible</span>
|
||||
<Button size="sm" disabled={busy} onclick={() => refresh++}>Refresh servers</Button>
|
||||
</div>
|
||||
@@ -97,46 +156,165 @@
|
||||
<fieldset disabled={busy || !!error} class="servers">
|
||||
<legend>Servers</legend>
|
||||
{#each servers as server (server.name)}
|
||||
{@const available = eligible.some(item => item.name === server.name)}
|
||||
{@const available = eligible.some((item) => item.name === server.name)}
|
||||
{@const health = serverHealth(server, $uiNow)}
|
||||
<label class="server" class:unavailable={!available}>
|
||||
<input type="checkbox" checked={available && selected.includes(server.name)} disabled={!available} onchange={event => toggle(server.name, event.currentTarget.checked)} />
|
||||
<span><strong>{serverTitle(server.name)}</strong><span class="metadata">{server.host?.name ?? 'Host unavailable'} · {health.label}</span>
|
||||
{#if queued.includes(server.name)}<span class="metadata">Job queued</span>
|
||||
{:else if !available}<span class="metadata">{actionLabels[action]} unavailable for the current server status.</span>
|
||||
{:else}<span class="metadata">{lifecycleImpact(action, health.state === 'online' && freshServer(server, $uiNow) ? server.last_players : null, currentRound(server, server.telemetry, $uiNow))}</span>{/if}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={available && selected.includes(server.name)}
|
||||
disabled={!available}
|
||||
onchange={(event) =>
|
||||
toggle(server.name, event.currentTarget.checked)}
|
||||
/>
|
||||
<span
|
||||
><strong>{serverTitle(server.name)}</strong><span class="metadata"
|
||||
>{server.host?.name ?? 'Host unavailable'} · {health.label}</span
|
||||
>
|
||||
{#if queued.includes(server.name)}<span class="metadata"
|
||||
>Job queued</span
|
||||
>
|
||||
{:else if !available}<span class="metadata"
|
||||
>{actionLabels[action]} unavailable for the current server status.</span
|
||||
>
|
||||
{:else}<span class="metadata"
|
||||
>{lifecycleImpact(
|
||||
action,
|
||||
health.state === 'online' && freshServer(server, $uiNow)
|
||||
? server.last_players
|
||||
: null,
|
||||
currentRound(server, server.telemetry, $uiNow)
|
||||
)}</span
|
||||
>{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{:else if !error}<p>No servers are available.</p>{/if}
|
||||
{/if}
|
||||
{#if action !== 'start'}<p>Each server waits for a live check confirming it is empty. Follow progress or cancel waiting jobs in Provisioning.</p>{/if}
|
||||
{#if destructive}<p>Shared game libraries, recorded history, saved keys, and collections are retained.</p>{/if}
|
||||
{#if destructive}<label class="check"><input type="checkbox" bind:checked={confirmed} disabled={busy || !targets.length} />I understand that the selected servers’ writable files and data will be permanently deleted.</label>{/if}
|
||||
{#if failures.length}<div role="alert"><p class="ui-error">Some requests could not be queued:</p><ul>{#each failures as failure}<li><strong>{serverTitle(failure.server)}:</strong> {failure.error}</li>{/each}</ul></div>{/if}
|
||||
{#if busy}<p role="status">Queuing {actionLabels[action].toLowerCase()} for the selected servers…</p>{/if}
|
||||
{#if action !== 'start'}<p>
|
||||
Each server waits for a live check confirming it is empty. Follow progress or cancel
|
||||
waiting jobs in Provisioning.
|
||||
</p>{/if}
|
||||
{#if destructive}<p>
|
||||
Shared game libraries, recorded history, saved keys, and collections are retained.
|
||||
</p>{/if}
|
||||
{#if destructive}<label class="check"
|
||||
><input
|
||||
type="checkbox"
|
||||
bind:checked={confirmed}
|
||||
disabled={busy || !targets.length}
|
||||
/>I understand that the selected servers’ writable files and data will be
|
||||
permanently deleted.</label
|
||||
>{/if}
|
||||
{#if failures.length}<div role="alert">
|
||||
<p class="ui-error">Some requests could not be queued:</p>
|
||||
<ul>
|
||||
{#each failures as failure}<li>
|
||||
<strong>{serverTitle(failure.server)}:</strong>
|
||||
{failure.error}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>{/if}
|
||||
{#if busy}<p role="status">
|
||||
Queuing {actionLabels[action].toLowerCase()} for the selected servers…
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button disabled={busy} onclick={() => open = false}>Close</Button>
|
||||
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)} loading={busy} onclick={submit}>{actionLabels[action]} {targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start' ? ' when empty' : ''}</Button>
|
||||
<Button disabled={busy} onclick={() => (open = false)}>Close</Button>
|
||||
<Button
|
||||
variant={destructive ? 'danger' : 'primary'}
|
||||
disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)}
|
||||
loading={busy}
|
||||
onclick={submit}
|
||||
>{actionLabels[action]}
|
||||
{targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start'
|
||||
? ' when empty'
|
||||
: ''}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.fields, .action { display: grid; gap: var(--space-4); }
|
||||
.action { gap: var(--space-2); }
|
||||
select { width: 100%; min-height: 40px; padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
|
||||
p, ul { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
.check, .selection-tools { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.check { line-height: 1.5; }
|
||||
input[type='checkbox'] { flex: 0 0 auto; width: 18px; height: 18px; accent-color: var(--accent-primary); }
|
||||
.selection-tools { flex-wrap: wrap; justify-content: space-between; font-size: var(--text-sm); }
|
||||
.servers { min-width: 0; margin: 0; padding: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); max-height: 360px; overflow-y: auto; }
|
||||
legend { margin-left: var(--space-3); padding: 0 var(--space-2); }
|
||||
.server { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); cursor: pointer; }
|
||||
.server + .server { border-top: 1px solid var(--border-subtle); }
|
||||
.server > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.metadata { display: block; margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; }
|
||||
.unavailable { cursor: default; }
|
||||
.fields,
|
||||
.action {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.action {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
p,
|
||||
ul {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.check,
|
||||
.selection-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.check {
|
||||
line-height: 1.5;
|
||||
}
|
||||
input[type='checkbox'] {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.selection-tools {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.servers {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
legend {
|
||||
margin-left: var(--space-3);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
.server {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.server + .server {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.server > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.metadata {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.unavailable {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { updatedAt = null, error = '', intervalLabel = '', onRetry, paused = false, retryDisabled = false }:
|
||||
{ updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void; paused?: boolean; retryDisabled?: boolean } = $props();
|
||||
let {
|
||||
updatedAt = null,
|
||||
error = '',
|
||||
intervalLabel = '',
|
||||
onRetry,
|
||||
paused = false,
|
||||
retryDisabled = false
|
||||
}: {
|
||||
updatedAt?: number | null;
|
||||
error?: string;
|
||||
intervalLabel?: string;
|
||||
onRetry?: () => void;
|
||||
paused?: boolean;
|
||||
retryDisabled?: boolean;
|
||||
} = $props();
|
||||
let now = $state(Date.now());
|
||||
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
// These are local receipt times, so their age uses the browser clock. The
|
||||
// shared formatter still supplies the same language and exact tooltip.
|
||||
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
|
||||
</script>
|
||||
|
||||
<div class="freshness" class:failed={!!error}>
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
|
||||
{:else}<span>{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}<time datetime={new Date(updatedAt!).toISOString()} title={$timeFormat.dateTime(updatedAt!)}>{$timeFormat.relative(updatedAt!, now)}</time>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button
|
||||
type="button"
|
||||
disabled={retryDisabled}
|
||||
onclick={onRetry}>Retry</button
|
||||
>{/if}
|
||||
{:else}<span
|
||||
>{paused
|
||||
? 'Paused'
|
||||
: known
|
||||
? 'Live'
|
||||
: 'Waiting for update'}{#if known}{' · updated '}<time
|
||||
datetime={new Date(updatedAt!).toISOString()}
|
||||
title={$timeFormat.dateTime(updatedAt!)}
|
||||
>{$timeFormat.relative(updatedAt!, now)}</time
|
||||
>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span
|
||||
>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.failed { color: var(--state-warning); }
|
||||
button { background: none; border: 0; padding: var(--space-1); color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
.freshness {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.failed {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: var(--space-1);
|
||||
color: var(--accent-primary-hover);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,49 +3,137 @@
|
||||
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)));
|
||||
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.';
|
||||
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 !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}
|
||||
{#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>
|
||||
<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; }
|
||||
.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>
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (cache.transfer && connected && (cache.status === 'downloading' || cache.status === 'verifying')) now = Date.now();
|
||||
if (
|
||||
cache.transfer &&
|
||||
connected &&
|
||||
(cache.status === 'downloading' || cache.status === 'verifying')
|
||||
)
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
@@ -22,31 +27,92 @@
|
||||
|
||||
<div class="transfer-progress">
|
||||
<ol aria-label="Game file preparation steps">
|
||||
<li class:active={downloading} class:complete={downloaded}>1. {downloaded ? 'Downloaded' : downloading ? 'Downloading' : 'Download'}</li>
|
||||
<li class:active={verifying} class:complete={progress.complete}>2. {progress.complete ? 'Verified' : verifying ? 'Verifying' : 'Verify'}</li>
|
||||
<li class:active={downloading} class:complete={downloaded}>
|
||||
1. {downloaded ? 'Downloaded' : downloading ? 'Downloading' : 'Download'}
|
||||
</li>
|
||||
<li class:active={verifying} class:complete={progress.complete}>
|
||||
2. {progress.complete ? 'Verified' : verifying ? 'Verifying' : 'Verify'}
|
||||
</li>
|
||||
</ol>
|
||||
{#if progress.phase && !progress.complete}
|
||||
<progress max={progress.total || 1} value={progress.total ? progress.phaseBytes ?? undefined : undefined} aria-label={progress.phase === 'verifying' ? 'Host verification progress' : 'Host download progress'}></progress>
|
||||
<span class="bytes">{#if progress.phaseBytes !== null}{formatGameBytes(progress.phaseBytes)} / {formatGameBytes(progress.total)} {progress.phase === 'verifying' ? 'checked' : 'downloaded'}{:else}Checking every file…{/if}</span>
|
||||
<progress
|
||||
max={progress.total || 1}
|
||||
value={progress.total ? (progress.phaseBytes ?? undefined) : undefined}
|
||||
aria-label={progress.phase === 'verifying'
|
||||
? 'Host verification progress'
|
||||
: 'Host download progress'}
|
||||
></progress>
|
||||
<span class="bytes"
|
||||
>{#if progress.phaseBytes !== null}{formatGameBytes(progress.phaseBytes)} / {formatGameBytes(
|
||||
progress.total
|
||||
)}
|
||||
{progress.phase === 'verifying' ? 'checked' : 'downloaded'}{:else}Checking every
|
||||
file…{/if}</span
|
||||
>
|
||||
{/if}
|
||||
{#if progress.elapsedSeconds !== null}
|
||||
<div class="timing" aria-live="off">
|
||||
<span>Elapsed {formatDuration(progress.elapsedSeconds * 1000)}</span>
|
||||
{#if progress.bytesPerSecond !== null}<span>{formatGameBytes(progress.bytesPerSecond)}/s</span>{/if}
|
||||
{#if progress.etaSeconds !== null}<span>About {formatDuration(Math.ceil(progress.etaSeconds) * 1000)} {progress.phase === 'verifying' ? 'verification' : 'download'} remaining</span>
|
||||
{:else if progress.live && progress.phaseBytes !== progress.total}<span>Estimating {progress.phase === 'verifying' ? 'verification' : 'download'} time remaining…</span>{/if}
|
||||
{#if progress.bytesPerSecond !== null}<span
|
||||
>{formatGameBytes(progress.bytesPerSecond)}/s</span
|
||||
>{/if}
|
||||
{#if progress.etaSeconds !== null}<span
|
||||
>About {formatDuration(Math.ceil(progress.etaSeconds) * 1000)}
|
||||
{progress.phase === 'verifying' ? 'verification' : 'download'} remaining</span
|
||||
>
|
||||
{:else if progress.live && progress.phaseBytes !== progress.total}<span
|
||||
>Estimating {progress.phase === 'verifying' ? 'verification' : 'download'} time remaining…</span
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if progress.active && !connected}<span class="muted">Host offline · waiting to resume updates</span>
|
||||
{#if progress.active && !connected}<span class="muted"
|
||||
>Host offline · waiting to resume updates</span
|
||||
>
|
||||
{:else if progress.stale}<span class="muted">Waiting for a fresh host report</span>
|
||||
{:else if downloading}<span class="muted">All files download before verification starts.</span>{/if}
|
||||
{:else if downloading}<span class="muted">All files download before verification starts.</span
|
||||
>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.transfer-progress { display:grid; gap:6px; min-width:200px; }
|
||||
ol { display:flex; flex-wrap:wrap; gap:6px 16px; list-style:none; margin:0; padding:0; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
li.active { color:var(--text-primary); font-weight:600; } li.complete { color:var(--accent-primary-hover); }
|
||||
progress { display:block; width:100%; margin:0; accent-color:var(--accent-primary); }
|
||||
.bytes,.timing,.muted { font-size:var(--text-xs); color:var(--text-secondary); font-variant-numeric:tabular-nums; line-height:1.5; }
|
||||
.timing { display:flex; flex-wrap:wrap; gap:2px 12px; }
|
||||
.transfer-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 200px;
|
||||
}
|
||||
ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
li.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
li.complete {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.bytes,
|
||||
.timing,
|
||||
.muted {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.timing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { name, deletedAt, href }: { name: string; deletedAt?: string | null; href?: string } = $props();
|
||||
let { name, deletedAt, href }: { name: string; deletedAt?: string | null; href?: string } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<span class="historical-server">
|
||||
@@ -12,6 +13,14 @@
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.historical-server { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 6px 8px; min-width: 0; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.historical-server {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,164 @@
|
||||
<script lang="ts">
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
|
||||
import {
|
||||
hostListStatus,
|
||||
hostReadingsStale,
|
||||
hostCpuUsage,
|
||||
hostUsedSpace
|
||||
} from '$ui/host-overview.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
|
||||
let {
|
||||
hosts,
|
||||
now,
|
||||
returnTo = '/hosts'
|
||||
}: {
|
||||
hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[];
|
||||
now: number;
|
||||
returnTo?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers">Servers</th><th title="Players reported by online servers">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
{@const status = hostListStatus(host, now)}
|
||||
{@const stale = hostReadingsStale(host, now)}
|
||||
<tr>
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
|
||||
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
|
||||
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
|
||||
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}>{host.serverCount}</a></td>
|
||||
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}` : 'Players reported by online servers'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
|
||||
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers"
|
||||
>Servers</th
|
||||
><th title="Players reported by online servers">Players</th><th>CPU</th><th
|
||||
>Memory <span>used / total</span></th
|
||||
><th>Disk <span>used / total</span></th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each hosts as host (host.id)}
|
||||
{@const status = hostListStatus(host, now)}
|
||||
{@const stale = hostReadingsStale(host, now)}
|
||||
<tr>
|
||||
<td
|
||||
><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}
|
||||
>{host.name}</a
|
||||
>{#if host.hostname && host.hostname !== host.name}<span class="secondary"
|
||||
>{host.hostname}</span
|
||||
>{/if}
|
||||
{#if stale && host.lastHeartbeat}<span class="secondary"
|
||||
>Resource readings stale</span
|
||||
>{/if}</td
|
||||
>
|
||||
<td class="status"
|
||||
><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a
|
||||
class="reason"
|
||||
href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}
|
||||
>{status.detail}</a
|
||||
>{/if}</td
|
||||
>
|
||||
<td
|
||||
><a
|
||||
href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)}
|
||||
aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}
|
||||
>{host.serverCount}</a
|
||||
></td
|
||||
>
|
||||
<td
|
||||
title={host.unreportedInstances
|
||||
? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}`
|
||||
: 'Players reported by online servers'}
|
||||
>{host.unreportedInstances === host.serverCount && host.serverCount > 0
|
||||
? '—'
|
||||
: `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td
|
||||
>
|
||||
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
|
||||
<td class:stale
|
||||
>{hostUsedSpace(
|
||||
host.inventory.memoryTotal,
|
||||
host.inventory.memoryAvailable
|
||||
)}</td
|
||||
>
|
||||
<td class:stale
|
||||
>{hostUsedSpace(
|
||||
host.inventory.storageTotal,
|
||||
host.inventory.storageAvailable
|
||||
)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
table { width:100%; min-width:850px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
|
||||
th { padding:12px 16px; color:var(--text-secondary); background:var(--surface-sunken); font-weight:500; font-size:var(--text-xs); }
|
||||
th span { display:block; font-weight:400; margin-top:3px; }
|
||||
td { padding:16px; background:var(--surface); border-top:1px solid var(--border-subtle); font-variant-numeric:tabular-nums; }
|
||||
tr:hover td { background:var(--surface-raised); }
|
||||
a { color:var(--accent-primary-hover); } .name { color:var(--text-primary); font-weight:600; text-decoration:none; overflow-wrap:anywhere; }
|
||||
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
|
||||
.status { max-width:330px; min-width:140px; }
|
||||
.secondary,.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); margin-top:6px; line-height:1.5; }
|
||||
.reason { overflow-wrap:anywhere; text-decoration:none; } .reason:hover { text-decoration:underline; color:var(--accent-primary-hover); }
|
||||
.stale { color:var(--text-secondary); }
|
||||
a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 850px;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-sunken);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
th span {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
margin-top: 3px;
|
||||
}
|
||||
td {
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr:hover td {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.name:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.status {
|
||||
max-width: 330px;
|
||||
min-width: 140px;
|
||||
}
|
||||
.secondary,
|
||||
.reason {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reason {
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: none;
|
||||
}
|
||||
.reason:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.stale {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,20 +6,53 @@
|
||||
</script>
|
||||
|
||||
<div class="preparation" aria-live="polite">
|
||||
<p>The default runtime and the latest available Black Ops I and Black Ops II game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both game libraries before it accepts deployments. Running servers keep their current files.</p>
|
||||
<p>Supported hosts also cache Plutonium files to speed up new deployments. This optional cache refreshes when a new production release is detected; deployments can proceed while it is unavailable.</p>
|
||||
<p>
|
||||
The default runtime and the latest available Black Ops I and Black Ops II game files are
|
||||
prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of
|
||||
both game libraries before it accepts deployments. Running servers keep their current files.
|
||||
</p>
|
||||
<p>
|
||||
Supported hosts also cache Plutonium files to speed up new deployments. This optional cache
|
||||
refreshes when a new production release is detected; deployments can proceed while it is
|
||||
unavailable.
|
||||
</p>
|
||||
{#if host.preparation}
|
||||
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
|
||||
<p class="status" class:blocked={host.preparation.state === 'blocked'}>
|
||||
Automatic setup: {host.preparation.state === 'ready'
|
||||
? 'Ready'
|
||||
: host.preparation.state === 'preparing'
|
||||
? 'In progress'
|
||||
: host.preparation.state === 'blocked'
|
||||
? 'Needs attention'
|
||||
: 'Waiting'}
|
||||
</p>
|
||||
<ul>
|
||||
{#each host.preparation.items as item}
|
||||
<li>
|
||||
<div class="row"><strong>{item.label}</strong><span>{item.state === 'blocked' ? 'Needs attention' : item.state}</span></div>
|
||||
<div class="row">
|
||||
<strong>{item.label}</strong><span
|
||||
>{item.state === 'blocked' ? 'Needs attention' : item.state}</span
|
||||
>
|
||||
</div>
|
||||
<p class:blocked={item.state === 'blocked'}>{item.detail}</p>
|
||||
{#if item.cache && item.cache.status !== 'missing'}
|
||||
<div class="transfer"><GameFileTransferProgress cache={item.cache} connected={host.connected} /></div>
|
||||
<div class="transfer">
|
||||
<GameFileTransferProgress
|
||||
cache={item.cache}
|
||||
connected={host.connected}
|
||||
/>
|
||||
</div>
|
||||
{:else if item.bytesTotal && item.bytesDownloaded !== undefined && item.state === 'preparing'}
|
||||
<progress max={item.bytesTotal} value={item.bytesDownloaded} aria-label={`${item.label} download progress`}></progress>
|
||||
<small>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(item.bytesTotal)}</small>
|
||||
<progress
|
||||
max={item.bytesTotal}
|
||||
value={item.bytesDownloaded}
|
||||
aria-label={`${item.label} download progress`}
|
||||
></progress>
|
||||
<small
|
||||
>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(
|
||||
item.bytesTotal
|
||||
)}</small
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -29,8 +62,62 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preparation { display:grid; gap:12px; } p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .status { color:var(--text-primary); }
|
||||
ul { list-style:none; padding:0; margin:0; display:grid; gap:16px; } li { min-width:0; } .row { display:flex; justify-content:space-between; gap:12px; font-size:var(--text-sm); } strong { overflow-wrap:anywhere; } span { text-transform:capitalize; color:var(--text-secondary); }
|
||||
li p { margin-top:4px; } .blocked { color:var(--state-danger); } progress { width:100%; margin-top:8px; accent-color:var(--accent-primary); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); } a { color:var(--accent-primary-hover); }
|
||||
.transfer { margin-top:8px; }
|
||||
.preparation {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.status {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
li {
|
||||
min-width: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
span {
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
li p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.blocked {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.transfer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,156 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
|
||||
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="recipe">
|
||||
<div class="recipe-heading"><div>{#if !historical}<span class="eyebrow">Current promoted setup</span>{/if}<h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
|
||||
<div class="recipe-heading">
|
||||
<div>
|
||||
{#if !historical}<span class="eyebrow">Current promoted setup</span>{/if}
|
||||
<h3>{recipe.mapName}</h3>
|
||||
</div>
|
||||
<span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
|
||||
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
|
||||
<div>
|
||||
<dt>Configuration</dt>
|
||||
<dd>{recipe.configSource}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Container image</dt>
|
||||
<dd class="mono">{recipe.image}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if !historical}<p>Each server uses the latest promoted scripts, plugins, and configuration when provisioning begins, including promotions made after this preview or while queued.</p>{/if}
|
||||
{#if !historical}<p>
|
||||
Each server uses the latest promoted scripts, plugins, and configuration when
|
||||
provisioning begins, including promotions made after this preview or while queued.
|
||||
</p>{/if}
|
||||
<details>
|
||||
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
|
||||
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
|
||||
{#if !historical}<p>Previewed <RelativeTime value={recipe.capturedAt} />. This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.</p>{/if}
|
||||
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
|
||||
<summary
|
||||
>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'}
|
||||
{historical ? 'recorded for this deployment' : 'in the current setup'}</summary
|
||||
>
|
||||
<ul>
|
||||
{#each recipe.files as file}<li>
|
||||
<div>
|
||||
<strong>{file.name}</strong><span
|
||||
>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind}
|
||||
· {file.size.toLocaleString()} bytes</span
|
||||
>
|
||||
</div>
|
||||
<code>{file.path}</code><small title={file.sha256}
|
||||
>SHA-256 {file.sha256.slice(0, 12)}</small
|
||||
>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
{#if !historical}<p>
|
||||
Previewed <RelativeTime value={recipe.capturedAt} />. This preview shows the
|
||||
promoted setup at the time above. The deployment history records the files selected
|
||||
when provisioning begins.
|
||||
</p>{/if}
|
||||
<small class="fingerprint" title={recipe.fingerprint}
|
||||
>Recipe {recipe.fingerprint.slice(0, 16)}</small
|
||||
>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recipe { display: grid; gap: 16px; min-width: 0; }
|
||||
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
h3 { margin: 0; font-size: var(--text-xl); letter-spacing: -.03em; }
|
||||
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: var(--text-xs); white-space: nowrap; }
|
||||
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: var(--text-xs); } dd { margin: 4px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.mono, code, .fingerprint { font-family: var(--font-mono); }
|
||||
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: var(--text-sm); font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.recipe {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.recipe-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.game {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
padding: 5px 10px;
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mono,
|
||||
code,
|
||||
.fingerprint {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
details {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 14px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
li {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
li > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 550;
|
||||
}
|
||||
span,
|
||||
code,
|
||||
small {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
li span,
|
||||
small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
<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();
|
||||
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');
|
||||
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>
|
||||
{#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); }
|
||||
.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>
|
||||
|
||||
@@ -10,8 +10,21 @@
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), initialServer = '', initialAction = 'start', serverContext = null, players = null, round = null }:
|
||||
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction; serverContext?: FleetServer | null; players?: number | null; round?: number | null } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
initialServer = '',
|
||||
initialAction = 'start',
|
||||
serverContext = null,
|
||||
players = null,
|
||||
round = null
|
||||
}: {
|
||||
open?: boolean;
|
||||
initialServer?: string;
|
||||
initialAction?: RuntimeAction;
|
||||
serverContext?: FleetServer | null;
|
||||
players?: number | null;
|
||||
round?: number | null;
|
||||
} = $props();
|
||||
let servers = $state<FleetServer[]>([]);
|
||||
let server = $state(untrack(() => initialServer));
|
||||
let action = $state<RuntimeAction | 'destroy'>(untrack(() => initialAction));
|
||||
@@ -19,15 +32,35 @@
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let confirmed = $state(false);
|
||||
const selected = $derived(serverContext?.name === server ? serverContext : servers.find(item => item.name === server));
|
||||
const selected = $derived(
|
||||
serverContext?.name === server
|
||||
? serverContext
|
||||
: servers.find((item) => item.name === server)
|
||||
);
|
||||
const available = $derived(selected ? applicableRuntimeActions(selected, $uiNow) : []);
|
||||
const applicable = $derived(action === 'destroy' || available.includes(action));
|
||||
const destructive = $derived(action === 'rebuild' || action === 'destroy');
|
||||
const health = $derived(selected ? serverHealth(selected, $uiNow) : null);
|
||||
const impactPlayers = $derived(initialServer ? players : selected && health?.state === 'online' && freshServer(selected, $uiNow) ? selected.last_players : null);
|
||||
const impactRound = $derived(initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null);
|
||||
const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server');
|
||||
const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Destroy server when empty' : `${actionLabels[action]} when empty`);
|
||||
const impactPlayers = $derived(
|
||||
initialServer
|
||||
? players
|
||||
: selected && health?.state === 'online' && freshServer(selected, $uiNow)
|
||||
? selected.last_players
|
||||
: null
|
||||
);
|
||||
const impactRound = $derived(
|
||||
initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null
|
||||
);
|
||||
const title = $derived(
|
||||
initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server'
|
||||
);
|
||||
const submitLabel = $derived(
|
||||
action === 'start'
|
||||
? 'Start server'
|
||||
: action === 'destroy'
|
||||
? 'Destroy server when empty'
|
||||
: `${actionLabels[action]} when empty`
|
||||
);
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const fixedServer = initialServer;
|
||||
@@ -36,86 +69,181 @@
|
||||
// Live context and match reports update the copy and eligibility without
|
||||
// reopening the dialog or clearing a deliberate confirmation.
|
||||
untrack(() => {
|
||||
server = fixedServer; action = requestedAction; confirmed = false; error = ''; servers = [];
|
||||
server = fixedServer;
|
||||
action = requestedAction;
|
||||
confirmed = false;
|
||||
error = '';
|
||||
servers = [];
|
||||
loading = false;
|
||||
if (!fixedServer || serverContext?.name !== fixedServer) void load(fixedServer, controller.signal);
|
||||
if (!fixedServer || serverContext?.name !== fixedServer)
|
||||
void load(fixedServer, controller.signal);
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
async function load(fixedServer: string, signal: AbortSignal) {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers', { signal });
|
||||
const response = await fetch(
|
||||
fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers',
|
||||
{ signal }
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not load servers.');
|
||||
const body = await response.json();
|
||||
if (signal.aborted) return;
|
||||
servers = body.servers;
|
||||
syncServerTime(body.serverNow);
|
||||
} catch (err) { if (!signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!signal.aborted) loading = false; }
|
||||
} catch (err) {
|
||||
if (!signal.aborted) error = (err as Error).message;
|
||||
} finally {
|
||||
if (!signal.aborted) loading = false;
|
||||
}
|
||||
}
|
||||
function chooseServer(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
|
||||
server = event.currentTarget.value;
|
||||
confirmed = false; error = '';
|
||||
const item = servers.find(item => item.name === server);
|
||||
confirmed = false;
|
||||
error = '';
|
||||
const item = servers.find((item) => item.name === server);
|
||||
const actions = item ? applicableRuntimeActions(item, serverTimeNow()) : [];
|
||||
action = actions[0] ?? 'destroy';
|
||||
}
|
||||
async function submit() {
|
||||
if (busy || !server || (destructive && !confirmed)) return;
|
||||
if (action !== 'destroy' && (!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))) {
|
||||
error = 'The server status has changed. Close this dialog and choose an action from its current status.';
|
||||
if (
|
||||
action !== 'destroy' &&
|
||||
(!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))
|
||||
) {
|
||||
error =
|
||||
'The server status has changed. Close this dialog and choose an action from its current status.';
|
||||
return;
|
||||
}
|
||||
busy = true; error = '';
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = action === 'destroy'
|
||||
? await fetch(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
|
||||
: await fetch('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
|
||||
const response =
|
||||
action === 'destroy'
|
||||
? await fetch(`/api/servers/${encodeURIComponent(server)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
: await fetch('/api/provisioning', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server, action })
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue this operation.');
|
||||
if (!response.ok || !body.deploymentId)
|
||||
throw new Error(body.error ?? 'Could not queue this operation.');
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open {title} {busy}>
|
||||
<div class="fields">
|
||||
{#if !initialServer}
|
||||
<label>Server <select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
|
||||
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
|
||||
{#each servers as item (item.name)}<option value={item.name}>{serverTitle(item.name)}</option>{/each}
|
||||
</select></label>
|
||||
<label>Action <select bind:value={action} disabled={busy || !server || loading} onchange={() => { confirmed = false; error = ''; }}>
|
||||
{#if !server}<option value="start">Choose a server first</option>{/if}
|
||||
{#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if}
|
||||
{#each available as value}<option value={value}>{actionLabels[value]}</option>{/each}
|
||||
{#if server}<option value="destroy">Destroy server</option>{/if}
|
||||
</select></label>
|
||||
<label
|
||||
>Server <select
|
||||
bind:value={server}
|
||||
disabled={busy || loading}
|
||||
onchange={chooseServer}
|
||||
>
|
||||
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
|
||||
{#each servers as item (item.name)}<option value={item.name}
|
||||
>{serverTitle(item.name)}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Action <select
|
||||
bind:value={action}
|
||||
disabled={busy || !server || loading}
|
||||
onchange={() => {
|
||||
confirmed = false;
|
||||
error = '';
|
||||
}}
|
||||
>
|
||||
{#if !server}<option value="start">Choose a server first</option>{/if}
|
||||
{#if server && !applicable}<option value={action} disabled
|
||||
>{actionLabels[action]} unavailable</option
|
||||
>{/if}
|
||||
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
|
||||
{#if server}<option value="destroy">Destroy server</option>{/if}
|
||||
</select></label
|
||||
>
|
||||
{/if}
|
||||
{#if loading}<p role="status">Checking the latest server status…</p>
|
||||
{:else if server}
|
||||
{#if applicable}
|
||||
<p class="impact">{lifecycleImpact(action, impactPlayers, impactRound)}</p>
|
||||
<p>{action === 'destroy' ? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
|
||||
{#if action !== 'start'}<p class="help">Follow progress or cancel the waiting request on the provisioning page.</p>{/if}
|
||||
{:else}<p role="status">{actionLabels[action]} is unavailable. {health?.reason ?? 'The current server status could not be verified.'} Close this dialog and choose an action from its current status.</p>{/if}
|
||||
{#if applicable && destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that this instance’s writable files and data will be permanently deleted.</label>{/if}
|
||||
<p>
|
||||
{action === 'destroy'
|
||||
? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.'
|
||||
: runtimeDescriptions[action]}
|
||||
</p>
|
||||
{#if action !== 'start'}<p class="help">
|
||||
Follow progress or cancel the waiting request on the provisioning page.
|
||||
</p>{/if}
|
||||
{:else}<p role="status">
|
||||
{actionLabels[action]} is unavailable. {health?.reason ??
|
||||
'The current server status could not be verified.'} Close this dialog and choose
|
||||
an action from its current status.
|
||||
</p>{/if}
|
||||
{#if applicable && destructive}<label class="confirmation"
|
||||
><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that
|
||||
this instance’s writable files and data will be permanently deleted.</label
|
||||
>{/if}
|
||||
{/if}
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button disabled={busy} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || !applicable || (destructive && !confirmed)} loading={busy} onclick={submit}>{submitLabel}</Button>
|
||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant={destructive ? 'danger' : 'primary'}
|
||||
disabled={busy || loading || !server || !applicable || (destructive && !confirmed)}
|
||||
loading={busy}
|
||||
onclick={submit}>{submitLabel}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.fields { display: grid; gap: 16px; } label { display: grid; gap: 8px; }
|
||||
select { padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); }
|
||||
p { margin: 0; line-height: 1.6; color: var(--text-secondary); } .confirmation { display: flex; align-items: flex-start; gap: 12px; line-height: 1.5; }
|
||||
.impact { padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); color: var(--text-primary); }
|
||||
.help { font-size: var(--text-sm); }
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
select {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.confirmation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.impact {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
label: string; // human-facing tab name
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
label: string; // human-facing tab name
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -23,16 +23,21 @@
|
||||
}
|
||||
let { server, tabs, lines = 1000, pollMs = 5000 }: Props = $props();
|
||||
|
||||
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||
const active = $derived(
|
||||
tabs.find((tab) => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? ''
|
||||
);
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
let checkedAt = $state<Record<string, number>>({});
|
||||
|
||||
function chooseLog(key: string) {
|
||||
const url = new URL($page.url); url.searchParams.set('log', key);
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('log', key);
|
||||
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
|
||||
}
|
||||
let query = $state('');
|
||||
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
|
||||
onMount(() => {
|
||||
query = $page.url.searchParams.get('logQuery') ?? '';
|
||||
});
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let refreshError = $state('');
|
||||
@@ -41,15 +46,25 @@
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
const refresh = createLogRefresh({
|
||||
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
onReset: () => {
|
||||
data = {};
|
||||
checkedAt = {};
|
||||
refreshError = '';
|
||||
copyStatus = '';
|
||||
},
|
||||
onLoading: (value) => {
|
||||
loading = value;
|
||||
},
|
||||
onError: (message) => {
|
||||
refreshError = message;
|
||||
},
|
||||
onData: (key, body) => {
|
||||
data = { ...data, [key]: body };
|
||||
checkedAt = { ...checkedAt, [key]: Date.now() };
|
||||
const currentServer = server;
|
||||
void tick().then(() => {
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl)
|
||||
bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,32 +83,51 @@
|
||||
refresh.configure({ server, key: active, lines }, autoRefresh, pollMs);
|
||||
}
|
||||
async function jumpToLatest() {
|
||||
query = ''; stickToBottom = true;
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
query = '';
|
||||
stickToBottom = true;
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
async function copy(value: string, message: string) {
|
||||
copyStatus = '';
|
||||
try { await navigator.clipboard.writeText(value); copyStatus = message; }
|
||||
catch { copyStatus = 'Could not copy. Select the log text or browser address to copy manually.'; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copyStatus = message;
|
||||
} catch {
|
||||
copyStatus = 'Could not copy. Select the log text or browser address to copy manually.';
|
||||
}
|
||||
}
|
||||
function copyLink() {
|
||||
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
|
||||
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', 'logs');
|
||||
url.searchParams.set('log', active);
|
||||
if (query) url.searchParams.set('logQuery', query);
|
||||
else url.searchParams.delete('logQuery');
|
||||
void copy(url.href, 'Log link copied.');
|
||||
}
|
||||
function download() {
|
||||
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${server}-${active}.log`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function highlight(line: string) {
|
||||
if (!query) return [{ text: line, match: false }];
|
||||
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
|
||||
const parts: { text: string; match: boolean }[] = [];
|
||||
let start = 0;
|
||||
let index: number;
|
||||
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
|
||||
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
|
||||
parts.push(
|
||||
{ text: line.slice(start, index), match: false },
|
||||
{ text: line.slice(index, index + query.length), match: true }
|
||||
);
|
||||
start = index + query.length;
|
||||
}
|
||||
parts.push({ text: line.slice(start), match: false }); return parts;
|
||||
parts.push({ text: line.slice(start), match: false });
|
||||
return parts;
|
||||
}
|
||||
$effect(() => {
|
||||
const target = { server, key: active, lines };
|
||||
@@ -111,67 +145,193 @@
|
||||
}
|
||||
|
||||
let current = $derived(data[active] ?? null);
|
||||
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
const visibleLines = $derived(
|
||||
(current?.lines ?? [])
|
||||
.map(stripGameColorCodes)
|
||||
.filter((line) => !query || line.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="tabs">
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<Tabs
|
||||
tabs={tabs.map((t) => ({ id: t.key, label: t.label }))}
|
||||
value={active}
|
||||
onchange={chooseLog}
|
||||
idPrefix={uid}
|
||||
panelId={uid + '-panel'}
|
||||
ariaLabel="Log files"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · File changed <RelativeTime value={current.mtime} /></span>
|
||||
<span class="meta"
|
||||
>{fmtBytes(current.size)} · File changed <RelativeTime
|
||||
value={current.mtime}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
<label class="auto">
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
Auto-refresh
|
||||
</label>
|
||||
{#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>{/if}
|
||||
{#if !autoRefresh}<button
|
||||
class="refresh"
|
||||
onclick={() => refresh.refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Freshness updatedAt={checkedAt[active] ?? null} paused={!autoRefresh} intervalLabel={`Updates every ${pollMs / 1000} seconds`} error={refreshError ? 'Log updates interrupted.' : ''} onRetry={() => void refresh.refresh()} retryDisabled={loading} />
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download raw tail</Button></div></details></div>
|
||||
<Freshness
|
||||
updatedAt={checkedAt[active] ?? null}
|
||||
paused={!autoRefresh}
|
||||
intervalLabel={`Updates every ${pollMs / 1000} seconds`}
|
||||
error={refreshError ? 'Log updates interrupted.' : ''}
|
||||
onRetry={() => void refresh.refresh()}
|
||||
retryDisabled={loading}
|
||||
/>
|
||||
<div class="log-tools">
|
||||
<label class="log-search"
|
||||
>Search loaded lines<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Find text in this log"
|
||||
/></label
|
||||
>
|
||||
<details class="log-actions">
|
||||
<summary>More</summary>
|
||||
<div class="action-items">
|
||||
<Button size="sm" onclick={copyLink}>Copy link</Button><Button
|
||||
size="sm"
|
||||
disabled={!current?.lines?.length}
|
||||
onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}
|
||||
>Copy {query ? 'matches' : 'log'}</Button
|
||||
><Button size="sm" disabled={!current?.lines?.length} onclick={download}
|
||||
>Download raw tail</Button
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
|
||||
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
|
||||
<div class="follow-state">
|
||||
<span
|
||||
>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query
|
||||
? `${visibleLines.length} matching lines`
|
||||
: stickToBottom
|
||||
? 'Following latest lines'
|
||||
: 'Reading earlier lines'}</span
|
||||
>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}
|
||||
>Jump to latest</Button
|
||||
>{/if}
|
||||
</div>
|
||||
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<details><summary>Technical details</summary>{refreshError}</details></div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">{loading ? 'Loading log…' : autoRefresh ? 'Waiting for the first log update.' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current?.missing}
|
||||
<div class="empty">
|
||||
log file not written yet
|
||||
{#if current.path}<br /><code>{current.path}</code>{/if}
|
||||
</div>
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else if current?.lines}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
|
||||
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
|
||||
{#if current.truncated}
|
||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||
{:else}
|
||||
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
|
||||
{#if refreshError}<div class="ui-error" role="alert">
|
||||
Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}
|
||||
<details><summary>Technical details</summary>{refreshError}</details>
|
||||
</div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">
|
||||
{loading
|
||||
? 'Loading log…'
|
||||
: autoRefresh
|
||||
? 'Waiting for the first log update.'
|
||||
: 'No log loaded. Select Refresh to load it.'}
|
||||
</div>
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current?.missing}
|
||||
<div class="empty">
|
||||
log file not written yet
|
||||
{#if current.path}<br /><code>{current.path}</code>{/if}
|
||||
</div>
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else if current?.lines}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
|
||||
<pre
|
||||
class="body"
|
||||
bind:this={bodyEl}
|
||||
onscroll={onScroll}
|
||||
tabindex="0"
|
||||
aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark
|
||||
>{part.text}</mark
|
||||
>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
|
||||
{#if query && !visibleLines.length}<p class="empty">
|
||||
No matches in the loaded tail. Try another search or download the tail.
|
||||
</p>{/if}
|
||||
{#if current.truncated}
|
||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||
{:else}
|
||||
<div class="foot">
|
||||
{current.lines.length} line{current.lines.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.log-tools, .action-items, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.log-actions { position: relative; font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; }
|
||||
.action-items { position: absolute; right: 0; z-index: 3; padding: 12px; min-width: 160px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
|
||||
.log-tools { align-items: end; }
|
||||
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
|
||||
.copy-status { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
mark { background: var(--state-warning); color: var(--surface-sunken); }
|
||||
.log-tools,
|
||||
.action-items,
|
||||
.follow-state {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.log-actions {
|
||||
position: relative;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-items {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.log-tools {
|
||||
align-items: end;
|
||||
}
|
||||
.log-search {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.log-search input {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
.copy-status {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.follow-state {
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
mark {
|
||||
background: var(--state-warning);
|
||||
color: var(--surface-sunken);
|
||||
}
|
||||
.log-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -207,7 +367,9 @@
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.auto input { accent-color: var(--accent-primary); }
|
||||
.auto input {
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.refresh {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
@@ -222,7 +384,10 @@
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.refresh:disabled { opacity: 0.4; cursor: default; }
|
||||
.refresh:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
@@ -239,7 +404,8 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.empty, .error {
|
||||
.empty,
|
||||
.error {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
@@ -267,6 +433,10 @@
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.right { width: 100%; margin-left: 0; flex-wrap: wrap; }
|
||||
.right {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import type { MatchHistoryResponse } from '$lib/match-history.js';
|
||||
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
|
||||
import {
|
||||
matchApiParams,
|
||||
matchHistoryHref,
|
||||
matchPageSize,
|
||||
readMatchQuery,
|
||||
type MatchHistoryQuery,
|
||||
type MatchSort
|
||||
} from '$lib/match-history-query.js';
|
||||
import { matchInstanceLabel } from '$ui/match-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
@@ -22,7 +29,7 @@
|
||||
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
|
||||
let tableRegion = $state<HTMLDivElement>();
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
@@ -30,32 +37,60 @@
|
||||
const apiUrl = $derived.by(() => {
|
||||
const params = matchApiParams($page.url);
|
||||
params.set('limit', String(matchPageSize));
|
||||
const endpoint = server === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(server)}/matches`;
|
||||
const endpoint =
|
||||
server === undefined
|
||||
? '/api/matches'
|
||||
: `/api/servers/${encodeURIComponent(server)}/matches`;
|
||||
return `${endpoint}?${params}`;
|
||||
});
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
|
||||
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
|
||||
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
|
||||
let loadedUrl = $state(untrack(() => (initial ? apiUrl : '')));
|
||||
const filterCount = $derived(
|
||||
[filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length
|
||||
);
|
||||
const hasFilters = $derived(
|
||||
!!(filters.game || filters.map || filters.q || filters.days || filters.status)
|
||||
);
|
||||
const sorts: Array<{ key: MatchSort; label: string }> = [
|
||||
{ key: 'started', label: 'Started' },
|
||||
{ key: 'players', label: 'Players' },
|
||||
{ key: 'duration', label: 'Duration' },
|
||||
{ key: 'round', label: 'Round' }
|
||||
];
|
||||
|
||||
$effect(() => { search = filters.q; });
|
||||
$effect(() => {
|
||||
search = filters.q;
|
||||
});
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
if (report)
|
||||
untrack(() => {
|
||||
data = report;
|
||||
loadedUrl = apiUrl;
|
||||
loading = false;
|
||||
error = '';
|
||||
checkedAt = Date.now();
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
retry;
|
||||
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
|
||||
return pollVisible(async signal => {
|
||||
void retry;
|
||||
untrack(() => {
|
||||
if (loadedUrl !== url) loading = true;
|
||||
error = '';
|
||||
});
|
||||
return pollVisible(async (signal) => {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load match history.');
|
||||
const body: MatchHistoryResponse = await response.json();
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
// Do not move keyboard users to another match while they are following a link.
|
||||
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
|
||||
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
|
||||
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement))
|
||||
data = body;
|
||||
loadedUrl = url;
|
||||
loading = false;
|
||||
error = '';
|
||||
checkedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load match history.';
|
||||
@@ -69,130 +104,547 @@
|
||||
pendingUrl = new URL(href, $page.url);
|
||||
const id = ++navigationId;
|
||||
void goto(href, { noScroll: true, keepFocus: true })
|
||||
.catch(() => { error = 'Could not update the match filters. Please try again.'; })
|
||||
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
|
||||
.catch(() => {
|
||||
error = 'Could not update the match filters. Please try again.';
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === navigationId) pendingUrl = undefined;
|
||||
});
|
||||
}
|
||||
function sort(key: MatchSort) {
|
||||
update({
|
||||
sort: key,
|
||||
direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc'
|
||||
});
|
||||
}
|
||||
function reset() {
|
||||
update({ game: '', map: '', q: '', days: '', status: '' });
|
||||
}
|
||||
function sort(key: MatchSort) { update({ sort: key, direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc' }); }
|
||||
function reset() { update({ game: '', map: '', q: '', days: '', status: '' }); }
|
||||
</script>
|
||||
|
||||
<section class="history" aria-label="Match history">
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
{#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
|
||||
<label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
|
||||
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
|
||||
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
|
||||
</form>{/if}
|
||||
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||||
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||||
{#if server === undefined}<label for={`match-game${suffix}`}
|
||||
>Game<select
|
||||
id={`match-game${suffix}`}
|
||||
value={filters.game}
|
||||
onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
|
||||
><option value="">All games</option><option value="t5">Black Ops I</option
|
||||
><option value="t6">Black Ops II</option></select
|
||||
></label
|
||||
>{/if}
|
||||
<label for={`match-map${suffix}`}
|
||||
>Map<select
|
||||
id={`match-map${suffix}`}
|
||||
value={filters.map}
|
||||
onchange={(event) => update({ map: event.currentTarget.value })}
|
||||
>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some((map) => map.key === filters.map)}<option
|
||||
value={filters.map}>{filters.map}</option
|
||||
>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}
|
||||
>{map.name}{!filters.game && server === undefined
|
||||
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
|
||||
: ''}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
{#if server === undefined}<form
|
||||
role="search"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
update({ q: search.trim() });
|
||||
if (suffix) filtersOpen = false;
|
||||
}}
|
||||
>
|
||||
<label for={`match-search${suffix}`}
|
||||
>Server<input
|
||||
id={`match-search${suffix}`}
|
||||
type="search"
|
||||
bind:value={search}
|
||||
maxlength="100"
|
||||
placeholder="Find a server…"
|
||||
aria-describedby={`match-search-help${suffix}`}
|
||||
/></label
|
||||
>
|
||||
<span id={`match-search-help${suffix}`} class="sr-only"
|
||||
>Press Enter to search by server name.</span
|
||||
>
|
||||
</form>{/if}
|
||||
<label for={`match-days${suffix}`}
|
||||
>Date range<select
|
||||
id={`match-days${suffix}`}
|
||||
value={filters.days}
|
||||
onchange={(event) => update({ days: event.currentTarget.value })}
|
||||
><option value="0">All time</option><option value="7">Last 7 days</option
|
||||
><option value="30">Last 30 days</option><option value="90">Last 90 days</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label for={`match-status${suffix}`}
|
||||
>Status<select
|
||||
id={`match-status${suffix}`}
|
||||
value={filters.status}
|
||||
onchange={(event) => update({ status: event.currentTarget.value })}
|
||||
><option value="">All statuses</option><option value="completed"
|
||||
>Completed</option
|
||||
><option value="active">In progress</option><option value="interrupted"
|
||||
>Interrupted</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
{#if suffix}
|
||||
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
|
||||
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
|
||||
<label for={`match-sort${suffix}`}
|
||||
>Sort by<select
|
||||
id={`match-sort${suffix}`}
|
||||
value={filters.sort}
|
||||
onchange={(event) => update({ sort: event.currentTarget.value })}
|
||||
>{#each sorts as item}<option value={item.key}>{item.label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label for={`match-order${suffix}`}
|
||||
>Order<select
|
||||
id={`match-order${suffix}`}
|
||||
value={filters.direction}
|
||||
onchange={(event) => update({ direction: event.currentTarget.value })}
|
||||
><option value="desc">Highest or newest first</option><option value="asc"
|
||||
>Lowest or oldest first</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
|
||||
<div class="mobile-filters">
|
||||
<span
|
||||
>{data?.total ?? '—'} matches{filters.map
|
||||
? ` · ${data?.maps.find((map) => map.key === filters.map)?.name ?? 'Selected map'}`
|
||||
: ''}</span
|
||||
><Button size="sm" onclick={() => (filtersOpen = true)}
|
||||
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
|
||||
>
|
||||
</div>
|
||||
<Dialog
|
||||
bind:open={filtersOpen}
|
||||
title="Match filters"
|
||||
description="Choose the matches to show."
|
||||
drawer
|
||||
>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
|
||||
variant="primary"
|
||||
onclick={() => (filtersOpen = false)}>Show results</Button
|
||||
>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
<div class="context">
|
||||
<span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span
|
||||
>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button
|
||||
>{/if}{#if !error}<Freshness
|
||||
updatedAt={checkedAt}
|
||||
intervalLabel="Updates every 10 seconds"
|
||||
/>{/if}
|
||||
</div>
|
||||
{#if error}<div class="error" role="alert">
|
||||
{error}
|
||||
<Button size="sm" onclick={() => retry++}>Retry</Button>
|
||||
</div>{/if}
|
||||
{#if loading}<EmptyState title="Loading matches…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState
|
||||
title="Match history unavailable"
|
||||
description="Retry to load this selection."
|
||||
/>
|
||||
{:else if data?.matches.length}
|
||||
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="table-scroll" bind:this={tableRegion} tabindex="0" role="region" aria-label="Recorded matches">
|
||||
<div
|
||||
class="table-scroll"
|
||||
bind:this={tableRegion}
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="Recorded matches"
|
||||
>
|
||||
<table>
|
||||
<caption class="sr-only">Matches sorted by {sorts.find(item => item.key === filters.sort)?.label.toLowerCase()}, {filters.direction === 'desc' ? 'highest or newest' : 'lowest or oldest'} first</caption>
|
||||
<thead><tr>
|
||||
<th scope="col" aria-sort={filters.sort === 'started' ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort('started')}>Started{filters.sort === 'started' ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
|
||||
<th scope="col">Match</th><th scope="col">Status</th>
|
||||
{#each sorts.filter(item => item.key !== 'started') as item}
|
||||
<th scope="col" class="numeric" aria-sort={filters.sort === item.key ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort(item.key)} title={item.key === 'players' ? 'Unique players observed throughout the match' : item.key === 'round' ? 'Final round, or highest observed round for unfinished matches' : 'Recorded duration; unknown durations sort last'} aria-describedby={item.key === 'players' ? 'match-player-help' : undefined}>{item.label}{filters.sort === item.key ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
|
||||
{/each}
|
||||
</tr></thead>
|
||||
<tbody>{#each data.matches as match (match.id)}
|
||||
<tr>
|
||||
<td><RelativeTime value={match.startedAt} /></td>
|
||||
<td class="match">
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
|
||||
{#if server === undefined}<div class="instance">
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
|
||||
</div>{/if}
|
||||
</td>
|
||||
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
|
||||
<td class="numeric">{match.uniquePlayers}</td>
|
||||
<td class="numeric"><span title={match.durationMs === null ? 'Duration was not recorded or the match has not completed.' : undefined}>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</span></td>
|
||||
<td class="numeric">{match.status === 'completed' ? match.finalRound ?? '—' : match.observedRound ?? '—'}{#if match.status !== 'completed'}<small>Highest observed</small>{/if}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
<caption class="sr-only"
|
||||
>Matches sorted by {sorts
|
||||
.find((item) => item.key === filters.sort)
|
||||
?.label.toLowerCase()}, {filters.direction === 'desc'
|
||||
? 'highest or newest'
|
||||
: 'lowest or oldest'} first</caption
|
||||
>
|
||||
<thead
|
||||
><tr>
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={filters.sort === 'started'
|
||||
? filters.direction === 'desc'
|
||||
? 'descending'
|
||||
: 'ascending'
|
||||
: 'none'}
|
||||
><button onclick={() => sort('started')}
|
||||
>Started{filters.sort === 'started'
|
||||
? filters.direction === 'desc'
|
||||
? ' ↓'
|
||||
: ' ↑'
|
||||
: ''}</button
|
||||
></th
|
||||
>
|
||||
<th scope="col">Match</th><th scope="col">Status</th>
|
||||
{#each sorts.filter((item) => item.key !== 'started') as item}
|
||||
<th
|
||||
scope="col"
|
||||
class="numeric"
|
||||
aria-sort={filters.sort === item.key
|
||||
? filters.direction === 'desc'
|
||||
? 'descending'
|
||||
: 'ascending'
|
||||
: 'none'}
|
||||
><button
|
||||
onclick={() => sort(item.key)}
|
||||
title={item.key === 'players'
|
||||
? 'Unique players observed throughout the match'
|
||||
: item.key === 'round'
|
||||
? 'Final round, or highest observed round for unfinished matches'
|
||||
: 'Recorded duration; unknown durations sort last'}
|
||||
aria-describedby={item.key === 'players'
|
||||
? 'match-player-help'
|
||||
: undefined}
|
||||
>{item.label}{filters.sort === item.key
|
||||
? filters.direction === 'desc'
|
||||
? ' ↓'
|
||||
: ' ↑'
|
||||
: ''}</button
|
||||
></th
|
||||
>
|
||||
{/each}
|
||||
</tr></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each data.matches as match (match.id)}
|
||||
<tr>
|
||||
<td><RelativeTime value={match.startedAt} /></td>
|
||||
<td class="match">
|
||||
{#if match.status === 'completed'}<a
|
||||
class="match-link"
|
||||
href={contextHref(`/matches/${match.id}`, $page.url)}
|
||||
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
|
||||
>{match.map} · Match #{match.id}</a
|
||||
>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"
|
||||
><RelativeTime value={match.startedAt} /> · {match.status ===
|
||||
'active'
|
||||
? 'In progress'
|
||||
: match.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Interrupted'}{match.durationMs !== null
|
||||
? ` · ${formatDuration(match.durationMs)}`
|
||||
: ''}</small
|
||||
>
|
||||
{#if server === undefined}<div class="instance">
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span
|
||||
>{#if match.server_deleted_at}<span>
|
||||
· Historical instance</span
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
</td>
|
||||
<td
|
||||
><span
|
||||
class="status"
|
||||
class:active={match.status === 'active'}
|
||||
class:interrupted={match.status === 'interrupted'}
|
||||
>{match.status === 'active'
|
||||
? 'In progress'
|
||||
: match.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Interrupted'}</span
|
||||
></td
|
||||
>
|
||||
<td class="numeric">{match.uniquePlayers}</td>
|
||||
<td class="numeric"
|
||||
><span
|
||||
title={match.durationMs === null
|
||||
? 'Duration was not recorded or the match has not completed.'
|
||||
: undefined}
|
||||
>{match.durationMs === null
|
||||
? '—'
|
||||
: formatDuration(match.durationMs)}</span
|
||||
></td
|
||||
>
|
||||
<td class="numeric"
|
||||
>{match.status === 'completed'
|
||||
? (match.finalRound ?? '—')
|
||||
: (match.observedRound ??
|
||||
'—')}{#if match.status !== 'completed'}<small
|
||||
>Highest observed</small
|
||||
>{/if}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination"><span>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit + data.matches.length} of {data.total} matches</span>
|
||||
{#if data.total > data.limit}<div><Button size="sm" disabled={data.page <= 1} href={matchHistoryHref($page.url, { page: String(data.page - 1) })}>Previous</Button><Button size="sm" disabled={data.page * data.limit >= data.total} href={matchHistoryHref($page.url, { page: String(data.page + 1) })}>Next</Button></div>{/if}
|
||||
<div class="pagination">
|
||||
<span
|
||||
>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit +
|
||||
data.matches.length} of {data.total} matches</span
|
||||
>
|
||||
{#if data.total > data.limit}<div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
href={matchHistoryHref($page.url, { page: String(data.page - 1) })}
|
||||
>Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={data.page * data.limit >= data.total}
|
||||
href={matchHistoryHref($page.url, { page: String(data.page + 1) })}
|
||||
>Next</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
{:else if !error}<EmptyState title={hasFilters ? 'No matches found' : 'No matches recorded yet'} description={hasFilters ? 'Try another game, map, instance, or date range.' : 'Matches appear when instances report gameplay.'}>
|
||||
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
<span id="match-player-help" class="sr-only">Players counts unique players observed throughout the match.</span>
|
||||
<details class="about"><summary>About match history</summary><p>Players counts unique players across all reports. A dash means the value is unavailable, not zero. Duration requires a recorded start and completed ending. Round shows the final round for completed matches and the highest observed round for unfinished matches. Open a completed match for exact timestamps and starting and ending player counts.</p></details>
|
||||
{:else if !error}<EmptyState
|
||||
title={hasFilters ? 'No matches found' : 'No matches recorded yet'}
|
||||
description={hasFilters
|
||||
? 'Try another game, map, instance, or date range.'
|
||||
: 'Matches appear when instances report gameplay.'}
|
||||
>
|
||||
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}
|
||||
>Clear filters</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
<span id="match-player-help" class="sr-only"
|
||||
>Players counts unique players observed throughout the match.</span
|
||||
>
|
||||
<details class="about">
|
||||
<summary>About match history</summary>
|
||||
<p>
|
||||
Players counts unique players across all reports. A dash means the value is unavailable,
|
||||
not zero. Duration requires a recorded start and completed ending. Round shows the final
|
||||
round for completed matches and the highest observed round for unfinished matches. Open
|
||||
a completed match for exact timestamps and starting and ending player counts.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.history { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
.filters { display: flex; align-items: flex-start; flex-wrap: wrap; gap: var(--space-3); }
|
||||
label, form { flex: 1; min-width: 145px; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pagination > div { display: flex; gap: var(--space-2); }
|
||||
.table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
th { color: var(--text-secondary); font-size: var(--text-xs); background: var(--surface-sunken); }
|
||||
th button, .text-button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; padding: 4px 0; }
|
||||
.text-button { color: var(--accent-primary-hover); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover, tbody tr:focus-within { background: var(--surface-raised); }
|
||||
.numeric { text-align: right; }
|
||||
.match { min-width: 200px; white-space: normal; }
|
||||
.match-link { color: var(--accent-primary-hover); text-decoration: none; }
|
||||
.match-link, .match-name { font-weight: var(--weight-medium); }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.status { color: var(--text-secondary); }
|
||||
.status.active { color: var(--accent-primary-hover); }
|
||||
.status.interrupted { color: var(--state-warning); }
|
||||
.about { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.about p { max-width: 90ch; line-height: 1.6; }
|
||||
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.mobile-filters, .mobile-match-context { display: none; }
|
||||
.history {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
label,
|
||||
form {
|
||||
flex: 1;
|
||||
min-width: 145px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.context,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pagination > div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.table-scroll {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.table-scroll:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
th button,
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.text-button {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
td {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tbody tr:hover,
|
||||
tbody tr:focus-within {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
.match {
|
||||
min-width: 200px;
|
||||
white-space: normal;
|
||||
}
|
||||
.match-link {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
.match-link,
|
||||
.match-name {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.instance,
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status.active {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.status.interrupted {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.about {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.about p {
|
||||
max-width: 90ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.mobile-filters,
|
||||
.mobile-match-context {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-match-context { display: block; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.filters > label, .filters > form { min-width: 0; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
th, td { padding: 10px 12px; }
|
||||
th:nth-child(1), td:nth-child(1) { display: none; }
|
||||
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
.match { min-width: 130px; }
|
||||
.desktop-filters {
|
||||
display: none;
|
||||
}
|
||||
.mobile-match-context {
|
||||
display: block;
|
||||
}
|
||||
.mobile-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.filters > label,
|
||||
.filters > form {
|
||||
min-width: 0;
|
||||
}
|
||||
.context {
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
th:nth-child(1),
|
||||
td:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
th:nth-child(3),
|
||||
td:nth-child(3),
|
||||
th:nth-child(5),
|
||||
td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
.match {
|
||||
min-width: 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,31 +5,90 @@
|
||||
import type { GameLibrary } from '$lib/game-files.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
|
||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
|
||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||
import {
|
||||
latestGameFiles,
|
||||
selectLatestGameFiles,
|
||||
type NativeHostSelectionStatus
|
||||
} from '$ui/native-game-files.js';
|
||||
let {
|
||||
games,
|
||||
selection = $bindable(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
|
||||
ready = $bindable(false),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
|
||||
status = $bindable(),
|
||||
disabled = false,
|
||||
network = false,
|
||||
compact = false,
|
||||
refreshVersion = 0,
|
||||
onchange
|
||||
}: {
|
||||
games: ZombieGame[];
|
||||
selection: NativeDeploymentSelection;
|
||||
ready?: boolean;
|
||||
status?: NativeHostSelectionStatus;
|
||||
disabled?: boolean;
|
||||
network?: boolean;
|
||||
compact?: boolean;
|
||||
refreshVersion?: number;
|
||||
onchange?: () => void;
|
||||
} = $props();
|
||||
let hosts = $state<Host[]>([]);
|
||||
let libraries = $state<GameLibrary[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let defaultRuntimeImage = $state('');
|
||||
let runtimeSelection: NativeDeploymentSelection | null = null;
|
||||
let requestVersion = 0;
|
||||
let activeController: AbortController | undefined;
|
||||
const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image)));
|
||||
const eligible = $derived(
|
||||
hosts.filter(
|
||||
(host) =>
|
||||
host.readiness === 'ready' &&
|
||||
host.inventory.runtimeImages?.includes(selection.image)
|
||||
)
|
||||
);
|
||||
const latest = $derived(latestGameFiles(libraries));
|
||||
|
||||
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); }
|
||||
$effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image)
|
||||
&& games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); });
|
||||
function cacheReady(game: ZombieGame) {
|
||||
return !!latest[game]?.caches.some(
|
||||
(cache) =>
|
||||
eligible.some((host) => host.id === cache.hostId) &&
|
||||
['ready', 'outdated'].includes(cache.status)
|
||||
);
|
||||
}
|
||||
$effect(() => {
|
||||
ready =
|
||||
!loading &&
|
||||
!error &&
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image) &&
|
||||
games.length > 0 &&
|
||||
games.every(
|
||||
(game) => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id
|
||||
);
|
||||
});
|
||||
$effect(() => {
|
||||
status = {
|
||||
loading,
|
||||
error,
|
||||
hasHosts: hosts.length > 0,
|
||||
filesReady: games.length > 0 && games.every(cacheReady),
|
||||
missingGames: games.filter(game => !latest[game]?.id),
|
||||
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
||||
.filter((value): value is string => !!value).sort().at(-1) ?? null,
|
||||
gameFiles: Object.fromEntries(games.map(game => [game, { ready: cacheReady(game),
|
||||
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null }])),
|
||||
missingGames: games.filter((game) => !latest[game]?.id),
|
||||
uploadedAt:
|
||||
games
|
||||
.map((game) => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
||||
.filter((value): value is string => !!value)
|
||||
.sort()
|
||||
.at(-1) ?? null,
|
||||
gameFiles: Object.fromEntries(
|
||||
games.map((game) => [
|
||||
game,
|
||||
{
|
||||
ready: cacheReady(game),
|
||||
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -52,14 +111,24 @@
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts', { signal: controller.signal }), fetch('/api/game-files', { signal: controller.signal })]);
|
||||
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
|
||||
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]);
|
||||
const [hostResponse, libraryResponse] = await Promise.all([
|
||||
fetch('/api/hosts', { signal: controller.signal }),
|
||||
fetch('/api/game-files', { signal: controller.signal })
|
||||
]);
|
||||
if (!hostResponse.ok || !libraryResponse.ok)
|
||||
throw new Error('Could not load hosts and game files.');
|
||||
const [hostBody, libraryBody] = await Promise.all([
|
||||
hostResponse.json(),
|
||||
libraryResponse.json()
|
||||
]);
|
||||
if (version !== requestVersion || controller.signal.aborted) return;
|
||||
hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
|
||||
hosts = hostBody.hosts;
|
||||
libraries = libraryBody.libraries;
|
||||
error = '';
|
||||
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
||||
} catch (cause) {
|
||||
if (version === requestVersion && !controller.signal.aborted) error = (cause as Error).message;
|
||||
if (version === requestVersion && !controller.signal.aborted)
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (version === requestVersion && !controller.signal.aborted) loading = false;
|
||||
@@ -67,7 +136,7 @@
|
||||
}
|
||||
$effect(() => {
|
||||
// Restart the poll for a parent refresh, sharing one initial request.
|
||||
refreshVersion;
|
||||
void refreshVersion;
|
||||
loading = true;
|
||||
const stop = pollVisible(load, 5000);
|
||||
return () => {
|
||||
@@ -79,14 +148,45 @@
|
||||
</script>
|
||||
|
||||
{#snippet runtimeFields()}
|
||||
<label>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input required bind:value={selection.image} disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label
|
||||
>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input
|
||||
required
|
||||
bind:value={selection.image}
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet networkFields()}
|
||||
<label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label
|
||||
>Local bind address<input
|
||||
bind:value={selection.bindAddress}
|
||||
placeholder="Host default"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Public / NAT address<input
|
||||
bind:value={selection.publicAddress}
|
||||
placeholder="Host default"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Public / NAT port<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={selection.publicPort}
|
||||
placeholder="Same as local port"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<p>Public mapping does not change the local Docker port reservation.</p>
|
||||
{/snippet}
|
||||
|
||||
@@ -95,39 +195,175 @@
|
||||
<summary>Advanced settings</summary>
|
||||
<div class="advanced-fields">
|
||||
{@render runtimeFields()}
|
||||
{#if network}<fieldset><legend>Network overrides</legend>{@render networkFields()}</fieldset>{/if}
|
||||
{#if network}<fieldset>
|
||||
<legend>Network overrides</legend>{@render networkFields()}
|
||||
</fieldset>{/if}
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<section class="selection" aria-label="Deployment host and game files">
|
||||
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
||||
<p>Hosts are assigned when provisioning starts. You can queue launches while hosts are busy or preparing; each server waits for an eligible host and available capacity.</p>
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest Black Ops I and Black Ops II files will be prepared automatically.</p>{/if}
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>Latest upload · <RelativeTime value={latest[game]?.publishedAt ?? latest[game]?.createdAt} /></p>
|
||||
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}</p>
|
||||
{:else}
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {gameTitle(game, true)} files</a> to prepare your hosts.</p>
|
||||
{/if}
|
||||
<section class="selection" aria-label="Deployment host and game files">
|
||||
<div class="title">
|
||||
<h3>Host and game files</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
onclick={() => {
|
||||
onchange?.();
|
||||
void load();
|
||||
}}>Refresh hosts</button
|
||||
>
|
||||
</div>
|
||||
{/each}{/if}
|
||||
<p>Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
|
||||
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
|
||||
</section>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
||||
<p>
|
||||
Hosts are assigned when provisioning starts. You can queue launches while hosts are busy
|
||||
or preparing; each server waits for an eligible host and available capacity.
|
||||
</p>
|
||||
{#if !loading && !hosts.length}<p>
|
||||
<a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and
|
||||
latest Black Ops I and Black Ops II files will be prepared automatically.
|
||||
</p>{/if}
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>
|
||||
Latest upload · <RelativeTime
|
||||
value={latest[game]?.publishedAt ?? latest[game]?.createdAt}
|
||||
/>
|
||||
</p>
|
||||
<p role="status">
|
||||
{cacheReady(game)
|
||||
? 'Latest files ready on an eligible host.'
|
||||
: 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
No files uploaded yet. <a
|
||||
href="/game-files"
|
||||
target="_blank"
|
||||
rel="noopener">Upload {gameTitle(game, true)} files</a
|
||||
> to prepare your hosts.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}{/if}
|
||||
<p>
|
||||
Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a
|
||||
href="/game-files"
|
||||
target="_blank"
|
||||
rel="noopener">Upload game files</a
|
||||
> when they change. Existing servers keep their current files.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Advanced: runtime override</summary>
|
||||
<p>
|
||||
The configured default runtime is prepared automatically. Custom images must first
|
||||
be prepared by an admin from the destination host page.
|
||||
</p>
|
||||
{@render runtimeFields()}
|
||||
</details>
|
||||
{#if network}<details>
|
||||
<summary>Network overrides</summary>{@render networkFields()}
|
||||
</details>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.game-files { display:grid; gap:5px; } .game-files strong { font-size: var(--text-sm); font-weight:600; }
|
||||
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
|
||||
.advanced summary { color:var(--text-secondary); }
|
||||
.advanced-fields { padding-top:2px; }
|
||||
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
|
||||
legend { padding:0; font-size: var(--text-sm); font-weight:600; }
|
||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size: var(--text-sm); } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size: var(--text-xs); line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size: var(--text-xs); } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size: var(--text-sm); } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
.game-files {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.game-files strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.advanced {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.advanced summary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.advanced-fields {
|
||||
padding-top: 2px;
|
||||
}
|
||||
fieldset {
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
legend {
|
||||
padding: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.selection {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
a,
|
||||
button {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
details label {
|
||||
margin-top: 14px;
|
||||
}
|
||||
details p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,12 +11,32 @@
|
||||
const groups = $derived(visibleNavigation(role));
|
||||
const attention = $derived.by(() => {
|
||||
if (!$fleet.receivedAt) return null;
|
||||
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
|
||||
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|
||||
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
|
||||
const hosts = $fleet.hostAttention ?? new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
|
||||
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
|
||||
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
|
||||
const statuses = $fleetServers.map((server) => ({
|
||||
server,
|
||||
state: serverHealth(server, $uiNow).state
|
||||
}));
|
||||
const servers = statuses.filter(
|
||||
({ server, state }) =>
|
||||
!['online', 'starting', 'provisioning'].includes(state) ||
|
||||
$fleet.operations.some(
|
||||
(operation) =>
|
||||
operation.server === server.name && operation.state === 'attention'
|
||||
)
|
||||
).length;
|
||||
const hosts =
|
||||
$fleet.hostAttention ??
|
||||
new Set(
|
||||
statuses
|
||||
.filter(({ server, state }) => server.host && state === 'host-offline')
|
||||
.map(({ server }) => server.host!.id)
|
||||
).size;
|
||||
const maintenance = $fleet.operations.filter(
|
||||
(operation) => operation.kind === 'maintenance' && operation.state === 'attention'
|
||||
).length;
|
||||
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -24,25 +44,86 @@
|
||||
{#each groups as group (group.id)}
|
||||
<section aria-labelledby={`${id}-${group.id}`}>
|
||||
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
|
||||
<ul>{#each group.items as item (item.href)}
|
||||
<li><a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}
|
||||
aria-label={attention && attention[item.href] > 0 ? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention` : undefined} onclick={() => onNavigate?.()}>
|
||||
<span>{item.label}</span>
|
||||
{#if attention && attention[item.href] > 0}<span class="attention" aria-hidden="true">{attention[item.href]}</span>{/if}
|
||||
</a></li>
|
||||
{/each}</ul>
|
||||
<ul>
|
||||
{#each group.items as item (item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={navigationItemActive(item, $page.url.pathname)
|
||||
? 'page'
|
||||
: undefined}
|
||||
aria-label={attention && attention[item.href] > 0
|
||||
? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention`
|
||||
: undefined}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{#if attention && attention[item.href] > 0}<span
|
||||
class="attention"
|
||||
aria-hidden="true">{attention[item.href]}</span
|
||||
>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
nav { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
h2 { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 32px; padding: 5px var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; }
|
||||
a:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||||
a[aria-current] { color: var(--text-primary); background: var(--surface-raised); box-shadow: inset 2px 0 var(--accent-primary); font-weight: var(--weight-semibold); }
|
||||
a:focus-visible { outline-offset: -2px; }
|
||||
.attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
||||
@media (max-width: 960px) { a { min-height: 44px; } }
|
||||
nav {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-label);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: 32px;
|
||||
padding: 5px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
a[aria-current] {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 2px 0 var(--accent-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
a:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.attention {
|
||||
color: var(--state-warning);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
a {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,72 +3,228 @@
|
||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { steps = [], events, active = false, showOverview = true, footer }: { steps?: OperationStep[]; events: ActivityEvent[]; active?: boolean; showOverview?: boolean; footer?: Snippet } = $props();
|
||||
let {
|
||||
steps = [],
|
||||
events,
|
||||
active = false,
|
||||
showOverview = true,
|
||||
footer
|
||||
}: {
|
||||
steps?: OperationStep[];
|
||||
events: ActivityEvent[];
|
||||
active?: boolean;
|
||||
showOverview?: boolean;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
let limit = $state(25);
|
||||
let query = $state('');
|
||||
let newestFirst = $state(true);
|
||||
const latest = $derived(events.at(-1));
|
||||
const matching = $derived(events.filter(event => event.message.toLowerCase().includes(query.toLowerCase())));
|
||||
const visible = $derived(newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit));
|
||||
const matching = $derived(
|
||||
events.filter((event) => event.message.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
const visible = $derived(
|
||||
newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if showOverview}
|
||||
<section>
|
||||
<h2>Operation flow</h2>
|
||||
<ol class="flow">
|
||||
{#each steps as step, index}
|
||||
<li class={step.state} aria-current={step.state === 'current' ? 'step' : undefined}>
|
||||
<span>{index + 1}</span>{step.label}
|
||||
<span class="sr-only">({step.state})</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span></p>{/if}
|
||||
<details>
|
||||
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
|
||||
{#if events.length}
|
||||
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
|
||||
<ol class="timeline">
|
||||
{#each visible as event (event.id)}
|
||||
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
|
||||
<section>
|
||||
<h2>Operation flow</h2>
|
||||
<ol class="flow">
|
||||
{#each steps as step, index}
|
||||
<li class={step.state} aria-current={step.state === 'current' ? 'step' : undefined}>
|
||||
<span>{index + 1}</span>{step.label}
|
||||
<span class="sr-only">({step.state})</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||
{#if matching.length > limit}<Button size="sm" onclick={() => limit += 25}>Show more activity ({matching.length - limit})</Button>{/if}
|
||||
{:else}
|
||||
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
|
||||
{/if}
|
||||
{@render footer?.()}
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
{#if showOverview && latest}<p class="latest">
|
||||
<strong>Latest update</strong>
|
||||
{latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span>
|
||||
</p>{/if}
|
||||
<details>
|
||||
<summary
|
||||
>Activity history · {events.length} events{active
|
||||
? ' · updates automatically'
|
||||
: ''}</summary
|
||||
>
|
||||
{#if events.length}
|
||||
<div class="tools">
|
||||
<label
|
||||
>Search activity<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
oninput={() => (limit = 25)}
|
||||
placeholder="Server, stage, or message"
|
||||
/></label
|
||||
><label
|
||||
>Order<select bind:value={newestFirst}
|
||||
><option value={true}>Newest first</option><option value={false}
|
||||
>Oldest first</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<ol class="timeline">
|
||||
{#each visible as event (event.id)}
|
||||
<li>
|
||||
<span class="time"><RelativeTime value={event.created_at} /></span>
|
||||
<p>{event.message}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||
{#if matching.length > limit}<Button size="sm" onclick={() => (limit += 25)}
|
||||
>Show more activity ({matching.length - limit})</Button
|
||||
>{/if}
|
||||
{:else}
|
||||
<p class="muted">
|
||||
This operation predates detailed activity tracking. Its recorded status and
|
||||
timestamps are shown on this page.
|
||||
</p>
|
||||
{/if}
|
||||
{@render footer?.()}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { display: grid; gap: 16px; }
|
||||
h2 { font-size: var(--text-md); margin: 0; }
|
||||
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
details[open] summary { margin-bottom: 16px; }
|
||||
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.latest strong { display: block; margin-bottom: 4px; }
|
||||
.latest .time { display: block; margin-top: 4px; }
|
||||
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
label:first-child { flex: 1; min-width: 180px; }
|
||||
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
|
||||
.time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow span { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.flow .done { color: var(--text-primary); }
|
||||
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.flow .failed { color: var(--state-danger); border-color: var(--state-danger); }
|
||||
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
|
||||
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
|
||||
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
|
||||
.timeline p { margin-top: 6px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-md);
|
||||
margin: 0;
|
||||
}
|
||||
details {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
details[open] summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.latest {
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.latest strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.latest .time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
label:first-child {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-control);
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.time,
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.flow li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.flow span {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.flow .done {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.flow .current {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.flow .failed {
|
||||
color: var(--state-danger);
|
||||
border-color: var(--state-danger);
|
||||
}
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0 0 0 18px;
|
||||
margin: 0;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
}
|
||||
.timeline li {
|
||||
position: relative;
|
||||
padding: 0 0 24px 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.timeline li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
top: 5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
.timeline p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,25 @@
|
||||
import { formatTimeOffset } from '$ui/duration.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
|
||||
let { value, start, missing = 'Not recorded' }: { value: Timestamp; start: Timestamp; missing?: string } = $props();
|
||||
let {
|
||||
value,
|
||||
start,
|
||||
missing = 'Not recorded'
|
||||
}: { value: Timestamp; start: Timestamp; missing?: string } = $props();
|
||||
const iso = $derived(timestampISO(value));
|
||||
const offset = $derived(formatTimeOffset(start, value));
|
||||
const absolute = $derived($timeFormat.dateTime(value));
|
||||
</script>
|
||||
|
||||
{#if iso}<time datetime={iso} title={absolute} aria-label={offset ? `${offset} from operation start · ${absolute}` : undefined}>{offset ?? absolute}</time>{:else}<span>{missing}</span>{/if}
|
||||
{#if iso}<time
|
||||
datetime={iso}
|
||||
title={absolute}
|
||||
aria-label={offset ? `${offset} from operation start · ${absolute}` : undefined}
|
||||
>{offset ?? absolute}</time
|
||||
>{:else}<span>{missing}</span>{/if}
|
||||
|
||||
<style>
|
||||
time { font-variant-numeric: tabular-nums; }
|
||||
time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,15 +25,20 @@
|
||||
<a class="row" href="/campaigns/{dep.id}">
|
||||
<div class="row-head">
|
||||
<span class="action">{dep.action || dep.id}</span>
|
||||
<span class="count">{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span>
|
||||
<span class="count"
|
||||
>{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each [...dep.servers.entries()] as [srv, meta] (srv)}
|
||||
<span
|
||||
class={stateClass(meta.state)}
|
||||
title={meta.error ?? (meta.players !== null ? `${meta.players}p` : meta.state)}
|
||||
title={meta.error ??
|
||||
(meta.players !== null ? `${meta.players}p` : meta.state)}
|
||||
>
|
||||
{srv}{meta.players !== null && meta.players > 0 ? ` ${meta.players}p` : ''}
|
||||
{srv}{meta.players !== null && meta.players > 0
|
||||
? ` ${meta.players}p`
|
||||
: ''}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -72,7 +77,10 @@
|
||||
margin: 0;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.rows { display: grid; gap: 10px; }
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
@@ -83,16 +91,31 @@
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.row:hover { border-color: var(--accent-primary); }
|
||||
.row:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.action { font-family: var(--font-body); font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.count { color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.action {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.count {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -116,7 +139,7 @@
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.chip-held {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
background: rgba(124, 192, 245, 0.1);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,111 @@
|
||||
import { type PlayerMapRecord, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
|
||||
let {
|
||||
maps,
|
||||
metric = 'highestRound',
|
||||
returnTo
|
||||
}: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
|
||||
const records = [
|
||||
{ key: 'highestRound', label: 'Best round' },
|
||||
{ key: 'bestKills', label: 'Best kills' },
|
||||
{ key: 'bestScore', label: 'Best score' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="map-cards">
|
||||
{#each maps as map (`${map.game}:${map.key}`)}
|
||||
<article>
|
||||
<header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
|
||||
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a><small aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}>Rank #{number.format(map.ranks[record.key])}</small></dd></div>{/each}</dl>
|
||||
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
|
||||
<header>
|
||||
<h3>
|
||||
<a
|
||||
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}
|
||||
>{map.name}</a
|
||||
>
|
||||
</h3>
|
||||
<span
|
||||
>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(
|
||||
map.gamesPlayed
|
||||
)}
|
||||
{map.gamesPlayed === 1 ? 'game' : 'games'}</span
|
||||
>
|
||||
</header>
|
||||
<dl>
|
||||
{#each records as record}<div>
|
||||
<dt>{record.label}</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={contextHref(
|
||||
`/matches/${map.records[record.key].matchId}`,
|
||||
returnTo ?? '/leaderboard'
|
||||
)}
|
||||
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
|
||||
title={map.records[record.key].endedAt
|
||||
? $timeFormat.dateTime(map.records[record.key].endedAt!)
|
||||
: 'Completed match'}>{number.format(map[record.key])}</a
|
||||
><small
|
||||
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
|
||||
>Rank #{number.format(map.ranks[record.key])}</small
|
||||
>
|
||||
</dd>
|
||||
</div>{/each}
|
||||
</dl>
|
||||
<p>
|
||||
{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed
|
||||
rounds
|
||||
</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: var(--text-md); }
|
||||
header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; }
|
||||
dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
|
||||
dd a { display: inline-flex; align-items: center; min-height: 44px; }
|
||||
dd small { display: block; color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-xs); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
article {
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
h3,
|
||||
p,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
header span,
|
||||
p,
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
dd {
|
||||
font-family: var(--font-numeric);
|
||||
font-size: var(--text-lg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
dd a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
dd small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,22 +7,75 @@
|
||||
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
</script>
|
||||
|
||||
<div class="match-cards">
|
||||
{#each matches as match (match.id)}
|
||||
<article>
|
||||
<h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
|
||||
<h3>
|
||||
{#if match.status === 'completed'}<a
|
||||
href={contextHref(`/matches/${match.id}`, returnTo ?? '/leaderboard')}
|
||||
>{match.mapName} · Match #{match.id}</a
|
||||
>{:else}{match.mapName} · Match #{match.id}{/if}
|
||||
</h3>
|
||||
<p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
|
||||
<p><RelativeTime value={match.startedAt} /> · {playerCompletionLabel(match)}</p>
|
||||
<dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Round</dt>
|
||||
<dd>{number.format(match.highestRound)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kills</dt>
|
||||
<dd>{number.format(match.kills)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revives</dt>
|
||||
<dd>{number.format(match.revives)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
|
||||
p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-3); }
|
||||
dd { margin-top: var(--space-1); font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
|
||||
article {
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
h3,
|
||||
p,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
h3 a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
p,
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
dd {
|
||||
margin-top: var(--space-1);
|
||||
font-family: var(--font-numeric);
|
||||
font-size: var(--text-lg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,12 +10,32 @@
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
|
||||
let { report, mapLabel, current = true, after = null, matchElapsed = null, activity }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null; matchElapsed?: string | null; activity?: string } = $props();
|
||||
let {
|
||||
report,
|
||||
mapLabel,
|
||||
current = true,
|
||||
after = null,
|
||||
matchElapsed = null,
|
||||
activity
|
||||
}: {
|
||||
report: GameTelemetry | undefined;
|
||||
mapLabel?: string;
|
||||
current?: boolean;
|
||||
after?: string | null;
|
||||
matchElapsed?: string | null;
|
||||
activity?: string;
|
||||
} = $props();
|
||||
const fresh = $derived(current && freshTelemetry(report, $uiNow, after));
|
||||
const timingNow = $derived(report && (report.event === 'game_end' || !fresh)
|
||||
? Date.parse(report.receivedAt) : $uiNow);
|
||||
const displayElapsed = $derived(fresh ? matchElapsed : report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt
|
||||
? elapsedSince(report.timing.server.gameStartedAt, timingNow) : null);
|
||||
const timingNow = $derived(
|
||||
report && (report.event === 'game_end' || !fresh) ? Date.parse(report.receivedAt) : $uiNow
|
||||
);
|
||||
const displayElapsed = $derived(
|
||||
fresh
|
||||
? matchElapsed
|
||||
: report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt
|
||||
? elapsedSince(report.timing.server.gameStartedAt, timingNow)
|
||||
: null
|
||||
);
|
||||
const columns = [
|
||||
{ key: 'score', label: 'Score' },
|
||||
{ key: 'kills', label: 'Kills' },
|
||||
@@ -23,19 +43,30 @@
|
||||
{ key: 'downs', label: 'Downs' },
|
||||
{ key: 'revives', label: 'Revives' }
|
||||
] as const;
|
||||
type StatKey = typeof columns[number]['key'];
|
||||
type StatKey = (typeof columns)[number]['key'];
|
||||
let sortBy = $state<StatKey>('score');
|
||||
let descending = $state(true);
|
||||
let expanded = $state<string[]>([]);
|
||||
const uid = $props.id();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const players = $derived(report?.event === 'game_start'
|
||||
? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null }))
|
||||
: report?.players ?? []);
|
||||
const sorted = $derived([...players].sort((a, b) => {
|
||||
const difference = (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
|
||||
return (descending ? -difference : difference) || a.name.localeCompare(b.name);
|
||||
}));
|
||||
const players = $derived(
|
||||
report?.event === 'game_start'
|
||||
? report.players.map((player) => ({
|
||||
...player,
|
||||
score: null,
|
||||
kills: null,
|
||||
headshots: null,
|
||||
downs: null,
|
||||
revives: null
|
||||
}))
|
||||
: (report?.players ?? [])
|
||||
);
|
||||
const sorted = $derived(
|
||||
[...players].sort((a, b) => {
|
||||
const difference = (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
|
||||
return (descending ? -difference : difference) || a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
function sort(key: StatKey) {
|
||||
descending = sortBy === key ? !descending : true;
|
||||
@@ -44,21 +75,36 @@
|
||||
</script>
|
||||
|
||||
{#if !report}
|
||||
<EmptyState title="Waiting for game data" description="The scoreboard will appear when this server reports a game start, round change, heartbeat, or game end." />
|
||||
<EmptyState
|
||||
title="Waiting for game data"
|
||||
description="The scoreboard will appear when this server reports a game start, round change, heartbeat, or game end."
|
||||
/>
|
||||
{:else}
|
||||
<header class="scoreboard-header">
|
||||
<div class="match">
|
||||
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}</span>
|
||||
<span class="eyebrow"
|
||||
>{report.event === 'game_end'
|
||||
? 'Final scoreboard'
|
||||
: fresh
|
||||
? 'Live scoreboard'
|
||||
: 'Last reported scoreboard'}</span
|
||||
>
|
||||
<div class="match-title">
|
||||
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
|
||||
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge>{/if}
|
||||
{#if displayElapsed}<span class="match-time">Match {displayElapsed}{fresh ? '' : ' at last report'}</span>{/if}
|
||||
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge
|
||||
>{/if}
|
||||
{#if displayElapsed}<span class="match-time"
|
||||
>Match {displayElapsed}{fresh ? '' : ' at last report'}</span
|
||||
>{/if}
|
||||
{#if report.event === 'game_end'}<Badge>Game ended</Badge>
|
||||
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-meta">
|
||||
<span>{players.length} {players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'}</span>
|
||||
<span
|
||||
>{players.length}
|
||||
{players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'}</span
|
||||
>
|
||||
{#if !fresh}<Badge variant="warning">Historical report</Badge>{/if}
|
||||
<time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}>
|
||||
Updated {elapsedSince(report.receivedAt, $uiNow)} ago
|
||||
@@ -67,20 +113,44 @@
|
||||
</header>
|
||||
|
||||
{#if !players.length}
|
||||
<EmptyState title="No players reported" description="This game report has an empty scoreboard." />
|
||||
<EmptyState
|
||||
title="No players reported"
|
||||
description="This game report has an empty scoreboard."
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player scoreboard" tabindex="0">
|
||||
<table>
|
||||
<caption class="sr-only">{report.event === 'game_end' ? 'Final' : 'Latest'} player scores for {report.server}</caption>
|
||||
<caption class="sr-only"
|
||||
>{report.event === 'game_end' ? 'Final' : 'Latest'} player scores for {report.server}</caption
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="player">Player</th>
|
||||
{#each columns as column (column.key)}
|
||||
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
|
||||
<button type="button" onclick={() => sort(column.key)}
|
||||
aria-label={`Sort by ${column.label.toLowerCase()}, ${sortBy === column.key && descending ? 'ascending' : 'descending'}`}>
|
||||
{column.label}<span class:active={sortBy === column.key} aria-hidden="true">{sortBy === column.key ? descending ? '↓' : '↑' : '↕'}</span>
|
||||
<th
|
||||
scope="col"
|
||||
class="stat"
|
||||
aria-sort={sortBy === column.key
|
||||
? descending
|
||||
? 'descending'
|
||||
: 'ascending'
|
||||
: 'none'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => sort(column.key)}
|
||||
aria-label={`Sort by ${column.label.toLowerCase()}, ${sortBy === column.key && descending ? 'ascending' : 'descending'}`}
|
||||
>
|
||||
{column.label}<span
|
||||
class:active={sortBy === column.key}
|
||||
aria-hidden="true"
|
||||
>{sortBy === column.key
|
||||
? descending
|
||||
? '↓'
|
||||
: '↑'
|
||||
: '↕'}</span
|
||||
>
|
||||
</button>
|
||||
</th>
|
||||
{/each}
|
||||
@@ -91,73 +161,301 @@
|
||||
{@const timing = report.timing?.players[player.guid]}
|
||||
<tr>
|
||||
<th scope="row" class="player">
|
||||
<a class="player-name" href={playerProfileHref(player.guid)}>{player.name}</a>
|
||||
<button class="player-details" aria-label={`Details for ${player.name}`} aria-expanded={expanded.includes(player.guid)} aria-controls={`${uid}-${player.guid}`} onclick={() => expanded = expanded.includes(player.guid) ? expanded.filter(guid => guid !== player.guid) : [...expanded, player.guid]}>Details</button>
|
||||
<a class="player-name" href={playerProfileHref(player.guid)}
|
||||
>{player.name}</a
|
||||
>
|
||||
<button
|
||||
class="player-details"
|
||||
aria-label={`Details for ${player.name}`}
|
||||
aria-expanded={expanded.includes(player.guid)}
|
||||
aria-controls={`${uid}-${player.guid}`}
|
||||
onclick={() =>
|
||||
(expanded = expanded.includes(player.guid)
|
||||
? expanded.filter((guid) => guid !== player.guid)
|
||||
: [...expanded, player.guid])}>Details</button
|
||||
>
|
||||
</th>
|
||||
{#each columns as column (column.key)}
|
||||
<td class="stat" class:score={column.key === 'score'}>
|
||||
{#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span>
|
||||
{#if player[column.key] === null}<span
|
||||
class="unreported"
|
||||
aria-label="Not yet reported">—</span
|
||||
>
|
||||
{:else}{number.format(player[column.key]!)}{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr id={`${uid}-${player.guid}`} hidden={!expanded.includes(player.guid)}><td colspan="6"><div class="player-metadata"><div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div><span>Time on instance: {timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</span><span>Time in match: {timing ? elapsedSince(timing.matchJoinedAt, timingNow) : '—'}</span><span>Joined round: {timing?.roundStarted ?? '—'}</span></div></td></tr>
|
||||
<tr id={`${uid}-${player.guid}`} hidden={!expanded.includes(player.guid)}
|
||||
><td colspan="6"
|
||||
><div class="player-metadata">
|
||||
<div class="guid">
|
||||
<span>GUID</span><CopyValue
|
||||
value={player.guid}
|
||||
label={`GUID for ${player.name}`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
>Time on instance: {timing
|
||||
? elapsedSince(timing.serverJoinedAt, timingNow)
|
||||
: '—'}</span
|
||||
><span
|
||||
>Time in match: {timing
|
||||
? elapsedSince(timing.matchJoinedAt, timingNow)
|
||||
: '—'}</span
|
||||
><span>Joined round: {timing?.roundStarted ?? '—'}</span>
|
||||
</div></td
|
||||
></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="scoreboard-footer">
|
||||
<details><summary>About these stats</summary><p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p><p class="timing-note">Times and joining rounds are estimated from player reports. Time on instance carries across matches; ended or stale reports freeze at the last update.</p>{#if activity}<p class="timing-note">{activity}</p>{/if}</details>
|
||||
<details>
|
||||
<summary>About these stats</summary>
|
||||
<p class="note">
|
||||
{report.event === 'game_start'
|
||||
? 'Player stats arrive with the next round report or heartbeat.'
|
||||
: report.event === 'game_end'
|
||||
? 'Final stats from this game. The scoreboard resets when the next game starts.'
|
||||
: 'Stats update at each round change, heartbeat, and game end.'}
|
||||
</p>
|
||||
<p class="timing-note">
|
||||
Times and joining rounds are estimated from player reports. Time on instance carries
|
||||
across matches; ended or stale reports freeze at the last update.
|
||||
</p>
|
||||
{#if activity}<p class="timing-note">{activity}</p>{/if}
|
||||
</details>
|
||||
<a href="/leaderboard">View lifetime leaderboard →</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.player-metadata { display: flex; flex-wrap: wrap; gap: 12px 24px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.match-time { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
button.player-details { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.scoreboard-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); margin-bottom: var(--space-3); }
|
||||
.match { display: grid; gap: var(--space-2); min-width: 0; }
|
||||
.eyebrow { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; color: var(--text-tertiary); }
|
||||
.match-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.report-meta { display: grid; gap: var(--space-1); text-align: right; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
time { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
table { width: 100%; min-width: 560px; border-collapse: separate; border-spacing: 0; }
|
||||
th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
thead th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-semibold); background: var(--surface-sunken); }
|
||||
.player { text-align: left; position: sticky; left: 0; z-index: 1; }
|
||||
thead .player { min-width: 160px; }
|
||||
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
|
||||
.player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.player-name { text-decoration: none; }
|
||||
.player-name:hover { color: var(--accent-primary-hover); text-decoration: underline; }
|
||||
.player-name:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
|
||||
.timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font: var(--text-sm) var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.score { color: var(--text-primary); font-weight: var(--weight-semibold); }
|
||||
.unreported { color: var(--text-tertiary); }
|
||||
tbody tr:last-child > * { border-bottom: none; }
|
||||
tbody tr:hover > * { background: var(--surface-raised); }
|
||||
button { display: inline-flex; align-items: center; justify-content: flex-end; gap: var(--space-1); border: 0; background: transparent; padding: var(--space-1) 0; color: inherit; font: inherit; cursor: pointer; }
|
||||
button span { color: var(--text-tertiary); }
|
||||
button span.active, button:hover { color: var(--accent-primary-hover); }
|
||||
button:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
|
||||
.scoreboard-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-3); font-size: var(--text-xs); }
|
||||
.scoreboard-footer a { text-decoration: none; }
|
||||
.scoreboard-footer a:hover { text-decoration: underline; }
|
||||
.note { margin: 0; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.player-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.match-time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
button.player-details {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scoreboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.match {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.match-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.report-meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
text-align: right;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.table-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.table-scroll:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 560px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
thead th {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-semibold);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.player {
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
thead .player {
|
||||
min-width: 160px;
|
||||
}
|
||||
tbody .player {
|
||||
background: var(--surface);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.player-name {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.player-name {
|
||||
text-decoration: none;
|
||||
}
|
||||
.player-name:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.player-name:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.timing-note {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.guid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.stat {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.stat {
|
||||
font: var(--text-sm) var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.score {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.unreported {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
tbody tr:last-child > * {
|
||||
border-bottom: none;
|
||||
}
|
||||
tbody tr:hover > * {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: var(--space-1) 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
button span.active,
|
||||
button:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.scoreboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.scoreboard-footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.scoreboard-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.report-meta { text-align: left; }
|
||||
th, td { padding: var(--space-3); }
|
||||
thead .player { min-width: 150px; }
|
||||
.player-name { max-width: 220px; }
|
||||
.report-meta {
|
||||
text-align: left;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
thead .player {
|
||||
min-width: 150px;
|
||||
}
|
||||
.player-name {
|
||||
max-width: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,22 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
|
||||
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
|
||||
let {
|
||||
pluginId,
|
||||
targetPath,
|
||||
disabled = false,
|
||||
busy = $bindable(false),
|
||||
onimport
|
||||
}: {
|
||||
pluginId?: number;
|
||||
targetPath?: string;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
onimport?: (result: {
|
||||
pluginId: number;
|
||||
betaUrl: string;
|
||||
alreadyImported: boolean;
|
||||
}) => void | Promise<void>;
|
||||
} = $props();
|
||||
let url = $state('');
|
||||
let name = $state('');
|
||||
@@ -18,55 +32,89 @@
|
||||
let update = $state<PluginUpdate | null>(null);
|
||||
let checking = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
||||
let success = $state<{
|
||||
pluginId: number;
|
||||
betaUrl: string;
|
||||
sha256: string;
|
||||
alreadyImported: boolean;
|
||||
} | null>(null);
|
||||
let active = true;
|
||||
onDestroy(() => { active = false; });
|
||||
onDestroy(() => {
|
||||
active = false;
|
||||
});
|
||||
|
||||
function showRelease(value: GithubPluginRelease) {
|
||||
release = value;
|
||||
const preferred = update?.source?.asset_name ?? targetPath?.split('/').pop();
|
||||
assetId = String(value.assets.find(a => a.name === preferred)?.id ?? (value.assets.length === 1 ? value.assets[0].id : ''));
|
||||
assetId = String(
|
||||
value.assets.find((a) => a.name === preferred)?.id ??
|
||||
(value.assets.length === 1 ? value.assets[0].id : '')
|
||||
);
|
||||
if (!pluginId && !name) name = value.repository.split('/')[1];
|
||||
url = value.url;
|
||||
error = ''; success = null;
|
||||
error = '';
|
||||
success = null;
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
if (!pluginId || checking) return;
|
||||
checking = true; error = '';
|
||||
checking = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/plugins/updates?pluginId=${pluginId}`);
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not check for updates.');
|
||||
update = body.updates[0];
|
||||
if (!url && update?.source) url = `https://github.com/${update.source.repository}`;
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { checking = false; }
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
onMount(() => { void checkUpdates(); });
|
||||
onMount(() => {
|
||||
void checkUpdates();
|
||||
});
|
||||
|
||||
async function preview(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || disabled) return;
|
||||
busy = true; error = ''; release = null; success = null;
|
||||
busy = true;
|
||||
error = '';
|
||||
release = null;
|
||||
success = null;
|
||||
try {
|
||||
const response = await fetch(`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`);
|
||||
const response = await fetch(
|
||||
`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`
|
||||
);
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load release.');
|
||||
showRelease(body.release);
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importRelease(startBeta: boolean) {
|
||||
if (!release || !assetId || busy || disabled) return;
|
||||
const startPath = window.location.pathname;
|
||||
busy = true; error = ''; success = null;
|
||||
busy = true;
|
||||
error = '';
|
||||
success = null;
|
||||
try {
|
||||
const response = await fetch('/api/plugins/github/import', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: release.url, assetId: Number(assetId), pluginId,
|
||||
name: name.trim(), scopeType: game ? 'game' : null, scopeValue: game || null })
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: release.url,
|
||||
assetId: Number(assetId),
|
||||
pluginId,
|
||||
name: name.trim(),
|
||||
scopeType: game ? 'game' : null,
|
||||
scopeValue: game || null
|
||||
})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
||||
@@ -75,29 +123,57 @@
|
||||
await onimport?.(body);
|
||||
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
|
||||
else if (pluginId) await checkUpdates();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card padding="lg">
|
||||
<section class="github" aria-label="GitHub plugin releases">
|
||||
<div class="heading">
|
||||
<div><h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2><p>Choose a release and DLL, then test it with your fleet.</p></div>
|
||||
{#if pluginId}<Button size="sm" onclick={checkUpdates} loading={checking} disabled={busy}>Check for updates</Button>{/if}
|
||||
<div>
|
||||
<h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2>
|
||||
<p>Choose a release and DLL, then test it with your fleet.</p>
|
||||
</div>
|
||||
{#if pluginId}<Button
|
||||
size="sm"
|
||||
onclick={checkUpdates}
|
||||
loading={checking}
|
||||
disabled={busy}>Check for updates</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if checking}<p role="status">Checking GitHub…</p>{/if}
|
||||
{#if update?.source}
|
||||
<p>Following <a href={`https://github.com/${update.source.repository}`} target="_blank" rel="noreferrer">{update.source.repository}</a> · {update.source.asset_name}</p>
|
||||
<p>
|
||||
Following <a
|
||||
href={`https://github.com/${update.source.repository}`}
|
||||
target="_blank"
|
||||
rel="noreferrer">{update.source.repository}</a
|
||||
>
|
||||
· {update.source.asset_name}
|
||||
</p>
|
||||
{/if}
|
||||
{#if update?.status === 'available' && update.release}
|
||||
<div class="update">
|
||||
<Badge variant="info">New release: {update.release.tag}</Badge>
|
||||
<Button size="sm" disabled={busy || disabled} onclick={() => showRelease(update!.release!)}>Review update</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || disabled}
|
||||
onclick={() => showRelease(update!.release!)}>Review update</Button
|
||||
>
|
||||
</div>
|
||||
{:else if update?.status === 'imported' && update.release}
|
||||
<div class="update"><span>{update.release.tag} is already imported and available for testing.</span>
|
||||
<Button size="sm" href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`} disabled={disabled}>Start beta</Button></div>
|
||||
<div class="update">
|
||||
<span>{update.release.tag} is already imported and available for testing.</span>
|
||||
<Button
|
||||
size="sm"
|
||||
href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`}
|
||||
{disabled}>Start beta</Button
|
||||
>
|
||||
</div>
|
||||
{:else if update?.status === 'current'}
|
||||
<p role="status">The latest stable release is promoted.</p>
|
||||
{:else if update?.status === 'ahead'}
|
||||
@@ -108,44 +184,105 @@
|
||||
<p>Import a release below to track updates for this plugin.</p>
|
||||
{/if}
|
||||
<form onsubmit={preview} class="lookup">
|
||||
<label>Repository or release URL
|
||||
<input type="url" bind:value={url} oninput={() => { release = null; success = null; }} required
|
||||
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0" disabled={busy || disabled} />
|
||||
<label
|
||||
>Repository or release URL
|
||||
<input
|
||||
type="url"
|
||||
bind:value={url}
|
||||
oninput={() => {
|
||||
release = null;
|
||||
success = null;
|
||||
}}
|
||||
required
|
||||
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
|
||||
disabled={busy || disabled}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" loading={busy && !release} disabled={busy || disabled || !url.trim()}>Preview release</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={busy && !release}
|
||||
disabled={busy || disabled || !url.trim()}>Preview release</Button
|
||||
>
|
||||
</form>
|
||||
{#if disabled}<p>Finish the running beta before importing a release.</p>{/if}
|
||||
{#if release}
|
||||
<div class="preview">
|
||||
<div class="heading"><h3>{release.name || release.tag}</h3><Badge variant={release.prerelease ? 'warning' : 'neutral'}>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge></div>
|
||||
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {$timeFormat.date(release.publishedAt)}</p>
|
||||
<details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
|
||||
<div class="heading">
|
||||
<h3>{release.name || release.tag}</h3>
|
||||
<Badge variant={release.prerelease ? 'warning' : 'neutral'}
|
||||
>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
<a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> ·
|
||||
Published {$timeFormat.date(release.publishedAt)}
|
||||
</p>
|
||||
<details open>
|
||||
<summary>Release notes</summary>
|
||||
<pre>{release.notes || 'No release notes provided.'}</pre>
|
||||
</details>
|
||||
{#if release.assets.length === 0}
|
||||
<p>No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.</p>
|
||||
<p>
|
||||
No importable DLL assets found. Choose another release or upload a DLL
|
||||
manually. Direct DLL assets up to 64 MB are supported.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<label>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
|
||||
<option value="" disabled>Choose a DLL…</option>
|
||||
{#each release.assets as asset}<option value={String(asset.id)}>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option>{/each}
|
||||
</select></label>
|
||||
<label
|
||||
>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
|
||||
<option value="" disabled>Choose a DLL…</option>
|
||||
{#each release.assets as asset}<option value={String(asset.id)}
|
||||
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
{#if !pluginId}
|
||||
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label
|
||||
>Plugin name<input
|
||||
bind:value={name}
|
||||
disabled={busy}
|
||||
placeholder="t6-gsc-utils"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Deploy scope<select bind:value={game} disabled={busy}
|
||||
><option value="">Unscoped — select servers in a beta</option
|
||||
><option value="t5">Black Ops I</option><option value="t6"
|
||||
>Black Ops II</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
|
||||
<div class="actions">
|
||||
<Button variant="primary" disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} loading={busy} onclick={() => importRelease(true)}>Import and test</Button>
|
||||
<Button disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} onclick={() => importRelease(false)}>Import only</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
|
||||
loading={busy}
|
||||
onclick={() => importRelease(true)}>Import and test</Button
|
||||
>
|
||||
<Button
|
||||
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
|
||||
onclick={() => importRelease(false)}>Import only</Button
|
||||
>
|
||||
</div>
|
||||
<p>Imports are saved for testing. Choose servers and a test plan before starting a beta.</p>
|
||||
<p>
|
||||
Imports are saved for testing. Choose servers and a test plan before
|
||||
starting a beta.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="success" role="status">
|
||||
<strong>{success.alreadyImported ? 'This binary is already imported.' : 'Release imported.'}</strong>
|
||||
<a href={`/plugins/${success.pluginId}`}>View plugin</a> · <a href={success.betaUrl}>Start beta</a>
|
||||
<strong
|
||||
>{success.alreadyImported
|
||||
? 'This binary is already imported.'
|
||||
: 'Release imported.'}</strong
|
||||
>
|
||||
<a href={`/plugins/${success.pluginId}`}>View plugin</a> ·
|
||||
<a href={success.betaUrl}>Start beta</a>
|
||||
<span class="checksum">SHA-256: {success.sha256}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -154,27 +291,121 @@
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.github, .preview { display: grid; gap: var(--space-4); }
|
||||
.heading, .actions, .update { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.actions { justify-content: flex-start; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||
h3 { font-size: var(--text-md); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
.heading p { margin-top: var(--space-2); }
|
||||
.lookup { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.lookup label { flex: 1 1 300px; }
|
||||
label { display: grid; gap: var(--space-2); font-size: var(--text-sm); }
|
||||
input, select { width: 100%; min-width: 0; padding: 10px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font: inherit; }
|
||||
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.fields { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
|
||||
.preview { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow-y: auto; font: inherit; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
.error { color: var(--state-danger); }
|
||||
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
|
||||
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
.github,
|
||||
.preview {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.heading,
|
||||
.actions,
|
||||
.update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading p {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.lookup {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lookup label {
|
||||
flex: 1 1 300px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||
}
|
||||
.preview {
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.success {
|
||||
display: block;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.success strong,
|
||||
.checksum {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.checksum {
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--text-xs) var(--font-mono);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
select {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,41 +8,91 @@
|
||||
let checking = $state(false);
|
||||
let checked = $state(false);
|
||||
let error = $state('');
|
||||
const tracked = $derived(updates.filter(u => u.status !== 'untracked'));
|
||||
const tracked = $derived(updates.filter((u) => u.status !== 'untracked'));
|
||||
async function check() {
|
||||
if (checking) return;
|
||||
checking = true; error = '';
|
||||
checking = true;
|
||||
error = '';
|
||||
try {
|
||||
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; onchecked?.(updates);
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { checking = false; }
|
||||
updates = body.updates;
|
||||
checked = true;
|
||||
onchecked?.(updates);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
onMount(() => { void check(); });
|
||||
onMount(() => {
|
||||
void check();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="heading"><h2>Release updates</h2><Button size="sm" loading={checking} onclick={check}>Check for updates</Button></div>
|
||||
<div class="heading">
|
||||
<h2>Release updates</h2>
|
||||
<Button size="sm" loading={checking} onclick={check}>Check for updates</Button>
|
||||
</div>
|
||||
<p>Latest stable GitHub releases. Checks are cached for five minutes.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if checked && !tracked.length}<p>Import a plugin from GitHub to start tracking its releases.</p>{/if}
|
||||
{#if checked && !tracked.length}<p>
|
||||
Import a plugin from GitHub to start tracking its releases.
|
||||
</p>{/if}
|
||||
{#each tracked as update (update.pluginId)}
|
||||
<div class="row">
|
||||
<div><a href={`/plugins/${update.pluginId}`}>{update.name}</a>
|
||||
<p>{update.status === 'available' ? `${update.release?.tag} available` : update.status === 'imported' ? `${update.release?.tag} imported — awaiting promotion` : update.status === 'current' ? `${update.release?.tag} promoted` : update.status === 'ahead' ? 'Ahead of the latest stable release' : update.error}</p>
|
||||
<div>
|
||||
<a href={`/plugins/${update.pluginId}`}>{update.name}</a>
|
||||
<p>
|
||||
{update.status === 'available'
|
||||
? `${update.release?.tag} available`
|
||||
: update.status === 'imported'
|
||||
? `${update.release?.tag} imported — awaiting promotion`
|
||||
: update.status === 'current'
|
||||
? `${update.release?.tag} promoted`
|
||||
: update.status === 'ahead'
|
||||
? 'Ahead of the latest stable release'
|
||||
: update.error}
|
||||
</p>
|
||||
</div>
|
||||
{#if update.status === 'available'}<Button size="sm" href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button>{/if}
|
||||
{#if update.status === 'available'}<Button
|
||||
size="sm"
|
||||
href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.heading, .row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); margin: var(--space-2) 0 0; }
|
||||
.row { padding-top: var(--space-3); margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.error { color: var(--state-danger); }
|
||||
.heading,
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
.row {
|
||||
padding-top: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
let requestNumber = 0;
|
||||
let controller: AbortController | null = null;
|
||||
let disposed = false;
|
||||
const newEvents = $derived(baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal));
|
||||
const newEvents = $derived(
|
||||
baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal)
|
||||
);
|
||||
|
||||
async function load(more = false) {
|
||||
if (more && (loading || cursor === null)) return;
|
||||
@@ -33,7 +35,8 @@
|
||||
const request = ++requestNumber;
|
||||
const id = detail.deployment.id;
|
||||
const current = () => !disposed && request === requestNumber && detail.deployment.id === id;
|
||||
loading = true; error = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
const requestedQuery = more ? appliedQuery : query;
|
||||
const requestedOrder = more ? appliedOrder : order;
|
||||
const params = new URLSearchParams({ order: requestedOrder, limit: '25' });
|
||||
@@ -43,18 +46,44 @@
|
||||
params.set('watermark', String(watermark));
|
||||
}
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => { timedOut = true; currentController.abort(); }, 15_000);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
currentController.abort();
|
||||
}, 15_000);
|
||||
try {
|
||||
const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/events?${params}`, { signal: currentController.signal, cache: 'no-store' });
|
||||
const response = await fetch(
|
||||
`/api/provisioning/${encodeURIComponent(id)}/events?${params}`,
|
||||
{ signal: currentController.signal, cache: 'no-store' }
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not load activity history.');
|
||||
const body: unknown = await response.json();
|
||||
if (!isProvisioningEventPage(body)) throw new Error('The activity response was incomplete.');
|
||||
if (!isProvisioningEventPage(body))
|
||||
throw new Error('The activity response was incomplete.');
|
||||
if (!current()) return;
|
||||
entries = more ? [...entries, ...body.events.filter(event => !entries.some(previous => previous.id === event.id))] : body.events;
|
||||
total = body.total; watermark = body.watermark; cursor = body.nextCursor; loaded = true;
|
||||
if (!more) { baselineTotal = body.totalEvents; appliedQuery = requestedQuery; appliedOrder = requestedOrder; }
|
||||
entries = more
|
||||
? [
|
||||
...entries,
|
||||
...body.events.filter(
|
||||
(event) => !entries.some((previous) => previous.id === event.id)
|
||||
)
|
||||
]
|
||||
: body.events;
|
||||
total = body.total;
|
||||
watermark = body.watermark;
|
||||
cursor = body.nextCursor;
|
||||
loaded = true;
|
||||
if (!more) {
|
||||
baselineTotal = body.totalEvents;
|
||||
appliedQuery = requestedQuery;
|
||||
appliedOrder = requestedOrder;
|
||||
}
|
||||
} catch (reason) {
|
||||
if (current() && (!currentController.signal.aborted || timedOut)) error = timedOut ? 'Activity refresh timed out. Try again.' : reason instanceof Error ? reason.message : 'Could not load activity history.';
|
||||
if (current() && (!currentController.signal.aborted || timedOut))
|
||||
error = timedOut
|
||||
? 'Activity refresh timed out. Try again.'
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: 'Could not load activity history.';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (current()) loading = false;
|
||||
@@ -65,55 +94,247 @@
|
||||
if (expanded && !loaded && !loading) void load();
|
||||
}
|
||||
function jumpToLatest() {
|
||||
query = appliedQuery; order = 'newest';
|
||||
query = appliedQuery;
|
||||
order = 'newest';
|
||||
void load();
|
||||
}
|
||||
onDestroy(() => { disposed = true; requestNumber++; controller?.abort(); });
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
requestNumber++;
|
||||
controller?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section aria-labelledby="activity-title" class="activity">
|
||||
<h2 id="activity-title">Activity log</h2>
|
||||
<details class="history" ontoggle={toggle}>
|
||||
<summary>Full activity history <span>{countLabel(detail.activity.total, 'event')}</span></summary>
|
||||
<summary
|
||||
>Full activity history <span>{countLabel(detail.activity.total, 'event')}</span
|
||||
></summary
|
||||
>
|
||||
<div class="history-content">
|
||||
{#if newEvents > 0}<div class="new-events" role="status"><span>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button></div>{/if}
|
||||
<form class="tools" onsubmit={event => { event.preventDefault(); void load(); }}>
|
||||
<label class="search">Search activity<input type="search" bind:value={query} placeholder="Find a stage or message" /></label>
|
||||
<label>Order<select bind:value={order} onchange={() => { void load(); }}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></select></label>
|
||||
{#if newEvents > 0}<div class="new-events" role="status">
|
||||
<span
|
||||
>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span
|
||||
><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button>
|
||||
</div>{/if}
|
||||
<form
|
||||
class="tools"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label class="search"
|
||||
>Search activity<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Find a stage or message"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Order<select
|
||||
bind:value={order}
|
||||
onchange={() => {
|
||||
void load();
|
||||
}}
|
||||
><option value="newest">Newest first</option><option value="oldest"
|
||||
>Oldest first</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<Button type="submit" disabled={loading}>Search</Button>
|
||||
</form>
|
||||
{#if error}<div class="history-error" role="alert"><p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p><Button size="sm" onclick={() => load()}>Retry history</Button></div>{/if}
|
||||
{#if loaded}<p class="muted">{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery ? ` · Matching “${appliedQuery}”` : ' · All messages'}</p>{/if}
|
||||
{#if error}<div class="history-error" role="alert">
|
||||
<p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p>
|
||||
<Button size="sm" onclick={() => load()}>Retry history</Button>
|
||||
</div>{/if}
|
||||
{#if loaded}<p class="muted">
|
||||
{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery
|
||||
? ` · Matching “${appliedQuery}”`
|
||||
: ' · All messages'}
|
||||
</p>{/if}
|
||||
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
|
||||
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
|
||||
{:else if loaded && !entries.length}<p class="empty">
|
||||
{appliedQuery
|
||||
? 'No activity matches this search.'
|
||||
: 'No detailed activity recorded.'}
|
||||
</p>
|
||||
{:else}<ol class="timeline">
|
||||
{#each entries as event (event.id)}<li><time class="time" datetime={timestampISO(event.created_at)}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
||||
</ol>{/if}
|
||||
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
|
||||
{#each entries as event (event.id)}<li>
|
||||
<time class="time" datetime={timestampISO(event.created_at)}
|
||||
>{$timeFormat.dateTime(event.created_at)}</time
|
||||
>
|
||||
<p>{event.message}</p>
|
||||
</li>{/each}
|
||||
</ol>{/if}
|
||||
{#if loaded}<div class="footer">
|
||||
<span>Showing {entries.length} of {total} matching events</span
|
||||
>{#if cursor !== null}<Button
|
||||
size="sm"
|
||||
{loading}
|
||||
disabled={loading}
|
||||
onclick={() => load(true)}>Show more activity</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.activity { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
||||
.muted, .time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
|
||||
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
|
||||
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
|
||||
.timeline li::before { content: ''; position: absolute; top: 6px; left: -24px; width: 7px; height: 7px; background: var(--border-control); border-radius: var(--radius-pill); }
|
||||
.timeline p { margin-top: var(--space-1); }
|
||||
.history { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
summary { cursor: pointer; padding: var(--space-5); font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
summary span { margin-left: var(--space-2); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
||||
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
|
||||
.history-content { padding: 0 var(--space-5) var(--space-5); display: grid; gap: var(--space-5); }
|
||||
.tools { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }.tools label { display: grid; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }.search { flex: 1 1 160px; }
|
||||
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 10px; font: inherit; min-height: 40px; width: 100%; }
|
||||
input:focus-visible, select:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
|
||||
.new-events { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }.new-events span { flex: 1; }
|
||||
.footer { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; line-height: 1.6; }
|
||||
.history-error { display: grid; justify-items: start; gap: var(--space-3); color: var(--state-danger); }
|
||||
@media (max-width: 600px) { summary { padding: var(--space-4); }.history-content { padding: 0 var(--space-4) var(--space-4); }.tools { align-items: stretch; }.tools .search { flex-basis: 100%; } }
|
||||
.activity {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.muted,
|
||||
.time,
|
||||
.footer {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0 0 0 var(--space-1);
|
||||
padding: 0 0 0 var(--space-5);
|
||||
border-left: 1px solid var(--border-strong);
|
||||
}
|
||||
.timeline li {
|
||||
padding: 0 0 var(--space-5);
|
||||
position: relative;
|
||||
}
|
||||
.timeline li:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.timeline li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -24px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--border-control);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.timeline p {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.history {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
padding: var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
summary span {
|
||||
margin-left: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: -3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.history-content {
|
||||
padding: 0 var(--space-5) var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.tools label {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
min-height: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.new-events {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.new-events span {
|
||||
flex: 1;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.history-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-3);
|
||||
color: var(--state-danger);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
summary {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.history-content {
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
.tools {
|
||||
align-items: stretch;
|
||||
}
|
||||
.tools .search {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,86 +1,245 @@
|
||||
<script lang="ts">
|
||||
import OperationTime from '$ui/components/OperationTime.svelte';
|
||||
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
|
||||
import { activeProvisioningStates, provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
|
||||
import {
|
||||
activeProvisioningStates,
|
||||
provisioningCheckValue,
|
||||
provisioningGroups,
|
||||
progressIcons,
|
||||
progressStateLabels
|
||||
} from '$ui/provisioning-presentation.js';
|
||||
|
||||
let { detail, hostHistoryHref }: { detail: ProvisioningDetail; hostHistoryHref?: string } = $props();
|
||||
let { detail, hostHistoryHref }: { detail: ProvisioningDetail; hostHistoryHref?: string } =
|
||||
$props();
|
||||
const groups = $derived(provisioningGroups(detail));
|
||||
const progress = $derived(detail.progress);
|
||||
const active = $derived(activeProvisioningStates.includes(detail.deployment.state));
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
|
||||
const sources = { agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
|
||||
const evidenceLabels = {
|
||||
pass: 'Passed',
|
||||
waiting: 'Waiting',
|
||||
fail: 'Failed',
|
||||
unknown: 'Unknown'
|
||||
};
|
||||
const sources = {
|
||||
agent: 'Host agent',
|
||||
rcon: 'RCON',
|
||||
telemetry: 'Telemetry',
|
||||
combined: 'RCON and telemetry',
|
||||
manifest: 'Plutonium manifest'
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="execution" aria-labelledby="execution-title">
|
||||
<div class="section-heading"><div><span class="eyebrow">Execution</span><h2 id="execution-title">Progress and evidence</h2></div></div>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Execution</span>
|
||||
<h2 id="execution-title">Progress and evidence</h2>
|
||||
</div>
|
||||
</div>
|
||||
{#if !progress}
|
||||
<div class="legacy"><h3>{detail.deployment.stage}</h3><p>Detailed checkpoints were not recorded for this operation. Its recorded outcome and original activity remain available below.</p></div>
|
||||
<div class="legacy">
|
||||
<h3>{detail.deployment.stage}</h3>
|
||||
<p>
|
||||
Detailed checkpoints were not recorded for this operation. Its recorded outcome and
|
||||
original activity remain available below.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if groups.length}
|
||||
<ol class="phases">
|
||||
{#each groups as group (group.key)}
|
||||
{@const defaultOpen = detail.deployment.state !== 'done' || group.steps.some(step => step.state === 'failed' || step.checks.some(check => check.result !== 'pass'))}
|
||||
<li class:current={active && (group.state === 'current' || group.state === 'waiting')} class:failed={group.state === 'failed'}>
|
||||
{@const defaultOpen =
|
||||
detail.deployment.state !== 'done' ||
|
||||
group.steps.some(
|
||||
(step) =>
|
||||
step.state === 'failed' ||
|
||||
step.checks.some((check) => check.result !== 'pass')
|
||||
)}
|
||||
<li
|
||||
class:current={active &&
|
||||
(group.state === 'current' || group.state === 'waiting')}
|
||||
class:failed={group.state === 'failed'}
|
||||
>
|
||||
<details open={expanded[group.key] ?? defaultOpen}>
|
||||
<summary
|
||||
onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? defaultOpen); }}
|
||||
onkeydown={event => {
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
if (!event.repeat) expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
|
||||
if (!event.repeat)
|
||||
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
|
||||
}}
|
||||
>
|
||||
<span class="phase-icon state-{group.state}" aria-hidden="true">{progressIcons[group.state]}</span>
|
||||
<span class="phase-icon state-{group.state}" aria-hidden="true"
|
||||
>{progressIcons[group.state]}</span
|
||||
>
|
||||
<span class="phase-title">{group.label}</span>
|
||||
<span class="phase-state state-{group.state}">{progressStateLabels[group.state]}</span>
|
||||
<span class="phase-state state-{group.state}"
|
||||
>{progressStateLabels[group.state]}</span
|
||||
>
|
||||
</summary>
|
||||
<ol class="steps">
|
||||
{#each group.steps as step (step.key)}
|
||||
<li aria-current={active && (step.state === 'current' || step.state === 'waiting') ? 'step' : undefined}>
|
||||
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
|
||||
<li
|
||||
aria-current={active &&
|
||||
(step.state === 'current' || step.state === 'waiting')
|
||||
? 'step'
|
||||
: undefined}
|
||||
>
|
||||
<span class="step-icon state-{step.state}" aria-hidden="true"
|
||||
>{progressIcons[step.state]}</span
|
||||
>
|
||||
<div class="step-content">
|
||||
<strong>{step.label}</strong>
|
||||
<span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at}{' · '}<span class="time"><OperationTime start={detail.deployment.started_at} value={step.at} /></span>{/if}</span>
|
||||
<span class="step-meta state-{step.state}"
|
||||
>{progressStateLabels[
|
||||
step.state
|
||||
]}{#if step.at}{' · '}<span class="time"
|
||||
><OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={step.at}
|
||||
/></span
|
||||
>{/if}</span
|
||||
>
|
||||
{#each step.checks as check (check.key)}
|
||||
<div class="evidence">
|
||||
{#if step.checks.length > 1 || check.key === 'plutonium-release'}<p class="check-label">{check.label}{' · '}{evidenceLabels[check.result]}</p>{/if}
|
||||
{#if step.checks.length > 1 || check.key === 'plutonium-release'}<p
|
||||
class="check-label"
|
||||
>
|
||||
{check.label}{' · '}{evidenceLabels[
|
||||
check.result
|
||||
]}
|
||||
</p>{/if}
|
||||
<dl class="values">
|
||||
{#if check.expected !== null}<div><dt>Expected</dt><dd>{provisioningCheckValue(check.key, check.expected)}</dd></div>{/if}
|
||||
<div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</dd></div>
|
||||
{#if check.expected !== null}<div>
|
||||
<dt>Expected</dt>
|
||||
<dd>
|
||||
{provisioningCheckValue(
|
||||
check.key,
|
||||
check.expected
|
||||
)}
|
||||
</dd>
|
||||
</div>{/if}
|
||||
<div>
|
||||
<dt>Observed</dt>
|
||||
<dd>
|
||||
{provisioningCheckValue(
|
||||
check.key,
|
||||
check.actual
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if check.message}<p>{check.message}</p>{/if}
|
||||
<div class="evidence-meta">
|
||||
<span>{check.source ? sources[check.source] : 'Source not recorded'}</span>
|
||||
{#if check.checkedAt !== step.at}<span>Checked <OperationTime start={detail.deployment.started_at} value={check.checkedAt} /></span>{/if}
|
||||
{#if check.receivedAt}<span>Evidence received <OperationTime start={detail.deployment.started_at} value={check.receivedAt} /></span>{/if}
|
||||
<span
|
||||
>{check.source
|
||||
? sources[check.source]
|
||||
: 'Source not recorded'}</span
|
||||
>
|
||||
{#if check.checkedAt !== step.at}<span
|
||||
>Checked <OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={check.checkedAt}
|
||||
/></span
|
||||
>{/if}
|
||||
{#if check.receivedAt}<span
|
||||
>Evidence received <OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={check.receivedAt}
|
||||
/></span
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if step.key === 'verify-map' && detail.deployment.expected_map}<p class="map-note">Engine-map verification does not independently verify a variant or start location.</p>{/if}
|
||||
{#if step.key === 'verify-map' && detail.deployment.expected_map}<p
|
||||
class="map-note"
|
||||
>
|
||||
Engine-map verification does not independently
|
||||
verify a variant or start location.
|
||||
</p>{/if}
|
||||
{#if step.command}
|
||||
<div class="evidence" class:uncertain={step.command.state === 'uncertain'}>
|
||||
<p class="command-label">{step.command.state === 'attempted' ? 'Command submission attempted' : step.command.state === 'accepted' ? 'Remote request accepted' : step.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</p>
|
||||
<div
|
||||
class="evidence"
|
||||
class:uncertain={step.command.state === 'uncertain'}
|
||||
>
|
||||
<p class="command-label">
|
||||
{step.command.state === 'attempted'
|
||||
? 'Command submission attempted'
|
||||
: step.command.state === 'accepted'
|
||||
? 'Remote request accepted'
|
||||
: step.command.state === 'rejected'
|
||||
? 'Remote request rejected'
|
||||
: 'Remote response uncertain'}
|
||||
</p>
|
||||
<p>{step.command.message}</p>
|
||||
<span class="time"><OperationTime start={detail.deployment.started_at} value={step.command.at} /></span>
|
||||
<span class="time"
|
||||
><OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={step.command.at}
|
||||
/></span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if step.agentOperation}
|
||||
<div class="evidence">
|
||||
<dl class="agent-details">
|
||||
<div><dt>Agent state</dt><dd>{step.agentOperation.state}</dd></div>
|
||||
<div><dt>Command queued</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.createdAt} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Latest agent report</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
|
||||
<div>
|
||||
<dt>Agent state</dt>
|
||||
<dd>{step.agentOperation.state}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Command queued</dt>
|
||||
<dd>
|
||||
<OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={step.agentOperation
|
||||
.createdAt}
|
||||
missing="Not recorded"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest agent report</dt>
|
||||
<dd>
|
||||
<OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={step.agentOperation
|
||||
.updatedAt}
|
||||
missing="Not recorded"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if step.agentOperation.error}<p class="agent-error">{step.agentOperation.error}</p>{/if}
|
||||
<a href={hostHistoryHref ?? `/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}>View host operation history</a>
|
||||
{#if step.agentOperation.error}<p
|
||||
class="agent-error"
|
||||
>
|
||||
{step.agentOperation.error}
|
||||
</p>{/if}
|
||||
<a
|
||||
href={hostHistoryHref ??
|
||||
`/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}
|
||||
>View host operation history</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if step.checkpoints.length}<ul class="checkpoints">
|
||||
{#each step.checkpoints as checkpoint (checkpoint.key)}
|
||||
<li><span>{checkpoint.label}</span><span class="time"><OperationTime start={detail.deployment.started_at} value={checkpoint.at} /></span></li>
|
||||
{/each}
|
||||
</ul>{/if}
|
||||
{#each step.checkpoints as checkpoint (checkpoint.key)}
|
||||
<li>
|
||||
<span>{checkpoint.label}</span><span
|
||||
class="time"
|
||||
><OperationTime
|
||||
start={detail.deployment.started_at}
|
||||
value={checkpoint.at}
|
||||
/></span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -93,50 +252,262 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.execution { display: grid; gap: var(--space-5); min-width: 0; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
h3 { font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
.eyebrow { display: block; margin-bottom: var(--space-2); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.phases, .steps, .checkpoints { list-style: none; padding: 0; margin: 0; }
|
||||
.phases { display: grid; gap: var(--space-3); }
|
||||
.phases > li { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.phases > li.current { border-color: color-mix(in srgb, var(--accent-primary) 60%, var(--border-subtle)); }
|
||||
.phases > li.failed { border-color: color-mix(in srgb, var(--state-danger) 60%, var(--border-subtle)); }
|
||||
summary { cursor: pointer; }
|
||||
.phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; }
|
||||
.phases summary::-webkit-details-marker { display: none; }
|
||||
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: var(--text-md); }
|
||||
.phases details[open] > summary::after { content: '−'; }
|
||||
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
|
||||
.phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); }
|
||||
.phase-title { flex: 1; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
.phase-state { font-size: var(--text-xs); }
|
||||
.steps { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); display: grid; gap: var(--space-4); }
|
||||
.steps > li { display: flex; align-items: flex-start; gap: var(--space-3); }
|
||||
.step-icon { flex: 0 0 26px; text-align: center; line-height: 1.5; }
|
||||
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; }
|
||||
.step-meta { display: block; font-size: var(--text-xs); margin-top: var(--space-1); line-height: 1.6; }
|
||||
.state-current { color: var(--accent-primary-hover); }.state-waiting { color: var(--state-warning); }.state-failed { color: var(--state-danger); }.state-done { color: var(--state-online); }
|
||||
.state-unknown, .state-pending, .state-cancelled, .time { color: var(--text-secondary); }
|
||||
.legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.legacy p { font-size: var(--text-sm); }
|
||||
.step-content { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
||||
.evidence { display: grid; gap: var(--space-1); margin-top: var(--space-2); font-size: var(--text-xs); }
|
||||
.check-label, .command-label { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.uncertain .command-label { color: var(--state-warning); }
|
||||
.agent-error { color: var(--state-danger); }
|
||||
dl { display: flex; gap: var(--space-2) var(--space-4); margin: 0; flex-wrap: wrap; line-height: 1.6; }
|
||||
dt { color: var(--text-tertiary); }dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.values > div { display: flex; flex-wrap: wrap; gap: 0 var(--space-1); }
|
||||
.agent-details { margin-block: var(--space-1); }.agent-details > div { min-width: 0; }
|
||||
.time, .evidence-meta, .checkpoints { font-size: var(--text-xs); }
|
||||
.evidence-meta { display: flex; gap: var(--space-1) var(--space-4); flex-wrap: wrap; color: var(--text-tertiary); line-height: 1.6; }
|
||||
.checkpoints { display: grid; gap: var(--space-2); margin-top: var(--space-3); color: var(--text-secondary); line-height: 1.6; }
|
||||
.checkpoints li { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
|
||||
.map-note { margin-top: var(--space-2); }
|
||||
a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
|
||||
a:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
|
||||
@media (max-width: 600px) { .phases summary { padding: var(--space-4); gap: var(--space-2); flex-wrap: wrap; }.phase-title { flex-basis: calc(100% - 70px); }.phase-state { padding-left: 34px; order: 1; flex-basis: 100%; }.phases summary::after { order: 0; }.steps { padding: var(--space-4); } }
|
||||
.execution {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.phases,
|
||||
.steps,
|
||||
.checkpoints {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.phases {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.phases > li {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.phases > li.current {
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 60%, var(--border-subtle));
|
||||
}
|
||||
.phases > li.failed {
|
||||
border-color: color-mix(in srgb, var(--state-danger) 60%, var(--border-subtle));
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.phases summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
list-style: none;
|
||||
}
|
||||
.phases summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.phases summary::after {
|
||||
content: '+';
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.phases details[open] > summary::after {
|
||||
content: '−';
|
||||
}
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: -3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.phase-icon {
|
||||
flex: 0 0 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.phase-title {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.phase-state {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.steps {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.steps > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.step-icon {
|
||||
flex: 0 0 26px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.steps strong {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.step-meta {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.state-current {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.state-waiting {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.state-failed {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.state-done {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.state-unknown,
|
||||
.state-pending,
|
||||
.state-cancelled,
|
||||
.time {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.legacy {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.legacy p {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.step-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.evidence {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.check-label,
|
||||
.command-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.uncertain .command-label {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.agent-error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.values > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--space-1);
|
||||
}
|
||||
.agent-details {
|
||||
margin-block: var(--space-1);
|
||||
}
|
||||
.agent-details > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.time,
|
||||
.evidence-meta,
|
||||
.checkpoints {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.evidence-meta {
|
||||
display: flex;
|
||||
gap: var(--space-1) var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.checkpoints {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.checkpoints li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
.map-note {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.phases summary {
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.phase-title {
|
||||
flex-basis: calc(100% - 70px);
|
||||
}
|
||||
.phase-state {
|
||||
padding-left: 34px;
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.phases summary::after {
|
||||
order: 0;
|
||||
}
|
||||
.steps {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,42 @@
|
||||
import type { RecordEvidence } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
|
||||
let {
|
||||
evidence,
|
||||
returnTo,
|
||||
metric = 'Record'
|
||||
}: {
|
||||
evidence: RecordEvidence;
|
||||
returnTo?: string;
|
||||
metric?: string;
|
||||
disclosure?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
<span class="record-source"><a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`} aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`} title={evidence.endedAt ? `Completed ${$timeFormat.dateTime(evidence.endedAt)}` : 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a></span>
|
||||
|
||||
<span class="record-source"
|
||||
><a
|
||||
href={returnTo
|
||||
? contextHref(`/matches/${evidence.matchId}`, returnTo)
|
||||
: `/matches/${evidence.matchId}`}
|
||||
aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`}
|
||||
title={evidence.endedAt
|
||||
? `Completed ${$timeFormat.dateTime(evidence.endedAt)}`
|
||||
: 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a
|
||||
></span
|
||||
>
|
||||
|
||||
<style>
|
||||
.record-source { display: block; margin-top: var(--space-1); white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: var(--leading-body); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.record-source {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: normal;
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
|
||||
let { value, missing = '—', class: className }: { value: Timestamp; missing?: string; class?: string } = $props();
|
||||
let {
|
||||
value,
|
||||
missing = '—',
|
||||
class: className
|
||||
}: { value: Timestamp; missing?: string; class?: string } = $props();
|
||||
const iso = $derived(timestampISO(value));
|
||||
const now = $derived(browser ? $uiNow : Date.now());
|
||||
</script>
|
||||
|
||||
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}>{$timeFormat.relative(value, now)}</time>{:else}<span class={className}>{missing}</span>{/if}
|
||||
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}
|
||||
>{$timeFormat.relative(value, now)}</time
|
||||
>{:else}<span class={className}>{missing}</span>{/if}
|
||||
|
||||
@@ -5,63 +5,206 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
let preview = $state<{ policies: { category: string; label: string; eligible: number; payloadBytes: number; records: { id: string }[] }[];
|
||||
controls: { category: string; id: string; protected: number; resolvedAt: string | null }[] } | null>(null);
|
||||
let preview = $state<{
|
||||
policies: {
|
||||
category: string;
|
||||
label: string;
|
||||
eligible: number;
|
||||
payloadBytes: number;
|
||||
records: { id: string }[];
|
||||
}[];
|
||||
controls: { category: string; id: string; protected: number; resolvedAt: string | null }[];
|
||||
} | null>(null);
|
||||
let category = $state('campaign');
|
||||
let recordId = $state('');
|
||||
const categories = ['campaign','provisioning','operation','observation','maintenance','recovery','match','beta','file-content','beta-file-content','version','audit','game-file','upload'];
|
||||
const categories = [
|
||||
'campaign',
|
||||
'provisioning',
|
||||
'operation',
|
||||
'observation',
|
||||
'maintenance',
|
||||
'recovery',
|
||||
'match',
|
||||
'beta',
|
||||
'file-content',
|
||||
'beta-file-content',
|
||||
'version',
|
||||
'audit',
|
||||
'game-file',
|
||||
'upload'
|
||||
];
|
||||
async function refresh() {
|
||||
busy = true; error = '';
|
||||
try { const response = await fetch('/api/settings/retention/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); if (!response.ok) throw new Error('Could not load cleanup preview.'); preview = await response.json(); }
|
||||
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function control(action: { protected?: boolean; resolve?: boolean }, selectedCategory = category, id = recordId) {
|
||||
busy = true; error = ''; notice = '';
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/settings/retention',{ method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ category: selectedCategory,id,...action }) });
|
||||
const result = await response.json(); if (!response.ok) throw new Error(result.error);
|
||||
notice = action.resolve ? 'Failure marked resolved. Retention still checks all recovery dependencies.' : action.protected ? 'Record protected from cleanup.' : 'Record protection removed.';
|
||||
const response = await fetch('/api/settings/retention/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not load cleanup preview.');
|
||||
preview = await response.json();
|
||||
} catch (cause) {
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function control(
|
||||
action: { protected?: boolean; resolve?: boolean },
|
||||
selectedCategory = category,
|
||||
id = recordId
|
||||
) {
|
||||
busy = true;
|
||||
error = '';
|
||||
notice = '';
|
||||
try {
|
||||
const response = await fetch('/api/settings/retention', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ category: selectedCategory, id, ...action })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error);
|
||||
notice = action.resolve
|
||||
? 'Failure marked resolved. Retention still checks all recovery dependencies.'
|
||||
: action.protected
|
||||
? 'Record protected from cleanup.'
|
||||
: 'Record protection removed.';
|
||||
await refresh();
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
} catch (cause) {
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<details>
|
||||
<summary>Cleanup preview and protected records</summary>
|
||||
<p>Preview uses the values currently entered above, without saving them. Background cleanup runs in batches. Known payload sizes are estimates; database file size may not shrink immediately. Expired data cannot be restored by increasing retention.</p>
|
||||
<Button disabled={busy} onclick={refresh}>{busy ? 'Loading…' : 'Refresh cleanup preview'}</Button>
|
||||
<p>
|
||||
Preview uses the values currently entered above, without saving them. Background cleanup
|
||||
runs in batches. Known payload sizes are estimates; database file size may not shrink
|
||||
immediately. Expired data cannot be restored by increasing retention.
|
||||
</p>
|
||||
<Button disabled={busy} onclick={refresh}
|
||||
>{busy ? 'Loading…' : 'Refresh cleanup preview'}</Button
|
||||
>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if preview}
|
||||
<div class="table-wrap"><table><thead><tr><th>Category</th><th>Eligible records</th><th>Known payload size</th></tr></thead><tbody>
|
||||
{#each preview.policies as policy}<tr><td>{policy.label}</td><td>{policy.eligible}</td><td>{policy.payloadBytes ? `${(policy.payloadBytes / 1024 / 1024).toFixed(2)} MiB` : '—'}</td></tr>{/each}
|
||||
</tbody></table></div>
|
||||
{#each preview.policies.filter(policy => policy.records.length) as policy}
|
||||
<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></div>{/each}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr><th>Category</th><th>Eligible records</th><th>Known payload size</th></tr
|
||||
></thead
|
||||
><tbody>
|
||||
{#each preview.policies as policy}<tr
|
||||
><td>{policy.label}</td><td>{policy.eligible}</td><td
|
||||
>{policy.payloadBytes
|
||||
? `${(policy.payloadBytes / 1024 / 1024).toFixed(2)} MiB`
|
||||
: '—'}</td
|
||||
></tr
|
||||
>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#each preview.policies.filter((policy) => policy.records.length) as policy}
|
||||
<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
|
||||
>
|
||||
</div>{/each}
|
||||
</details>
|
||||
{/each}
|
||||
<h3>Protected records and resolved failures</h3>
|
||||
{#each preview.controls as record}<div class="record"><span>{record.category} · <code>{record.id}</code>{record.resolvedAt ? ' · Resolved' : ''}</span>
|
||||
{#if record.protected}<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}
|
||||
{#each preview.controls as record}<div class="record">
|
||||
<span
|
||||
>{record.category} · <code>{record.id}</code>{record.resolvedAt
|
||||
? ' · Resolved'
|
||||
: ''}</span
|
||||
>
|
||||
{#if record.protected}<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>
|
||||
<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">
|
||||
<label>Category<select bind:value={category}>{#each categories as value}<option value={value}>{value}</option>{/each}</select></label>
|
||||
<label
|
||||
>Category<select bind:value={category}
|
||||
>{#each categories as value}<option {value}>{value}</option>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label>Record ID<input bind:value={recordId} maxlength="200" /></label>
|
||||
<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}
|
||||
<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>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
details { margin-top: 20px; }
|
||||
summary { cursor: pointer; }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; }
|
||||
.record { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
label { display: grid; gap: 8px; }
|
||||
select,input { font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 8px; }
|
||||
.table-wrap { overflow-x: auto; margin: 16px 0; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||
th,td { padding: 10px; border-bottom: 1px solid var(--border-strong); }
|
||||
details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.record {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,21 +4,42 @@
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import type { GameTelemetry } from '$lib/telemetry.js';
|
||||
let { name, since = null, report: observation }: { name: string; since?: string | null; report?: GameTelemetry } = $props();
|
||||
let {
|
||||
name,
|
||||
since = null,
|
||||
report: observation
|
||||
}: { name: string; since?: string | null; report?: GameTelemetry } = $props();
|
||||
let report = $derived(observation ?? $telemetryByServer.get(name));
|
||||
let fresh = $derived(report && freshStatusTimestamp(report.receivedAt, $uiNow)
|
||||
&& (!since || Date.parse(report.receivedAt) >= Date.parse(since)));
|
||||
let fresh = $derived(
|
||||
report &&
|
||||
freshStatusTimestamp(report.receivedAt, $uiNow) &&
|
||||
(!since || Date.parse(report.receivedAt) >= Date.parse(since))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
|
||||
<div class="round">
|
||||
<strong>Round {report.round}</strong>
|
||||
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span>
|
||||
<span title={$timeFormat.dateTime(report.receivedAt)}
|
||||
>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.round { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
|
||||
strong { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.round {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { PlutoniumVersion } from '$lib/plutonium.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
let { name, version, after, ready = true }: { name: string; version?: PlutoniumVersion; after?: string | null; ready?: boolean } = $props();
|
||||
const fresh = $derived(ready && version?.checked_at && $uiNow - Date.parse(version.checked_at) < 3_600_000 && (!after || version.checked_at > after));
|
||||
let {
|
||||
name,
|
||||
version,
|
||||
after,
|
||||
ready = true
|
||||
}: {
|
||||
name: string;
|
||||
version?: PlutoniumVersion;
|
||||
after?: string | null;
|
||||
ready?: boolean;
|
||||
} = $props();
|
||||
const fresh = $derived(
|
||||
ready &&
|
||||
version?.checked_at &&
|
||||
$uiNow - Date.parse(version.checked_at) < 3_600_000 &&
|
||||
(!after || version.checked_at > after)
|
||||
);
|
||||
const revision = $derived(fresh ? version?.revision : null);
|
||||
const outdated = $derived(fresh && version?.update_status === 'outdated');
|
||||
</script>
|
||||
|
||||
<a class:outdated href={`/maintenance?kind=updates&server=${encodeURIComponent(name)}`} title="View Plutonium updates and verification">
|
||||
<a
|
||||
class:outdated
|
||||
href={`/maintenance?kind=updates&server=${encodeURIComponent(name)}`}
|
||||
title="View Plutonium updates and verification"
|
||||
>
|
||||
Plutonium {revision ? `r${revision}` : 'unverified'}{outdated ? ' · Update available' : ''}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
a { font-size: var(--text-xs); color: var(--text-secondary); text-decoration: none; }
|
||||
a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
|
||||
.outdated { color: var(--state-warning); }
|
||||
a {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.outdated {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,29 @@
|
||||
import { plural } from '$ui/plural.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
|
||||
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
|
||||
interface Duplicate { scriptId: number; versionId: number; version: number; }
|
||||
import {
|
||||
betaSelectionHref,
|
||||
mapLabel,
|
||||
scopeLabel,
|
||||
type BetaSelection,
|
||||
type LibraryScript
|
||||
} from '$lib/script-library.js';
|
||||
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } =
|
||||
$props();
|
||||
interface Duplicate {
|
||||
scriptId: number;
|
||||
versionId: number;
|
||||
version: number;
|
||||
}
|
||||
interface Upload {
|
||||
key: number; file: File; target: number | 'new' | ''; name: string;
|
||||
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
|
||||
key: number;
|
||||
file: File;
|
||||
target: number | 'new' | '';
|
||||
name: string;
|
||||
duplicates: Duplicate[];
|
||||
inspecting: boolean;
|
||||
inspected: boolean;
|
||||
error: string;
|
||||
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
||||
}
|
||||
let dialog: HTMLDialogElement;
|
||||
@@ -20,153 +37,315 @@
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
|
||||
games: [],
|
||||
mapsByGame: {}
|
||||
});
|
||||
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||
let scopeLoaded = $state(false);
|
||||
let message = $state('');
|
||||
let preferredTarget = $state<number | null>(null);
|
||||
let nextKey = 0;
|
||||
let activeFile = $state('');
|
||||
const checkedCount = $derived(queue.filter(q => q.inspected).length);
|
||||
const pending = $derived(queue.filter(q => !q.result));
|
||||
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
||||
const scopeValue = $derived(scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : '');
|
||||
const matches = $derived(servers.filter(s => scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue));
|
||||
const ready = $derived(pending.length > 0 && !busy && !inspecting && pending.every(q => q.inspected && !validation(q))
|
||||
&& (!hasNew || scopeType === 'none' || scopeLoaded && !!scopeValue));
|
||||
const results = $derived(queue.flatMap(q => q.result ? [q.result] : []));
|
||||
const testable = $derived(results.filter(r => {
|
||||
const s = scripts.find(s => s.id === r.scriptId);
|
||||
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
|
||||
}));
|
||||
const checkedCount = $derived(queue.filter((q) => q.inspected).length);
|
||||
const pending = $derived(queue.filter((q) => !q.result));
|
||||
const inspecting = $derived(queue.some((q) => q.inspecting));
|
||||
const hasNew = $derived(pending.some((q) => q.target === 'new'));
|
||||
const scopeValue = $derived(
|
||||
scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : ''
|
||||
);
|
||||
const matches = $derived(
|
||||
servers.filter((s) => (scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue))
|
||||
);
|
||||
const ready = $derived(
|
||||
pending.length > 0 &&
|
||||
!busy &&
|
||||
!inspecting &&
|
||||
pending.every((q) => q.inspected && !validation(q)) &&
|
||||
(!hasNew || scopeType === 'none' || (scopeLoaded && !!scopeValue))
|
||||
);
|
||||
const results = $derived(queue.flatMap((q) => (q.result ? [q.result] : [])));
|
||||
const testable = $derived(
|
||||
results.filter((r) => {
|
||||
const s = scripts.find((s) => s.id === r.scriptId);
|
||||
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
|
||||
})
|
||||
);
|
||||
|
||||
export function open(scriptId?: number) {
|
||||
// Reopening preserves unfinished work; a targeted upload starts a fresh review
|
||||
// only when there is no pending file to lose.
|
||||
if (!pending.length) { queue = []; notes = ''; message = ''; preferredTarget = scriptId ?? null; }
|
||||
if (!pending.length) {
|
||||
queue = [];
|
||||
notes = '';
|
||||
message = '';
|
||||
preferredTarget = scriptId ?? null;
|
||||
}
|
||||
dialog.showModal();
|
||||
if (!scopeLoaded) void loadScope();
|
||||
}
|
||||
async function loadScope() {
|
||||
try {
|
||||
const [scopeRes, serversRes] = await Promise.all([fetch('/api/scope-options'), fetch('/api/servers')]);
|
||||
if (!scopeRes.ok || !serversRes.ok) throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
|
||||
scopeOptions = await scopeRes.json(); servers = (await serversRes.json()).servers;
|
||||
const [scopeRes, serversRes] = await Promise.all([
|
||||
fetch('/api/scope-options'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
if (!scopeRes.ok || !serversRes.ok)
|
||||
throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
|
||||
scopeOptions = await scopeRes.json();
|
||||
servers = (await serversRes.json()).servers;
|
||||
scopeLoaded = true;
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
}
|
||||
function matchingScripts(file: File) {
|
||||
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||
return scripts.filter(
|
||||
(s) =>
|
||||
s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ===
|
||||
file.name.toLowerCase()
|
||||
);
|
||||
}
|
||||
export async function addFiles(files: File[]) {
|
||||
if (busy || inspecting || !files.length) return;
|
||||
if (!dialog.open) open();
|
||||
const items = files.map(file => {
|
||||
const items = files.map((file) => {
|
||||
const matches = matchingScripts(file);
|
||||
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||
const item: Upload = {
|
||||
key: nextKey++,
|
||||
file,
|
||||
target:
|
||||
preferredTarget ??
|
||||
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''),
|
||||
duplicates: [],
|
||||
inspecting: true,
|
||||
inspected: false,
|
||||
error: '',
|
||||
result: null
|
||||
};
|
||||
return item;
|
||||
});
|
||||
queue = [...queue, ...items];
|
||||
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
|
||||
for (const item of items) await inspect(queue.find((q) => q.key === item.key)!);
|
||||
preferredTarget = null;
|
||||
}
|
||||
async function inspect(item: Upload) {
|
||||
activeFile = item.file.name;
|
||||
item.inspecting = true; item.error = '';
|
||||
item.inspecting = true;
|
||||
item.error = '';
|
||||
try {
|
||||
const form = new FormData(); form.append('content', item.file);
|
||||
const form = new FormData();
|
||||
form.append('content', item.file);
|
||||
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||
item.duplicates = body.duplicates; item.inspected = true;
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
finally { item.inspecting = false; activeFile = ''; }
|
||||
item.duplicates = body.duplicates;
|
||||
item.inspected = true;
|
||||
} catch (e) {
|
||||
item.error = (e as Error).message;
|
||||
} finally {
|
||||
item.inspecting = false;
|
||||
activeFile = '';
|
||||
}
|
||||
}
|
||||
function duplicate(item: Upload) {
|
||||
return item.duplicates.find((d) => d.scriptId === item.target);
|
||||
}
|
||||
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||
function validation(item: Upload): string {
|
||||
if (item.target === '') return 'Several scripts use this filename. Choose which script to update.';
|
||||
if (item.target === '')
|
||||
return 'Several scripts use this filename. Choose which script to update.';
|
||||
if (item.target === 'new') {
|
||||
if (!item.name.trim()) return 'Give this script a name.';
|
||||
if (scripts.some(s => s.name === item.name.trim())) return 'This name already exists. Choose a new name or update that script.';
|
||||
if (queue.some(q => q.key !== item.key && !q.result && q.target === 'new' && q.name.trim() === item.name.trim())) return 'Use a distinct name for each new script.';
|
||||
if (scripts.some((s) => s.name === item.name.trim()))
|
||||
return 'This name already exists. Choose a new name or update that script.';
|
||||
if (
|
||||
queue.some(
|
||||
(q) =>
|
||||
q.key !== item.key &&
|
||||
!q.result &&
|
||||
q.target === 'new' &&
|
||||
q.name.trim() === item.name.trim()
|
||||
)
|
||||
)
|
||||
return 'Use a distinct name for each new script.';
|
||||
} else {
|
||||
const script = scripts.find(s => s.id === item.target);
|
||||
const script = scripts.find((s) => s.id === item.target);
|
||||
if (!script) return 'This script is no longer available.';
|
||||
if (script.runningBetaId !== null) return 'Finish the active beta before uploading another version.';
|
||||
if (queue.some(q => q.key !== item.key && !q.result && q.target === item.target)) return 'Choose one file per existing script in this batch.';
|
||||
if (script.runningBetaId !== null)
|
||||
return 'Finish the active beta before uploading another version.';
|
||||
if (queue.some((q) => q.key !== item.key && !q.result && q.target === item.target))
|
||||
return 'Choose one file per existing script in this batch.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
async function upload(prepareBeta: boolean) {
|
||||
if (!ready) return;
|
||||
busy = true; message = '';
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
for (const item of queue.filter(q => !q.result)) {
|
||||
for (const item of queue.filter((q) => !q.result)) {
|
||||
activeFile = item.file.name;
|
||||
item.error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('content', item.file); form.append('notes', notes.trim());
|
||||
form.append('content', item.file);
|
||||
form.append('notes', notes.trim());
|
||||
const creating = item.target === 'new';
|
||||
if (creating) {
|
||||
form.append('name', item.name.trim()); form.append('scopeType', scopeType);
|
||||
form.append('name', item.name.trim());
|
||||
form.append('scopeType', scopeType);
|
||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||
} else form.append('reuseExisting', 'true');
|
||||
const res = await fetch(creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`, { method: 'POST', body: form });
|
||||
const res = await fetch(
|
||||
creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`,
|
||||
{ method: 'POST', body: form }
|
||||
);
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Upload failed. Try again.');
|
||||
item.result = { scriptId: creating ? body.script.id : item.target as number,
|
||||
if (!res.ok || !body.ok)
|
||||
throw new Error(body.error ?? 'Upload failed. Try again.');
|
||||
item.result = {
|
||||
scriptId: creating ? body.script.id : (item.target as number),
|
||||
versionId: creating ? body.versionId : body.version.id,
|
||||
version: creating ? 1 : body.version.version, reused: !!body.reused };
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
version: creating ? 1 : body.version.version,
|
||||
reused: !!body.reused
|
||||
};
|
||||
} catch (e) {
|
||||
item.error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
await onuploaded();
|
||||
if (queue.some(q => !q.result)) message = 'Some files need attention. Successful uploads are saved; retry only the remaining files.';
|
||||
if (queue.some((q) => !q.result))
|
||||
message =
|
||||
'Some files need attention. Successful uploads are saved; retry only the remaining files.';
|
||||
else {
|
||||
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
|
||||
// Read from refreshed props after Svelte applies the parent update.
|
||||
await tick();
|
||||
if (testable.length !== results.length) message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
|
||||
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||
if (testable.length !== results.length)
|
||||
message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
|
||||
if (prepareBeta && testable.length === results.length && testable.length) {
|
||||
dialog.close();
|
||||
await goto(betaSelectionHref(testable));
|
||||
}
|
||||
}
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
finally { busy = false; activeFile = ''; }
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
activeFile = '';
|
||||
}
|
||||
}
|
||||
function close(event?: Event) {
|
||||
if (busy || inspecting) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
dialog.close();
|
||||
}
|
||||
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
|
||||
<div class="drawer">
|
||||
<header><div><span class="eyebrow">Prepare your next release</span><h2 id="upload-title">Upload scripts</h2></div><Button variant="ghost" ariaLabel="Close upload drawer" disabled={busy || inspecting} onclick={() => close()}>✕</Button></header>
|
||||
<p class="intro">Drop files anywhere or choose them below. Review each destination before uploading.</p>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Prepare your next release</span>
|
||||
<h2 id="upload-title">Upload scripts</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
ariaLabel="Close upload drawer"
|
||||
disabled={busy || inspecting}
|
||||
onclick={() => close()}>✕</Button
|
||||
>
|
||||
</header>
|
||||
<p class="intro">
|
||||
Drop files anywhere or choose them below. Review each destination before uploading.
|
||||
</p>
|
||||
<div class="dropzone">
|
||||
<strong>{preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'}</strong>
|
||||
<strong
|
||||
>{preferredTarget
|
||||
? `New version for ${scripts.find((s) => s.id === preferredTarget)?.name ?? 'script'}`
|
||||
: 'One file or a whole batch'}</strong
|
||||
>
|
||||
<span>Existing filenames are recognized automatically.</span>
|
||||
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button>
|
||||
<input class="file-input" bind:this={input} type="file" multiple aria-label="Choose script files" onchange={event => { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} />
|
||||
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button
|
||||
>
|
||||
<input
|
||||
class="file-input"
|
||||
bind:this={input}
|
||||
type="file"
|
||||
multiple
|
||||
aria-label="Choose script files"
|
||||
onchange={(event) => {
|
||||
void addFiles([...(event.currentTarget.files ?? [])]);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="queue" aria-live="polite">
|
||||
{#each queue as item (item.key)}
|
||||
<section class="upload-item" aria-label={item.file.name}>
|
||||
<div class="file-heading"><strong>{item.file.name}</strong><span>{(item.file.size / 1024).toFixed(1)} KB</span>
|
||||
<Button variant="ghost" size="sm" disabled={busy || inspecting} ariaLabel={`Remove ${item.file.name}`} onclick={() => queue = queue.filter(q => q.key !== item.key)}>Remove</Button>
|
||||
<div class="file-heading">
|
||||
<strong>{item.file.name}</strong><span
|
||||
>{(item.file.size / 1024).toFixed(1)} KB</span
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy || inspecting}
|
||||
ariaLabel={`Remove ${item.file.name}`}
|
||||
onclick={() => (queue = queue.filter((q) => q.key !== item.key))}
|
||||
>Remove</Button
|
||||
>
|
||||
</div>
|
||||
{#if item.result}
|
||||
<p class="success">{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
|
||||
<p class="success">
|
||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
|
||||
.version} ·
|
||||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||||
</p>
|
||||
{:else}
|
||||
<label>Destination<select bind:value={item.target} disabled={busy || item.inspecting} onchange={() => item.error = ''}>
|
||||
<option value="" disabled>Choose a script…</option><option value="new">Create a new script</option>
|
||||
{#each scripts as s}<option value={s.id}>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null ? ' · beta active' : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
|
||||
{:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}<p class="note">Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}</p>{/if}
|
||||
<label
|
||||
>Destination<select
|
||||
bind:value={item.target}
|
||||
disabled={busy || item.inspecting}
|
||||
onchange={() => (item.error = '')}
|
||||
>
|
||||
<option value="" disabled>Choose a script…</option><option
|
||||
value="new">Create a new script</option
|
||||
>
|
||||
{#each scripts as s}<option value={s.id}
|
||||
>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null
|
||||
? ' · beta active'
|
||||
: ''}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
{#if item.target === 'new'}<label
|
||||
>Script name<input bind:value={item.name} disabled={busy} /></label
|
||||
>
|
||||
{:else if item.target !== ''}{@const s = scripts.find(
|
||||
(s) => s.id === item.target
|
||||
)}
|
||||
<p class="note">
|
||||
Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}
|
||||
</p>{/if}
|
||||
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||
{:else if duplicate(item)}<p class="success">Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.</p>
|
||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">New version for {scripts.find(s => s.id === item.target)?.name}</p>{/if}
|
||||
{:else if duplicate(item)}<p class="success">
|
||||
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
|
||||
version.
|
||||
</p>
|
||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
|
||||
New version for {scripts.find((s) => s.id === item.target)?.name}
|
||||
</p>{/if}
|
||||
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
|
||||
{#if item.error}<p class="error" role="alert">{item.error}</p>{#if !item.inspected}<Button size="sm" disabled={busy || inspecting} onclick={() => inspect(item)}>Retry inspection</Button>{/if}{/if}
|
||||
{#if item.error}<p class="error" role="alert">{item.error}</p>
|
||||
{#if !item.inspected}<Button
|
||||
size="sm"
|
||||
disabled={busy || inspecting}
|
||||
onclick={() => inspect(item)}>Retry inspection</Button
|
||||
>{/if}{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
@@ -174,46 +353,277 @@
|
||||
{#if pending.length}
|
||||
<section class="shared">
|
||||
<h3>Shared upload details</h3>
|
||||
<label>Version notes<textarea bind:value={notes} disabled={busy} rows="3" placeholder="What changed in these files?"></textarea></label>
|
||||
<label
|
||||
>Version notes<textarea
|
||||
bind:value={notes}
|
||||
disabled={busy}
|
||||
rows="3"
|
||||
placeholder="What changed in these files?"
|
||||
></textarea></label
|
||||
>
|
||||
{#if hasNew}
|
||||
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
|
||||
<label
|
||||
>Scope for new scripts<select
|
||||
bind:value={scopeType}
|
||||
disabled={busy}
|
||||
onchange={() => {
|
||||
scopeGame = '';
|
||||
scopeMap = '';
|
||||
}}
|
||||
><option value="none">Unscoped · choose beta targets</option><option
|
||||
value="game">Game</option
|
||||
><option value="map">Map</option></select
|
||||
></label
|
||||
>
|
||||
{#if scopeType !== 'none'}
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
|
||||
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
|
||||
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
|
||||
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival variants.</p>{/if}
|
||||
{:else}<p class="note">Unscoped scripts deploy only to explicitly selected beta targets.</p>{/if}
|
||||
<label
|
||||
>Game<select
|
||||
bind:value={scopeGame}
|
||||
disabled={busy || !scopeLoaded}
|
||||
onchange={() => (scopeMap = '')}
|
||||
><option value="">Choose a game…</option
|
||||
>{#each scopeOptions.games as g}<option value={g}
|
||||
>{gameTitle(g)}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
{#if scopeType === 'map'}<label
|
||||
>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}
|
||||
><option value="">Choose a map…</option
|
||||
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
|
||||
value={m}>{mapLabel(m)}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>{/if}
|
||||
{#if scopeValue}<p class="note">
|
||||
{matches.length} matching servers: {matches
|
||||
.map((s) => s.name)
|
||||
.join(', ') || 'None'}
|
||||
</p>{/if}
|
||||
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">
|
||||
Map scope includes every server using this engine map, including its
|
||||
Survival variants.
|
||||
</p>{/if}
|
||||
{:else}<p class="note">
|
||||
Unscoped scripts deploy only to explicitly selected beta targets.
|
||||
</p>{/if}
|
||||
<p class="note">Existing scripts keep their current scope.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">
|
||||
{inspecting
|
||||
? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}`
|
||||
: `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile
|
||||
? ` · ${activeFile}`
|
||||
: ''}
|
||||
</p>{:else if queue.length}{@const failed = queue.filter((q) => q.error).length}
|
||||
<p class="batch-progress">
|
||||
{results.length} saved · {pending.length} remaining{failed
|
||||
? ` · ${failed} ${plural(failed, 'needs', 'need')} attention`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
{#if pending.length}<Button
|
||||
disabled={!ready}
|
||||
loading={busy}
|
||||
onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button
|
||||
><Button variant="primary" disabled={!ready} onclick={() => upload(true)}
|
||||
>Upload & prepare beta →</Button
|
||||
>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}
|
||||
>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button
|
||||
>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; border: 0; border-left: 1px solid var(--border-subtle); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: #0009; backdrop-filter: blur(3px); }
|
||||
.drawer { padding: 28px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; box-sizing: border-box; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { margin: 6px 0 0; font-size: var(--text-2xl); } h3 { margin: 0; font-size: var(--text-md); }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .1em; color: var(--accent-primary); font-size: var(--text-xs); }
|
||||
.intro, .note { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
|
||||
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 28px; background: var(--surface-sunken); }
|
||||
.dropzone span { font-size: var(--text-xs); color: var(--text-secondary); } .file-input { display: none; }
|
||||
.queue, .shared { display: flex; flex-direction: column; gap: 16px; } .upload-item { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.file-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .file-heading strong { flex: 1; overflow-wrap: anywhere; } .file-heading span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
label { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); background: var(--surface-sunken); font: inherit; font-size: var(--text-sm); } textarea { resize: vertical; }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
|
||||
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
|
||||
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||
dialog {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
margin: 0;
|
||||
width: min(620px, 100vw);
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
max-width: 100vw;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: #0009;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.drawer {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
h2 {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.intro,
|
||||
.note {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 28px;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.dropzone span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
.queue,
|
||||
.shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.upload-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.file-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-heading strong {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.file-heading span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.success,
|
||||
.warning,
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.success {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.warning {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.notice {
|
||||
padding: 14px;
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
background: var(--surface);
|
||||
}
|
||||
.batch-progress {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.drawer {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
input:not([type='checkbox']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
|
||||
interface Held { target: string; betaId: number; }
|
||||
interface Held {
|
||||
target: string;
|
||||
betaId: number;
|
||||
}
|
||||
interface Props {
|
||||
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
||||
name: string;
|
||||
@@ -38,137 +41,441 @@
|
||||
longestMatch?: boolean;
|
||||
}
|
||||
let {
|
||||
telemetry, name, players = null, map = null, roster = null, health = 'offline',
|
||||
lastSeenAt = null, lastError = null, held = [], restarting = false,
|
||||
inactiveSince = null, gameStartedAt = null, gameEndedAt = null, compactEmpty = true,
|
||||
mapLabelOverride, round, capacity = null, pinned = false, onPin, longestMatch = false
|
||||
telemetry,
|
||||
name,
|
||||
players = null,
|
||||
map = null,
|
||||
roster = null,
|
||||
health = 'offline',
|
||||
lastSeenAt = null,
|
||||
lastError = null,
|
||||
held = [],
|
||||
restarting = false,
|
||||
inactiveSince = null,
|
||||
gameStartedAt = null,
|
||||
gameEndedAt = null,
|
||||
compactEmpty = true,
|
||||
mapLabelOverride,
|
||||
round,
|
||||
capacity = null,
|
||||
pinned = false,
|
||||
onPin,
|
||||
longestMatch = false
|
||||
}: Props = $props();
|
||||
const id = $props.id();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const identity = $derived(serverIdentity(name));
|
||||
const title = $derived(mapLabelOverride ?? identity.map);
|
||||
const game = $derived(identity.game === 'Black Ops I' ? 'BO1' : identity.game === 'Black Ops II' ? 'BO2' : identity.game);
|
||||
const game = $derived(
|
||||
identity.game === 'Black Ops I'
|
||||
? 'BO1'
|
||||
: identity.game === 'Black Ops II'
|
||||
? 'BO2'
|
||||
: identity.game
|
||||
);
|
||||
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
||||
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
||||
const error = $derived(decodeRconError(lastError));
|
||||
const mapLabel = $derived(mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
|
||||
const topRoster = $derived((roster ?? []).slice().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 4));
|
||||
const topRoster = $derived(
|
||||
(roster ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
.slice(0, 4)
|
||||
);
|
||||
const report = $derived.by(() => {
|
||||
const streamed = $telemetryByServer.get(name);
|
||||
return streamed && (!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt)) ? streamed : telemetry;
|
||||
return streamed &&
|
||||
(!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt))
|
||||
? streamed
|
||||
: telemetry;
|
||||
});
|
||||
const reportedRound = $derived(report && (report.event === 'heartbeat' || report.event === 'round_change')
|
||||
&& freshStatusTimestamp(report.receivedAt, $uiNow)
|
||||
&& (!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
|
||||
? report.round : null);
|
||||
const currentRound = $derived(!gameEndedAt ? (round !== undefined ? round : reportedRound) : null);
|
||||
const gameElapsed = $derived(gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||
const reportedRound = $derived(
|
||||
report &&
|
||||
(report.event === 'heartbeat' || report.event === 'round_change') &&
|
||||
freshStatusTimestamp(report.receivedAt, $uiNow) &&
|
||||
(!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
|
||||
? report.round
|
||||
: null
|
||||
);
|
||||
const currentRound = $derived(
|
||||
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null
|
||||
);
|
||||
const gameElapsed = $derived(
|
||||
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
|
||||
);
|
||||
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
||||
// These lobbies have four slots when the live status omits the limit.
|
||||
const displayCapacity = $derived(capacity !== null && Number.isSafeInteger(capacity) && capacity > 0 ? capacity : 4);
|
||||
const cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||
const displayCapacity = $derived(
|
||||
capacity !== null && Number.isSafeInteger(capacity) && capacity > 0 ? capacity : 4
|
||||
);
|
||||
const cardStyle = $derived(
|
||||
`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`
|
||||
);
|
||||
</script>
|
||||
|
||||
<Card as="article" padding={compact ? 'sm' : 'md'} class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`} ariaLabelledby={`${id}-title`} style={cardStyle}>
|
||||
<Card
|
||||
as="article"
|
||||
padding={compact ? 'sm' : 'md'}
|
||||
class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`}
|
||||
ariaLabelledby={`${id}-title`}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div class="head">
|
||||
<a class="identity" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)} aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}>
|
||||
<a
|
||||
class="identity"
|
||||
href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
|
||||
aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}
|
||||
>
|
||||
<h3 id={`${id}-title`}>{title}</h3>
|
||||
<span class="identity-meta">
|
||||
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span>{/if}
|
||||
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span
|
||||
>{/if}
|
||||
{#if game}<span class="game" title={identity.game}>{game}</span>{/if}
|
||||
{#if compact}<span class="empty-duration">{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span>{/if}
|
||||
{#if compact}<span class="empty-duration"
|
||||
>{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span
|
||||
>{/if}
|
||||
</span>
|
||||
</a>
|
||||
<div class="head-actions">
|
||||
<StatusBadge {state} />
|
||||
{#if onPin}<button class="pin" type="button" aria-pressed={pinned} aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`} title={pinned ? 'Unpin server' : 'Pin server'} onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button>{/if}
|
||||
{#if onPin}<button
|
||||
class="pin"
|
||||
type="button"
|
||||
aria-pressed={pinned}
|
||||
aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`}
|
||||
title={pinned ? 'Unpin server' : 'Pin server'}
|
||||
onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !compact}
|
||||
{#if longestMatch && health === 'online'}<div class="highlight"><Badge>Longest match</Badge></div>{/if}
|
||||
{#if showMap}<p class="map-note">{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</p>{/if}
|
||||
{#if longestMatch && health === 'online'}<div class="highlight">
|
||||
<Badge>Longest match</Badge>
|
||||
</div>{/if}
|
||||
{#if showMap}<p class="map-note">
|
||||
{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}
|
||||
</p>{/if}
|
||||
{#if health === 'online'}
|
||||
<dl class="metrics">
|
||||
<div><dt>Players</dt><dd title={`${displayCapacity} player capacity`}>{players ?? '—'}<span class="capacity">/{displayCapacity}</span></dd></div>
|
||||
<div><dt>Round</dt><dd>{currentRound ?? '—'}</dd></div>
|
||||
<div><dt>Match time</dt><dd>{gameElapsed ?? '—'}</dd></div>
|
||||
<div>
|
||||
<dt>Players</dt>
|
||||
<dd title={`${displayCapacity} player capacity`}>
|
||||
{players ?? '—'}<span class="capacity">/{displayCapacity}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Round</dt>
|
||||
<dd>{currentRound ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Match time</dt>
|
||||
<dd>{gameElapsed ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if gameEndedAt && players !== 0}<p class="match-note">Match ended · waiting for the next match</p>{/if}
|
||||
{#if gameEndedAt && players !== 0}<p class="match-note">
|
||||
Match ended · waiting for the next match
|
||||
</p>{/if}
|
||||
{:else if health === 'stale'}
|
||||
<p class="problem-note"><strong>Current player count unknown.</strong> Last reported: {players ?? 'unknown'}{players !== null ? ` ${players === 1 ? 'player' : 'players'}` : ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.</p>
|
||||
<p class="problem-note">
|
||||
<strong>Current player count unknown.</strong> Last reported: {players ??
|
||||
'unknown'}{players !== null
|
||||
? ` ${players === 1 ? 'player' : 'players'}`
|
||||
: ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if health === 'online' || health === 'stale'}
|
||||
{#if topRoster.length}
|
||||
<div class="roster">
|
||||
<table aria-label={health === 'stale' ? 'Last reported players' : 'Current players'}>
|
||||
<thead><tr><th scope="col">Player</th><th scope="col">Score</th><th scope="col">Ping <span class="ping-unit">(ms)</span></th></tr></thead>
|
||||
<tbody>{#each topRoster as player (player.guid ?? player.num)}<tr>
|
||||
<td title={player.name ?? undefined}>{player.name || '—'}</td>
|
||||
<td>{player.score === null ? '—' : number.format(player.score)}</td>
|
||||
<td>{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
<table
|
||||
aria-label={health === 'stale'
|
||||
? 'Last reported players'
|
||||
: 'Current players'}
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Player</th><th scope="col">Score</th><th
|
||||
scope="col">Ping <span class="ping-unit">(ms)</span></th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each topRoster as player (player.guid ?? player.num)}<tr>
|
||||
<td title={player.name ?? undefined}>{player.name || '—'}</td>
|
||||
<td
|
||||
>{player.score === null
|
||||
? '—'
|
||||
: number.format(player.score)}</td
|
||||
>
|
||||
<td
|
||||
>{player.ping === 999
|
||||
? 'Connecting'
|
||||
: player.ping === null
|
||||
? '—'
|
||||
: number.format(player.ping)}</td
|
||||
>
|
||||
</tr>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{:else if players !== 0}<p class="roster-unavailable">{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}</p>{/if}
|
||||
{:else if players !== 0}<p class="roster-unavailable">
|
||||
{health === 'stale'
|
||||
? 'Waiting for a fresh report'
|
||||
: 'Player roster unavailable'}
|
||||
</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if health === 'offline'}
|
||||
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</p>
|
||||
{#if lastSeenAt}<p class="last-report">Last report {elapsedSince(lastSeenAt, $uiNow)} ago</p>{/if}
|
||||
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>
|
||||
{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}
|
||||
</p>
|
||||
{#if lastSeenAt}<p class="last-report">
|
||||
Last report {elapsedSince(lastSeenAt, $uiNow)} ago
|
||||
</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if held.length > 0}<div class="held">{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}>β {h.target}</Badge>{/each}</div>{/if}
|
||||
{#if held.length > 0}<div class="held">
|
||||
{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}
|
||||
>β {h.target}</Badge
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
:global(.card.server-card) { gap: var(--space-3); transition: border-color var(--dur-fast) var(--ease-standard); }
|
||||
:global(.card.server-card:hover) { border-color: var(--border-strong); }
|
||||
:global(.card.server-card.offline) { border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
|
||||
.identity { min-width: 0; color: inherit; text-decoration: none; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.identity::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius-lg); }
|
||||
.identity:focus-visible { outline: none; }
|
||||
.identity:focus-visible::after { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
h3 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); line-height: var(--leading-tight); overflow-wrap: anywhere; }
|
||||
.identity-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.game { background: var(--surface-raised); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 2px 5px; white-space: nowrap; font-weight: var(--weight-semibold); }
|
||||
.head-actions { display: flex; align-items: center; flex-shrink: 0; gap: var(--space-1); }
|
||||
.pin { position: relative; z-index: 1; display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; margin: -4px -6px -4px 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; font-size: var(--text-lg); }
|
||||
.pin:hover, .pin[aria-pressed='true'] { color: var(--accent-primary-hover); background: var(--surface-raised); }
|
||||
.pin:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.metrics { display: grid; grid-template-columns: 1fr 1fr minmax(0, 1.5fr); gap: var(--space-3); margin: 0; }
|
||||
.metrics > div { display: flex; flex-direction: column-reverse; align-items: flex-start; gap: var(--space-1); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-xl); font-weight: var(--weight-semibold); line-height: var(--leading-tight); white-space: nowrap; }
|
||||
dd[title] { position: relative; z-index: 1; cursor: help; }
|
||||
.capacity { font-size: var(--text-sm); font-weight: var(--weight-regular); color: var(--text-secondary); }
|
||||
.roster { border-top: 1px solid var(--border-subtle); padding-top: var(--space-2); }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-regular); padding: 2px 0 6px; }
|
||||
td { padding: 3px 0; }
|
||||
th, td { text-align: right; }
|
||||
th:first-child, td:first-child { width: 52%; text-align: left; padding-right: var(--space-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
td:not(:first-child) { color: var(--text-secondary); }
|
||||
.ping-unit { font-size: var(--text-xs); }
|
||||
.map-note, .problem-note, .last-report, .roster-unavailable, .match-note { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.problem-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.last-report, .match-note { font-size: var(--text-xs); }
|
||||
.held { display: flex; flex-wrap: wrap; gap: var(--space-1); }
|
||||
:global(.compact) .head { align-items: center; }
|
||||
:global(.compact) .identity { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
|
||||
:global(.compact) h3 { font-size: var(--text-base); }
|
||||
:global(.card.server-card) {
|
||||
gap: var(--space-3);
|
||||
transition: border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
:global(.card.server-card:hover) {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
:global(.card.server-card.offline) {
|
||||
border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle));
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.identity::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.identity:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.identity:focus-visible::after {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.game {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 5px;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.pin {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
margin: -4px -6px -4px 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.pin:hover,
|
||||
.pin[aria-pressed='true'] {
|
||||
color: var(--accent-primary-hover);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.pin:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(0, 1.5fr);
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
.metrics > div {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
dd[title] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
cursor: help;
|
||||
}
|
||||
.capacity {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-regular);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.roster {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-regular);
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
td {
|
||||
padding: 3px 0;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
}
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
width: 52%;
|
||||
text-align: left;
|
||||
padding-right: var(--space-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td:not(:first-child) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ping-unit {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.map-note,
|
||||
.problem-note,
|
||||
.last-report,
|
||||
.roster-unavailable,
|
||||
.match-note {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.problem-note strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.last-report,
|
||||
.match-note {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.held {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
:global(.compact) .head {
|
||||
align-items: center;
|
||||
}
|
||||
:global(.compact) .identity {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
:global(.compact) h3 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(.card.server-card) { padding: var(--space-3); gap: var(--space-2); }
|
||||
.roster, .roster-unavailable { display: none; }
|
||||
.pin { width: 44px; height: 44px; margin: -8px -6px -8px 0; }
|
||||
h3 { font-size: var(--text-base); }
|
||||
dd { font-size: var(--text-lg); }
|
||||
.head-actions { gap: 0; }
|
||||
.metrics { gap: var(--space-2); }
|
||||
:global(.compact) .identity { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||
:global(.card.server-card) {
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.roster,
|
||||
.roster-unavailable {
|
||||
display: none;
|
||||
}
|
||||
.pin {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -8px -6px -8px 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
dd {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.head-actions {
|
||||
gap: 0;
|
||||
}
|
||||
.metrics {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
:global(.compact) .identity {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { serverIdentity } from '../server-labels.js';
|
||||
let { name, mapLabel, showGame = true }: { name: string; mapLabel?: string; showGame?: boolean } = $props();
|
||||
let {
|
||||
name,
|
||||
mapLabel,
|
||||
showGame = true
|
||||
}: { name: string; mapLabel?: string; showGame?: boolean } = $props();
|
||||
const identity = $derived(serverIdentity(name));
|
||||
</script>
|
||||
<span class="identity" title={name}><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span class="lobby">Lobby {identity.lobby}</span>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span>
|
||||
|
||||
<span class="identity" title={name}
|
||||
><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span
|
||||
class="lobby">Lobby {identity.lobby}</span
|
||||
>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span
|
||||
>
|
||||
|
||||
<style>
|
||||
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
|
||||
.map { overflow-wrap: anywhere; }
|
||||
.lobby { white-space: nowrap; font-size: max(var(--text-xs), .85em); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
|
||||
.identity {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.map {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.lobby {
|
||||
white-space: nowrap;
|
||||
font-size: max(var(--text-xs), 0.85em);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
small {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import RetentionTools from './RetentionTools.svelte';
|
||||
import {
|
||||
defaultFleetSettings, fleetSettingLabels, settingsGroups, settingHelp, settingMinimum,
|
||||
settingZeroMode, settingUnit, settingDisplayValue, validateFleetSettings, type FleetSettings
|
||||
defaultFleetSettings,
|
||||
fleetSettingLabels,
|
||||
settingsGroups,
|
||||
settingHelp,
|
||||
settingMinimum,
|
||||
settingZeroMode,
|
||||
settingUnit,
|
||||
settingDisplayValue,
|
||||
validateFleetSettings,
|
||||
type FleetSettings
|
||||
} from '$lib/fleet-settings.js';
|
||||
|
||||
type Key = keyof FleetSettings;
|
||||
@@ -16,7 +24,9 @@
|
||||
const keys = Object.keys(defaultFleetSettings) as Key[];
|
||||
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
|
||||
let draft = $state<Draft>({ ...untrack(() => initial) });
|
||||
let remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
|
||||
let remembered = Object.fromEntries(
|
||||
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
|
||||
) as Partial<FleetSettings>;
|
||||
let query = $state('');
|
||||
let selectedGroup = $state(settingsGroups[0].id);
|
||||
let reviewInvalid = $state(false);
|
||||
@@ -24,21 +34,34 @@
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
let allowNavigation = false;
|
||||
const changed = $derived(keys.filter(key => draft[key] !== baseline[key]));
|
||||
const invalid = $derived(keys.filter(key => !!validationMessage(key, draft[key])));
|
||||
const changed = $derived(keys.filter((key) => draft[key] !== baseline[key]));
|
||||
const invalid = $derived(keys.filter((key) => !!validationMessage(key, draft[key])));
|
||||
const visibleGroups = $derived.by(() => {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
return settingsGroups.map(group => ({ ...group, keys: group.keys.filter(key => {
|
||||
if (reviewInvalid && invalid.length) return invalid.includes(key);
|
||||
if (!terms.length) return group.id === selectedGroup;
|
||||
const text = `${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
|
||||
return terms.every(term => text.includes(term));
|
||||
}) })).filter(group => group.keys.length);
|
||||
return settingsGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
keys: group.keys.filter((key) => {
|
||||
if (reviewInvalid && invalid.length) return invalid.includes(key);
|
||||
if (!terms.length) return group.id === selectedGroup;
|
||||
const text =
|
||||
`${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
|
||||
return terms.every((term) => text.includes(term));
|
||||
})
|
||||
}))
|
||||
.filter((group) => group.keys.length);
|
||||
});
|
||||
const shownCount = $derived(visibleGroups.reduce((count, group) => count + group.keys.length, 0));
|
||||
const shownCount = $derived(
|
||||
visibleGroups.reduce((count, group) => count + group.keys.length, 0)
|
||||
);
|
||||
|
||||
function validationMessage(key: Key, value: number | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < settingMinimum(key)) return 'Enter a valid whole number.';
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < settingMinimum(key)
|
||||
)
|
||||
return 'Enter a valid whole number.';
|
||||
if (key.endsWith('Days') && value > 36500) return 'Enter 36,500 days or fewer.';
|
||||
return '';
|
||||
}
|
||||
@@ -46,63 +69,132 @@
|
||||
if (saving) return;
|
||||
const current = draft[key];
|
||||
if (enabled) {
|
||||
if (current !== undefined && Number.isSafeInteger(current) && current > 0) remembered[key] = current;
|
||||
if (current !== undefined && Number.isSafeInteger(current) && current > 0)
|
||||
remembered[key] = current;
|
||||
draft[key] = 0;
|
||||
} else {
|
||||
draft[key] = remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
|
||||
draft[key] =
|
||||
remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
|
||||
}
|
||||
error = ''; notice = '';
|
||||
error = '';
|
||||
notice = '';
|
||||
}
|
||||
function selectGroup(groupId: string) {
|
||||
selectedGroup = groupId;
|
||||
query = '';
|
||||
reviewInvalid = false;
|
||||
}
|
||||
function selectGroup(groupId: string) { selectedGroup = groupId; query = ''; reviewInvalid = false; }
|
||||
function discard() {
|
||||
if (saving) return;
|
||||
draft = { ...baseline };
|
||||
remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
|
||||
error = ''; notice = ''; reviewInvalid = false;
|
||||
remembered = Object.fromEntries(
|
||||
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
|
||||
) as Partial<FleetSettings>;
|
||||
error = '';
|
||||
notice = '';
|
||||
reviewInvalid = false;
|
||||
}
|
||||
async function save() {
|
||||
if (saving || !changed.length || invalid.length) return;
|
||||
saving = true; error = ''; notice = '';
|
||||
saving = true;
|
||||
error = '';
|
||||
notice = '';
|
||||
try {
|
||||
const values = validateFleetSettings(draft);
|
||||
const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
|
||||
baseline = validateFleetSettings(result);
|
||||
draft = { ...baseline };
|
||||
for (const key of keys) if (baseline[key] > 0) remembered[key] = baseline[key];
|
||||
notice = 'Settings saved. Changes apply to the next queue or cleanup check.';
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not save fleet settings.'; }
|
||||
finally { saving = false; }
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not save fleet settings.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(({ cancel, to, from, type }) => {
|
||||
if (!changed.length || allowNavigation || type === 'leave' || !to || to.url.pathname === from?.url.pathname) return;
|
||||
if (
|
||||
!changed.length ||
|
||||
allowNavigation ||
|
||||
type === 'leave' ||
|
||||
!to ||
|
||||
to.url.pathname === from?.url.pathname
|
||||
)
|
||||
return;
|
||||
cancel();
|
||||
void confirmAction({ title: 'Leave Settings?', message: 'Your unsaved settings will be discarded.', confirmLabel: 'Leave settings', danger: true }).then(async accepted => {
|
||||
void confirmAction({
|
||||
title: 'Leave Settings?',
|
||||
message: 'Your unsaved settings will be discarded.',
|
||||
confirmLabel: 'Leave settings',
|
||||
danger: true
|
||||
}).then(async (accepted) => {
|
||||
if (!accepted) return;
|
||||
allowNavigation = true;
|
||||
try { await goto(to.url); } finally { allowNavigation = false; }
|
||||
try {
|
||||
await goto(to.url);
|
||||
} finally {
|
||||
allowNavigation = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (changed.length) { event.preventDefault(); event.returnValue = ''; } }
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (changed.length) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={beforeUnload} />
|
||||
<div class="settings-layout">
|
||||
<aside class="section-rail">
|
||||
<label class="search-label" for="settings-search">Find a setting</label>
|
||||
<input id="settings-search" type="search" placeholder="Search settings" bind:value={query} disabled={saving} oninput={() => reviewInvalid = false} />
|
||||
<label class="mobile-section">Section
|
||||
<select aria-label="Settings section" value={selectedGroup} disabled={saving} onchange={event => selectGroup(event.currentTarget.value)}>
|
||||
{#each settingsGroups as group}<option value={group.id}>{group.title}{changed.some(key => group.keys.includes(key)) ? ` · ${changed.filter(key => group.keys.includes(key)).length} changed` : ''}</option>{/each}
|
||||
<input
|
||||
id="settings-search"
|
||||
type="search"
|
||||
placeholder="Search settings"
|
||||
bind:value={query}
|
||||
disabled={saving}
|
||||
oninput={() => (reviewInvalid = false)}
|
||||
/>
|
||||
<label class="mobile-section"
|
||||
>Section
|
||||
<select
|
||||
aria-label="Settings section"
|
||||
value={selectedGroup}
|
||||
disabled={saving}
|
||||
onchange={(event) => selectGroup(event.currentTarget.value)}
|
||||
>
|
||||
{#each settingsGroups as group}<option value={group.id}
|
||||
>{group.title}{changed.some((key) => group.keys.includes(key))
|
||||
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
|
||||
: ''}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<nav class="group-links" aria-label="Settings sections">
|
||||
{#each settingsGroups as group}
|
||||
{@const count = changed.filter(key => group.keys.includes(key)).length}
|
||||
<a href={`#settings-group-${group.id}`} aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id ? 'location' : undefined} onclick={event => { event.preventDefault(); if (!saving) selectGroup(group.id); }}>
|
||||
<span>{group.title}</span>{#if count}<span class="changed-count">{count} changed</span>{/if}
|
||||
{@const count = changed.filter((key) => group.keys.includes(key)).length}
|
||||
<a
|
||||
href={`#settings-group-${group.id}`}
|
||||
aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id
|
||||
? 'location'
|
||||
: undefined}
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
if (!saving) selectGroup(group.id);
|
||||
}}
|
||||
>
|
||||
<span>{group.title}</span>{#if count}<span class="changed-count"
|
||||
>{count} changed</span
|
||||
>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -110,39 +202,140 @@
|
||||
<div class="settings-content">
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if query.trim()}<p class="search-results">{shownCount} {shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across all sections. <button type="button" class="text-button" onclick={() => query = ''}>Clear search</button></p>{/if}
|
||||
{#if reviewInvalid && invalid.length}<p class="search-results">Showing invalid settings across all sections. <button type="button" class="text-button" onclick={() => reviewInvalid = false}>Show selected section</button></p>{/if}
|
||||
<form id={`${id}-form`} onsubmit={event => { event.preventDefault(); void save(); }}>
|
||||
{#if query.trim()}<p class="search-results">
|
||||
{shownCount}
|
||||
{shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across
|
||||
all sections.
|
||||
<button type="button" class="text-button" onclick={() => (query = '')}
|
||||
>Clear search</button
|
||||
>
|
||||
</p>{/if}
|
||||
{#if reviewInvalid && invalid.length}<p class="search-results">
|
||||
Showing invalid settings across all sections. <button
|
||||
type="button"
|
||||
class="text-button"
|
||||
onclick={() => (reviewInvalid = false)}>Show selected section</button
|
||||
>
|
||||
</p>{/if}
|
||||
<form
|
||||
id={`${id}-form`}
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={saving}>
|
||||
<legend class="sr-only">Fleet settings</legend>
|
||||
{#each visibleGroups as group (group.id)}
|
||||
<section class="settings-group" id={`settings-group-${group.id}`} aria-labelledby={`settings-heading-${group.id}`}>
|
||||
<header><h2 id={`settings-heading-${group.id}`}>{group.title}</h2><p>{group.description}</p></header>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Default</th><th scope="col">What it affects</th></tr></thead>
|
||||
<tbody>{#each group.keys as key (key)}
|
||||
{@const zeroMode = settingZeroMode(key)}
|
||||
{@const unlimited = !!zeroMode && draft[key] === 0}
|
||||
{@const validation = validationMessage(key, draft[key])}
|
||||
<tr data-setting={key} class:changed={changed.includes(key)}>
|
||||
<th scope="row"><label for={`setting-${key}`}>{fleetSettingLabels[key]}</label>{#if changed.includes(key)}<span class="changed-marker">Changed</span>{/if}</th>
|
||||
<td class="value-cell">
|
||||
<div class="numeric-value">
|
||||
{#if unlimited}<input id={`setting-${key}`} type="number" disabled placeholder="—" aria-describedby={`setting-${key}-help setting-${key}-default`} />
|
||||
{:else}<input id={`setting-${key}`} type="number" min={Math.max(1, settingMinimum(key))} max={key.endsWith('Days') ? 36500 : undefined} step="1" required bind:value={draft[key]} aria-invalid={!!validation} aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`} oninput={() => { error = ''; notice = ''; }} />{/if}
|
||||
<span class="unit">{settingUnit(key)}</span>
|
||||
</div>
|
||||
{#if zeroMode}<label class="zero-mode" for={`setting-${key}-zero`}><input id={`setting-${key}-zero`} type="checkbox" checked={unlimited} onchange={event => toggleMode(key, event.currentTarget.checked)} aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`} aria-describedby={`setting-${key}-help`} /><span>{zeroMode}</span></label>{/if}
|
||||
{#if validation}<p class="field-error" id={`setting-${key}-error`}>{validation}</p>{/if}
|
||||
</td>
|
||||
<td class="default-value" id={`setting-${key}-default`}><span class="mobile-label">Default: </span>{settingDisplayValue(key, defaultFleetSettings[key])}</td>
|
||||
<td class="help" id={`setting-${key}-help`}>{settingHelp[key]}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{#if group.id === 'concurrency'}<p class="group-note">Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>{/if}
|
||||
<section
|
||||
class="settings-group"
|
||||
id={`settings-group-${group.id}`}
|
||||
aria-labelledby={`settings-heading-${group.id}`}
|
||||
>
|
||||
<header>
|
||||
<h2 id={`settings-heading-${group.id}`}>{group.title}</h2>
|
||||
<p>{group.description}</p>
|
||||
</header>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Setting</th><th scope="col">Value</th><th
|
||||
scope="col">Default</th
|
||||
><th scope="col">What it affects</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each group.keys as key (key)}
|
||||
{@const zeroMode = settingZeroMode(key)}
|
||||
{@const unlimited = !!zeroMode && draft[key] === 0}
|
||||
{@const validation = validationMessage(key, draft[key])}
|
||||
<tr
|
||||
data-setting={key}
|
||||
class:changed={changed.includes(key)}
|
||||
>
|
||||
<th scope="row"
|
||||
><label for={`setting-${key}`}
|
||||
>{fleetSettingLabels[key]}</label
|
||||
>{#if changed.includes(key)}<span
|
||||
class="changed-marker">Changed</span
|
||||
>{/if}</th
|
||||
>
|
||||
<td class="value-cell">
|
||||
<div class="numeric-value">
|
||||
{#if unlimited}<input
|
||||
id={`setting-${key}`}
|
||||
type="number"
|
||||
disabled
|
||||
placeholder="—"
|
||||
aria-describedby={`setting-${key}-help setting-${key}-default`}
|
||||
/>
|
||||
{:else}<input
|
||||
id={`setting-${key}`}
|
||||
type="number"
|
||||
min={Math.max(1, settingMinimum(key))}
|
||||
max={key.endsWith('Days')
|
||||
? 36500
|
||||
: undefined}
|
||||
step="1"
|
||||
required
|
||||
bind:value={draft[key]}
|
||||
aria-invalid={!!validation}
|
||||
aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`}
|
||||
oninput={() => {
|
||||
error = '';
|
||||
notice = '';
|
||||
}}
|
||||
/>{/if}
|
||||
<span class="unit">{settingUnit(key)}</span>
|
||||
</div>
|
||||
{#if zeroMode}<label
|
||||
class="zero-mode"
|
||||
for={`setting-${key}-zero`}
|
||||
><input
|
||||
id={`setting-${key}-zero`}
|
||||
type="checkbox"
|
||||
checked={unlimited}
|
||||
onchange={(event) =>
|
||||
toggleMode(
|
||||
key,
|
||||
event.currentTarget.checked
|
||||
)}
|
||||
aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`}
|
||||
aria-describedby={`setting-${key}-help`}
|
||||
/><span>{zeroMode}</span></label
|
||||
>{/if}
|
||||
{#if validation}<p
|
||||
class="field-error"
|
||||
id={`setting-${key}-error`}
|
||||
>
|
||||
{validation}
|
||||
</p>{/if}
|
||||
</td>
|
||||
<td class="default-value" id={`setting-${key}-default`}
|
||||
><span class="mobile-label"
|
||||
>Default:
|
||||
</span>{settingDisplayValue(
|
||||
key,
|
||||
defaultFleetSettings[key]
|
||||
)}</td
|
||||
>
|
||||
<td class="help" id={`setting-${key}-help`}
|
||||
>{settingHelp[key]}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if group.id === 'concurrency'}<p class="group-note">
|
||||
Lowering a limit lets active operations finish; queued work waits
|
||||
for capacity. Player safety checks still apply.
|
||||
</p>{/if}
|
||||
</section>
|
||||
{:else}<p class="no-results">No settings match this search. Try a setting name, a section, or “Forever”.</p>{/each}
|
||||
{:else}<p class="no-results">
|
||||
No settings match this search. Try a setting name, a section, or “Forever”.
|
||||
</p>{/each}
|
||||
</fieldset>
|
||||
</form>
|
||||
<div class="retention-tools">
|
||||
@@ -156,87 +349,421 @@
|
||||
</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>
|
||||
<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}
|
||||
{#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 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>
|
||||
|
||||
<style>
|
||||
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
||||
.section-rail { position: sticky; top: 80px; display: grid; gap: var(--space-2); min-width: 0; }
|
||||
.search-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
#settings-search { width: 100%; }
|
||||
.group-links { display: grid; gap: var(--space-1); margin-top: var(--space-3); }
|
||||
.group-links a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 40px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); text-decoration: none; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.group-links a:hover, .group-links a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.group-links a[aria-current] { font-weight: var(--weight-semibold); }
|
||||
.changed-count { font-size: var(--text-xs); color: var(--state-warning); white-space: nowrap; }
|
||||
.settings-content, fieldset { min-width: 0; }
|
||||
fieldset { border: 0; padding: 0; margin: 0; }
|
||||
.settings-group { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-4); }
|
||||
.settings-group header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
header p { margin: 0; font-size: var(--text-xs); }
|
||||
.table-wrap { position: relative; min-width: 0; overflow-x: auto; }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-xs); text-align: left; }
|
||||
th, td { padding: var(--space-3); vertical-align: top; border-bottom: 1px solid var(--border-subtle); }
|
||||
th { font-weight: var(--weight-medium); }
|
||||
thead th { color: var(--text-secondary); font-weight: var(--weight-semibold); }
|
||||
thead th:nth-child(1) { width: 23%; }
|
||||
thead th:nth-child(2) { width: 25%; }
|
||||
thead th:nth-child(3) { width: 15%; }
|
||||
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
|
||||
.changed { background: color-mix(in srgb, var(--state-warning) 3%, transparent); }
|
||||
.changed-marker { display: block; margin-top: var(--space-1); color: var(--state-warning); font-size: var(--text-xs); }
|
||||
.numeric-value { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1); }
|
||||
.numeric-value input { width: 88px; min-height: 32px; padding: 5px var(--space-2); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
input:disabled { color: var(--text-tertiary); opacity: 0.75; }
|
||||
.unit { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.zero-mode { display: inline-flex; align-items: center; gap: var(--space-1); margin-top: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
|
||||
.zero-mode input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); }
|
||||
.default-value, .help { color: var(--text-secondary); line-height: var(--leading-body); }
|
||||
.default-value { font-variant-numeric: tabular-nums; }
|
||||
.field-error { margin: var(--space-1) 0 0; color: var(--state-danger); font-size: var(--text-xs); }
|
||||
.group-note { margin: 0; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); }
|
||||
.search-results { margin-top: 0; }
|
||||
.text-button { padding: 0; background: none; border: 0; color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
.retention-tools { margin-top: var(--space-4); }
|
||||
.save-bar { position: sticky; bottom: var(--space-3); z-index: 10; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); margin-top: var(--space-5); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-raised); }
|
||||
.save-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.save-summary strong { font-weight: var(--weight-semibold); color: var(--text-primary); }
|
||||
.save-summary strong.has-changes { color: var(--state-warning); }
|
||||
.save-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
|
||||
.save-button { min-height: 40px; padding: var(--space-2) var(--space-4); border: 1px solid transparent; border-radius: var(--radius-md); background: var(--accent-primary); color: var(--text-on-accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
|
||||
.save-button:hover:not(:disabled) { background: var(--accent-primary-hover); }
|
||||
.save-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.invalid-summary { color: var(--state-danger); }
|
||||
.notice { color: var(--state-online); }
|
||||
.mobile-section, .mobile-label { display: none; }
|
||||
@media (max-width: 1100px) { .settings-layout { grid-template-columns: 180px minmax(0, 1fr); gap: var(--space-4); } thead th:nth-child(1) { width: 25%; } thead th:nth-child(2) { width: 27%; } thead th:nth-child(3) { width: 16%; } th, td { padding: var(--space-2); } }
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
}
|
||||
.section-rail {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.search-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
#settings-search {
|
||||
width: 100%;
|
||||
}
|
||||
.group-links {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.group-links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.group-links a:hover,
|
||||
.group-links a[aria-current] {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.group-links a[aria-current] {
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.changed-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--state-warning);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-content,
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.settings-group {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.settings-group header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
th {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
thead th:nth-child(1) {
|
||||
width: 23%;
|
||||
}
|
||||
thead th:nth-child(2) {
|
||||
width: 25%;
|
||||
}
|
||||
thead th:nth-child(3) {
|
||||
width: 15%;
|
||||
}
|
||||
tbody tr:last-child th,
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.changed {
|
||||
background: color-mix(in srgb, var(--state-warning) 3%, transparent);
|
||||
}
|
||||
.changed-marker {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.numeric-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.numeric-value input {
|
||||
width: 88px;
|
||||
min-height: 32px;
|
||||
padding: 5px var(--space-2);
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input:disabled {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.unit {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.zero-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.zero-mode input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.default-value,
|
||||
.help {
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.default-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.field-error {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--state-danger);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.group-note {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.search-results {
|
||||
margin-top: 0;
|
||||
}
|
||||
.text-button {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--accent-primary-hover);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retention-tools {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: var(--space-3);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
.save-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.save-summary strong {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.save-summary strong.has-changes {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.save-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
.save-button {
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-button:hover:not(:disabled) {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
.save-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.invalid-summary {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.notice {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.mobile-section,
|
||||
.mobile-label {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.settings-layout {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
thead th:nth-child(1) {
|
||||
width: 25%;
|
||||
}
|
||||
thead th:nth-child(2) {
|
||||
width: 27%;
|
||||
}
|
||||
thead th:nth-child(3) {
|
||||
width: 16%;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
|
||||
.section-rail { position: static; }
|
||||
.group-links { display: none; }
|
||||
.mobile-section { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-3); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.mobile-section select { width: 100%; }
|
||||
.table-wrap { overflow: visible; }
|
||||
table, tbody { display: block; }
|
||||
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2); padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
|
||||
tbody tr:last-child { border-bottom: 0; }
|
||||
th, td { display: block; padding: 0; border: 0; }
|
||||
tbody th { grid-column: 1 / -1; font-size: var(--text-sm); }
|
||||
.changed-marker { display: inline; margin-left: var(--space-2); }
|
||||
.value-cell { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
|
||||
.zero-mode { min-height: 44px; margin-top: 0; }
|
||||
.zero-mode input { width: 20px; height: 20px; }
|
||||
.default-value, .help { grid-column: 1 / -1; }
|
||||
.mobile-label { display: inline; }
|
||||
.save-bar { bottom: max(var(--space-2), env(safe-area-inset-bottom)); padding: var(--space-3); gap: var(--space-2); }
|
||||
.save-summary { gap: var(--space-1); flex-direction: column; align-items: flex-start; }
|
||||
.save-actions { width: 100%; justify-content: flex-end; }
|
||||
.settings-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.section-rail {
|
||||
position: static;
|
||||
}
|
||||
.group-links {
|
||||
display: none;
|
||||
}
|
||||
.mobile-section {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.mobile-section select {
|
||||
width: 100%;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
table,
|
||||
tbody {
|
||||
display: block;
|
||||
}
|
||||
thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
tbody tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
tbody tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
tbody th {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.changed-marker {
|
||||
display: inline;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
.value-cell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
.zero-mode {
|
||||
min-height: 44px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.zero-mode input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.default-value,
|
||||
.help {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.mobile-label {
|
||||
display: inline;
|
||||
}
|
||||
.save-bar {
|
||||
bottom: max(var(--space-2), env(safe-area-inset-bottom));
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.save-summary {
|
||||
gap: var(--space-1);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.save-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
|
||||
import {
|
||||
TELEMETRY_REQUEST_RETENTION,
|
||||
type TelemetryRequestLogEntry
|
||||
} from '$lib/telemetry-diagnostics.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
@@ -12,7 +15,9 @@
|
||||
|
||||
let { server }: { server: string } = $props();
|
||||
let requests = $state<TelemetryRequestLogEntry[]>([]);
|
||||
let retention = $state<{ maxPerServer: number; maxAgeDays: number }>({ ...TELEMETRY_REQUEST_RETENTION });
|
||||
let retention = $state<{ maxPerServer: number; maxAgeDays: number }>({
|
||||
...TELEMETRY_REQUEST_RETENTION
|
||||
});
|
||||
let refreshing = $state(false);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
@@ -31,13 +36,18 @@
|
||||
controller = requestController;
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(currentServer)}/telemetry-requests`, {
|
||||
cache: 'no-store',
|
||||
signal: requestController.signal
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/servers/${encodeURIComponent(currentServer)}/telemetry-requests`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
signal: requestController.signal
|
||||
}
|
||||
);
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
|
||||
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`);
|
||||
throw new Error(
|
||||
body?.error || `Could not load telemetry requests (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
if (!active || server !== currentServer || requestController.signal.aborted) return;
|
||||
requests = body.requests;
|
||||
@@ -47,7 +57,8 @@
|
||||
refreshedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (active && server === currentServer && !requestController.signal.aborted) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
||||
error =
|
||||
cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
@@ -78,13 +89,21 @@
|
||||
<h2>Telemetry API requests</h2>
|
||||
<p class="server-name">{serverTitle(server)}</p>
|
||||
</div>
|
||||
<Freshness updatedAt={refreshedAt} error={error ? 'Telemetry request updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void refresh()} retryDisabled={refreshing} />
|
||||
<Freshness
|
||||
updatedAt={refreshedAt}
|
||||
error={error ? 'Telemetry request updates interrupted.' : ''}
|
||||
intervalLabel="Updates every 5 seconds"
|
||||
onRetry={() => void refresh()}
|
||||
retryDisabled={refreshing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="load-error" role="alert">
|
||||
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
|
||||
</p><details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
Couldn’t load telemetry requests. {#if loaded}
|
||||
Previously loaded requests are shown below.{/if}
|
||||
</p>
|
||||
<details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
{/if}
|
||||
|
||||
{#if !loaded && !error}
|
||||
@@ -100,7 +119,13 @@
|
||||
<li class="request">
|
||||
<div class="request-head">
|
||||
<div class="request-result">
|
||||
<Badge variant={request.status >= 500 ? 'danger' : request.status >= 400 ? 'warning' : 'online'}>
|
||||
<Badge
|
||||
variant={request.status >= 500
|
||||
? 'danger'
|
||||
: request.status >= 400
|
||||
? 'warning'
|
||||
: 'online'}
|
||||
>
|
||||
{outcome(request.status)} · HTTP {request.status}
|
||||
</Badge>
|
||||
<code class="event">{request.event ?? 'Event not provided'}</code>
|
||||
@@ -108,16 +133,32 @@
|
||||
<span class="received"><RelativeTime value={request.receivedAt} /></span>
|
||||
</div>
|
||||
<dl class="request-meta">
|
||||
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
|
||||
<div><dt>Round</dt><dd>{request.round ?? 'Not provided'}</dd></div>
|
||||
<div><dt>Players</dt><dd>{request.playerCount ?? 'Not provided'}</dd></div>
|
||||
<div>
|
||||
<dt>Map</dt>
|
||||
<dd><code>{request.map ?? 'Not provided'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Round</dt>
|
||||
<dd>{request.round ?? 'Not provided'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Players</dt>
|
||||
<dd>{request.playerCount ?? 'Not provided'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if request.error}<p class="request-error">{request.error}</p>{/if}
|
||||
<details class="payload">
|
||||
<summary>Request payload{#if request.payloadTruncated} (truncated){/if}</summary>
|
||||
<p class="content-type">Content type: <code>{request.contentType ?? 'Not provided'}</code></p>
|
||||
<summary
|
||||
>Request payload{#if request.payloadTruncated}
|
||||
(truncated){/if}</summary
|
||||
>
|
||||
<p class="content-type">
|
||||
Content type: <code>{request.contentType ?? 'Not provided'}</code>
|
||||
</p>
|
||||
<pre>{request.payload}</pre>
|
||||
{#if request.payloadTruncated}<p class="truncation">Only the beginning of this payload was retained.</p>{/if}
|
||||
{#if request.payloadTruncated}<p class="truncation">
|
||||
Only the beginning of this payload was retained.
|
||||
</p>{/if}
|
||||
</details>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -125,59 +166,155 @@
|
||||
<ListFooter total={requests.length} bind:limit label="requests" />
|
||||
{/if}
|
||||
|
||||
<details class="request-help"><summary>About request logging</summary> <p class="intro">
|
||||
Incoming <code>POST /api/telemetry</code> requests with the exact server name <code>{server}</code>.
|
||||
Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
|
||||
</p>
|
||||
<p class="retention">
|
||||
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
|
||||
</p>
|
||||
<details class="request-help">
|
||||
<summary>About request logging</summary>
|
||||
<p class="intro">
|
||||
Incoming <code>POST /api/telemetry</code> requests with the exact server name
|
||||
<code>{server}</code>. Fresh game telemetry supplies player counts when available; RCON
|
||||
provides a fallback. The scoreboard uses telemetry.
|
||||
</p>
|
||||
<p class="retention">
|
||||
Newest first · {retention.maxPerServer
|
||||
? `up to ${retention.maxPerServer} requests per server`
|
||||
: 'no per-server retention cap'} · {retention.maxAgeDays
|
||||
? `last ${retention.maxAgeDays} days`
|
||||
: 'no age expiry'}. Fleet-wide limits also apply.
|
||||
</p>
|
||||
|
||||
<p class="attribution">
|
||||
Requests can be linked here only when readable JSON contains a matching top-level <code>server</code> field.
|
||||
Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here.
|
||||
</p></details>
|
||||
<p class="attribution">
|
||||
Requests can be linked here only when readable JSON contains a matching top-level <code
|
||||
>server</code
|
||||
> field. Calls that never reach Electric Cherry, malformed JSON, and different server names
|
||||
will not appear here.
|
||||
</p>
|
||||
</details>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.request-help { margin-top: 16px; font-size: var(--text-xs); }
|
||||
.panel-head, .request-head, .request-result {
|
||||
.request-help {
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.panel-head,
|
||||
.request-head,
|
||||
.request-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-head { margin-bottom: var(--space-4); }
|
||||
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||
p { margin: 0; }
|
||||
code, pre { font-family: var(--font-mono); }
|
||||
.server-name { margin-top: var(--space-2); overflow-wrap: anywhere; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.intro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
.retention, .attribution, .content-type, .truncation {
|
||||
.panel-head {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.server-name {
|
||||
margin-top: var(--space-2);
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.intro {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.retention,
|
||||
.attribution,
|
||||
.content-type,
|
||||
.truncation {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.retention { margin-top: var(--space-2); }
|
||||
.attribution { margin-top: var(--space-4); }
|
||||
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
|
||||
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
|
||||
.request-result { justify-content: flex-start; min-width: 0; }
|
||||
.event { font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
.received { color: var(--text-tertiary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
.request-meta { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-6); margin: var(--space-3) 0; font-size: var(--text-xs); }
|
||||
.request-meta div { display: flex; gap: var(--space-2); min-width: 0; }
|
||||
dt { color: var(--text-tertiary); }
|
||||
dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.load-error, .request-error { color: var(--state-danger); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
.load-error { margin-top: var(--space-4); }
|
||||
.request-error { margin-bottom: var(--space-3); }
|
||||
.payload { font-size: var(--text-xs); }
|
||||
summary { color: var(--text-secondary); cursor: pointer; width: fit-content; }
|
||||
summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
.content-type { margin-top: var(--space-3); }
|
||||
.retention {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.attribution {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.request-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-5) 0 0;
|
||||
}
|
||||
.request {
|
||||
padding: var(--space-4) 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.request-result {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
.event {
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.received {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.request-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-6);
|
||||
margin: var(--space-3) 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.request-meta div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.load-error,
|
||||
.request-error {
|
||||
color: var(--state-danger);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.load-error {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.request-error {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.payload {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
summary {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.content-type {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
pre {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: var(--space-3);
|
||||
@@ -191,5 +328,7 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.truncation { margin-top: var(--space-2); }
|
||||
.truncation {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
import { watchdogStateLabels, type WatchdogState } from '$lib/watchdog.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
let { state }: { state: WatchdogState } = $props();
|
||||
const tone = $derived(state === 'attention' ? 'warning' : state === 'resolved' ? 'online' : state === 'recovering' ? 'info' : 'neutral');
|
||||
const tone = $derived(
|
||||
state === 'attention'
|
||||
? 'warning'
|
||||
: state === 'resolved'
|
||||
? 'online'
|
||||
: state === 'recovering'
|
||||
? 'info'
|
||||
: 'neutral'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Badge variant={tone}>{watchdogStateLabels[state]}</Badge>
|
||||
|
||||
@@ -7,19 +7,69 @@
|
||||
let dismissed = $state(false);
|
||||
</script>
|
||||
|
||||
<button type="button" class="observation-time" class:stale={age.stale} class:dismissed
|
||||
aria-label={`${age.label}; observation details`} aria-describedby={id}
|
||||
onfocus={() => dismissed = false} onpointerenter={() => dismissed = false}
|
||||
onclick={() => dismissed = false} onkeydown={event => { if (event.key === 'Escape') dismissed = true; }}>
|
||||
<button
|
||||
type="button"
|
||||
class="observation-time"
|
||||
class:stale={age.stale}
|
||||
class:dismissed
|
||||
aria-label={`${age.label}; observation details`}
|
||||
aria-describedby={id}
|
||||
onfocus={() => (dismissed = false)}
|
||||
onpointerenter={() => (dismissed = false)}
|
||||
onclick={() => (dismissed = false)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') dismissed = true;
|
||||
}}
|
||||
>
|
||||
<time datetime={new Date(at).toISOString()}>{age.label}</time>
|
||||
<span class="timestamp-detail" role="tooltip" {id}>{$timeFormat.dateTime(at)} · {source ?? 'Source unavailable'}</span>
|
||||
<span class="timestamp-detail" role="tooltip" {id}
|
||||
>{$timeFormat.dateTime(at)} · {source ?? 'Source unavailable'}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.observation-time { position: relative; padding: 4px 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-secondary); font: inherit; cursor: help; text-align: left; }
|
||||
.observation-time.stale { color: var(--state-warning); }
|
||||
.observation-time:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.timestamp-detail { visibility: hidden; position: absolute; z-index: 5; right: 0; bottom: 100%; width: max-content; max-width: min(280px, 75vw); padding: 8px 10px; color: var(--text-primary); background: var(--surface-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); box-shadow: 0 4px 12px #0004; line-height: 1.5; }
|
||||
.observation-time:hover:not(.dismissed) .timestamp-detail, .observation-time:focus:not(.dismissed) .timestamp-detail { visibility: visible; }
|
||||
@media (max-width: 640px) { .timestamp-detail { left: 0; right: auto; } }
|
||||
.observation-time {
|
||||
position: relative;
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: help;
|
||||
text-align: left;
|
||||
}
|
||||
.observation-time.stale {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.observation-time:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.timestamp-detail {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
width: max-content;
|
||||
max-width: min(280px, 75vw);
|
||||
padding: 8px 10px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-overlay);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px #0004;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.observation-time:hover:not(.dismissed) .timestamp-detail,
|
||||
.observation-time:focus:not(.dismissed) .timestamp-detail {
|
||||
visibility: visible;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.timestamp-detail {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js';
|
||||
import { parseServerName, zombieMapBySlug } from '$lib/map-aliases.js';
|
||||
import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity, scalingReason } from '$ui/autoscaling.js';
|
||||
import {
|
||||
lobbyGroups,
|
||||
lobbyLabel,
|
||||
policyStatus,
|
||||
scalingActivity,
|
||||
scalingReason
|
||||
} from '$ui/autoscaling.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from '../primitives/Badge.svelte';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
@@ -11,8 +17,19 @@
|
||||
import Tabs from '../primitives/Tabs.svelte';
|
||||
import ObservationTime from './ObservationTime.svelte';
|
||||
|
||||
let { view, globallyPaused, canOperate, busy, onedit, ontoggle, ondelete }: {
|
||||
view: ScalingView; globallyPaused: boolean; canOperate: boolean; busy: boolean;
|
||||
let {
|
||||
view,
|
||||
globallyPaused,
|
||||
canOperate,
|
||||
busy,
|
||||
onedit,
|
||||
ontoggle,
|
||||
ondelete
|
||||
}: {
|
||||
view: ScalingView;
|
||||
globallyPaused: boolean;
|
||||
canOperate: boolean;
|
||||
busy: boolean;
|
||||
onedit: (policy: ScalingPolicy) => void;
|
||||
ontoggle: (policy: ScalingPolicy) => void;
|
||||
ondelete: (policy: ScalingPolicy) => void;
|
||||
@@ -21,47 +38,109 @@
|
||||
let now = $state(Date.now());
|
||||
onMount(() => {
|
||||
now = Date.now();
|
||||
const timer = setInterval(() => now = Date.now(), 1000);
|
||||
const timer = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
let menuOpen = $state(false);
|
||||
let menuElement = $state<HTMLDivElement | null>(null);
|
||||
const title = $derived(`${view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · ${view.policy.game.toUpperCase()}`);
|
||||
const title = $derived(
|
||||
`${view.policy.mapSlug === '*' ? 'Every map' : (zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug)} · ${view.policy.game.toUpperCase()}`
|
||||
);
|
||||
const status = $derived(policyStatus(view, globallyPaused));
|
||||
const groups = $derived(lobbyGroups(view));
|
||||
const activity = $derived(scalingActivity(view));
|
||||
const activeIds = $derived(new Set(view.unsettled.map(operation => operation.id)));
|
||||
const covered = $derived(groups.filter(group => group.target > 0 && group.ready >= group.target).length);
|
||||
const mapCount = $derived(groups.filter(group => group.target > 0).length);
|
||||
const healthyCoverage = $derived(status.tone === 'online' && mapCount > 0 && covered === mapCount && view.policy.mapSlug === '*' ? `All ${mapCount} maps meet their spare target.` : null);
|
||||
const activeIds = $derived(new Set(view.unsettled.map((operation) => operation.id)));
|
||||
const covered = $derived(
|
||||
groups.filter((group) => group.target > 0 && group.ready >= group.target).length
|
||||
);
|
||||
const mapCount = $derived(groups.filter((group) => group.target > 0).length);
|
||||
const healthyCoverage = $derived(
|
||||
status.tone === 'online' &&
|
||||
mapCount > 0 &&
|
||||
covered === mapCount &&
|
||||
view.policy.mapSlug === '*'
|
||||
? `All ${mapCount} maps meet their spare target.`
|
||||
: null
|
||||
);
|
||||
const dates = (at: number) => $timeFormat.dateTime(new Date(at).toISOString());
|
||||
const memberStatus = (state: ScalingMember['state']) => ({
|
||||
occupied: 'Occupied', empty: 'Ready spare', creating: 'Starting', removing: 'Removing', stopped: 'Stopped', unknown: 'Unknown', protected: 'Protected'
|
||||
})[state];
|
||||
const memberTone = (state: ScalingMember['state']) => state === 'empty' ? 'online' : state === 'occupied' ? 'neutral' : state === 'creating' || state === 'removing' ? 'info' : 'warning';
|
||||
const memberStatus = (state: ScalingMember['state']) =>
|
||||
({
|
||||
occupied: 'Occupied',
|
||||
empty: 'Ready spare',
|
||||
creating: 'Starting',
|
||||
removing: 'Removing',
|
||||
stopped: 'Stopped',
|
||||
unknown: 'Unknown',
|
||||
protected: 'Protected'
|
||||
})[state];
|
||||
const memberTone = (state: ScalingMember['state']) =>
|
||||
state === 'empty'
|
||||
? 'online'
|
||||
: state === 'occupied'
|
||||
? 'neutral'
|
||||
: state === 'creating' || state === 'removing'
|
||||
? 'info'
|
||||
: 'warning';
|
||||
function closeMenu(event: MouseEvent) {
|
||||
if (menuOpen && event.target instanceof Node && !menuElement?.contains(event.target)) menuOpen = false;
|
||||
if (menuOpen && event.target instanceof Node && !menuElement?.contains(event.target))
|
||||
menuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeMenu} onkeydown={event => { if (event.key === 'Escape' && menuOpen) { menuOpen = false; menuElement?.querySelector('button')?.focus(); } }} />
|
||||
<svelte:window
|
||||
onclick={closeMenu}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuElement?.querySelector('button')?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class="policy" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
|
||||
<header class="heading">
|
||||
<div class="identity">
|
||||
<h2 id={`title-${view.policy.id}`}>{title}</h2>
|
||||
<Badge variant={view.policy.mode === 'off' ? 'neutral' : 'info'}>{view.policy.mode === 'off' ? 'Paused' : 'Enabled'}</Badge>
|
||||
<Badge variant={view.policy.mode === 'off' ? 'neutral' : 'info'}
|
||||
>{view.policy.mode === 'off' ? 'Paused' : 'Enabled'}</Badge
|
||||
>
|
||||
</div>
|
||||
{#if canOperate}
|
||||
<div class="actions">
|
||||
<Button size="sm" disabled={busy} onclick={() => onedit(view.policy)}>Edit policy</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onclick={() => ontoggle(view.policy)}>{view.policy.mode === 'off' ? 'Resume' : 'Pause'}</Button>
|
||||
<Button size="sm" disabled={busy} onclick={() => onedit(view.policy)}
|
||||
>Edit policy</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onclick={() => ontoggle(view.policy)}
|
||||
>{view.policy.mode === 'off' ? 'Resume' : 'Pause'}</Button
|
||||
>
|
||||
<div class="more" bind:this={menuElement}>
|
||||
<button class="more-toggle" type="button" aria-label={`More actions for ${title}`} aria-expanded={menuOpen} aria-controls={`actions-${view.policy.id}`} onclick={() => menuOpen = !menuOpen}>•••</button>
|
||||
<button
|
||||
class="more-toggle"
|
||||
type="button"
|
||||
aria-label={`More actions for ${title}`}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={`actions-${view.policy.id}`}
|
||||
onclick={() => (menuOpen = !menuOpen)}>•••</button
|
||||
>
|
||||
{#if menuOpen}
|
||||
<div class="action-menu" id={`actions-${view.policy.id}`}>
|
||||
<Button size="sm" variant="danger" disabled={busy || view.unsettled.length > 0} onclick={() => { menuOpen = false; ondelete(view.policy); }}>Delete policy</Button>
|
||||
{#if view.unsettled.length}<p>Pause this policy and wait for outstanding jobs to finish before deleting it.</p>{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={busy || view.unsettled.length > 0}
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
ondelete(view.policy);
|
||||
}}>Delete policy</Button
|
||||
>
|
||||
{#if view.unsettled.length}<p>
|
||||
Pause this policy and wait for outstanding jobs to finish before
|
||||
deleting it.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -72,12 +151,29 @@
|
||||
<div class="counts" aria-label="Lobby counts across this policy">
|
||||
<Stat label="Occupied" value={view.decision.occupied} tween={false} />
|
||||
<Stat label="Ready spares" value={view.decision.empty} tone="online" tween={false} />
|
||||
<Stat label="Pending" value={view.decision.pending} tone={view.decision.pending ? 'accent' : 'primary'} tween={false} />
|
||||
<Stat label="Unknown / stopped" value={view.decision.unknown} tone={view.decision.unknown ? 'warning' : 'primary'} tween={false} />
|
||||
<Stat
|
||||
label="Pending"
|
||||
value={view.decision.pending}
|
||||
tone={view.decision.pending ? 'accent' : 'primary'}
|
||||
tween={false}
|
||||
/>
|
||||
<Stat
|
||||
label="Unknown / stopped"
|
||||
value={view.decision.unknown}
|
||||
tone={view.decision.unknown ? 'warning' : 'primary'}
|
||||
tween={false}
|
||||
/>
|
||||
</div>
|
||||
<div class="settings">
|
||||
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy.emptyMinutes} minutes empty</p>
|
||||
<p class:warning={view.snapshot.keyPool.available === 0}>{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available <span class="muted">· Shared across {view.policy.game.toUpperCase()}</span></p>
|
||||
<p>
|
||||
{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view
|
||||
.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy
|
||||
.emptyMinutes} minutes empty
|
||||
</p>
|
||||
<p class:warning={view.snapshot.keyPool.available === 0}>
|
||||
{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available
|
||||
<span class="muted">· Shared across {view.policy.game.toUpperCase()}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !view.unsettled.length || globallyPaused || view.policy.mode === 'off' || view.policy.pausedReason}
|
||||
@@ -87,153 +183,574 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#each view.unsettled as operation (operation.id)}
|
||||
{@const member = view.snapshot.members.find(member => member.operationId === operation.id)}
|
||||
{@const needsAttention = ['failed', 'interrupted', 'manual', 'unknown', 'cancelled', 'warning'].includes(operation.state)}
|
||||
{@const member = view.snapshot.members.find(
|
||||
(member) => member.operationId === operation.id
|
||||
)}
|
||||
{@const needsAttention = [
|
||||
'failed',
|
||||
'interrupted',
|
||||
'manual',
|
||||
'unknown',
|
||||
'cancelled',
|
||||
'warning'
|
||||
].includes(operation.state)}
|
||||
<div class="job" class:attention={needsAttention}>
|
||||
<span class="job-dot" class:working={!needsAttention} aria-hidden="true"></span>
|
||||
<div class="job-copy">
|
||||
<div class="job-title"><strong>{needsAttention ? 'Review' : operation.action === 'destroy' ? 'Removing' : 'Creating'} {member ? lobbyLabel(member.name) : 'lobby'}{needsAttention ? ' job' : ''}</strong><StatusBadge state={operation.state} /></div>
|
||||
<div class="job-title">
|
||||
<strong
|
||||
>{needsAttention
|
||||
? 'Review'
|
||||
: operation.action === 'destroy'
|
||||
? 'Removing'
|
||||
: 'Creating'}
|
||||
{member ? lobbyLabel(member.name) : 'lobby'}{needsAttention
|
||||
? ' job'
|
||||
: ''}</strong
|
||||
><StatusBadge state={operation.state} />
|
||||
</div>
|
||||
<p>{operation.stage || 'Waiting for the next provisioning update.'}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" href={`/provisioning/${operation.id}`}>View job</Button>
|
||||
<Button size="sm" variant="ghost" href={`/provisioning/${operation.id}`}
|
||||
>View job</Button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="detail-nav">
|
||||
<Tabs tabs={[{ id: 'lobbies', label: 'Lobbies', badge: view.snapshot.members.length }, { id: 'activity', label: 'Activity' }, { id: 'metrics', label: 'Metrics' }]} bind:value={tab} ariaLabel={`Details for ${title}`} panelId={`panel-${view.policy.id}`} idPrefix={`tab-${view.policy.id}`} size="sm" />
|
||||
{#if tab === 'lobbies' && view.policy.mapSlug === '*' && !healthyCoverage}<span class="muted">{covered} / {mapCount} maps meet spare target</span>{/if}
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'lobbies', label: 'Lobbies', badge: view.snapshot.members.length },
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
{ id: 'metrics', label: 'Metrics' }
|
||||
]}
|
||||
bind:value={tab}
|
||||
ariaLabel={`Details for ${title}`}
|
||||
panelId={`panel-${view.policy.id}`}
|
||||
idPrefix={`tab-${view.policy.id}`}
|
||||
size="sm"
|
||||
/>
|
||||
{#if tab === 'lobbies' && view.policy.mapSlug === '*' && !healthyCoverage}<span
|
||||
class="muted">{covered} / {mapCount} maps meet spare target</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="tab-panel" id={`panel-${view.policy.id}`} role="tabpanel" aria-labelledby={`tab-${view.policy.id}-${tab}`} tabindex="0">
|
||||
<div
|
||||
class="tab-panel"
|
||||
id={`panel-${view.policy.id}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${view.policy.id}-${tab}`}
|
||||
tabindex="0"
|
||||
>
|
||||
{#if tab === 'lobbies'}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Map / lobby</th><th>State</th><th>Players</th><th>Latest observation</th></tr></thead>
|
||||
<thead
|
||||
><tr
|
||||
><th>Map / lobby</th><th>State</th><th>Players</th><th
|
||||
>Latest observation</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
{#each groups as group (group.slug)}
|
||||
<tbody>
|
||||
{#each group.members as member, index (member.name)}
|
||||
{@const pending = member.state === 'creating' || member.state === 'removing'}
|
||||
{@const pending =
|
||||
member.state === 'creating' || member.state === 'removing'}
|
||||
{@const number = parseServerName(member.name)?.lobby}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="lobby-identity">
|
||||
<a class="lobby-link" href={`/servers/${encodeURIComponent(member.name)}`} aria-label={lobbyLabel(member.name)} title={member.name}>{`${index === 0 && group.slug !== 'unassigned' ? `${group.label} · ` : ''}${number == null ? member.name : `Lobby ${number}`}`}</a>
|
||||
{#if index === 0 && group.target}<span class="coverage" class:warning={group.ready < group.target}>{group.ready} / {group.target} spares</span>{/if}
|
||||
<a
|
||||
class="lobby-link"
|
||||
href={`/servers/${encodeURIComponent(member.name)}`}
|
||||
aria-label={lobbyLabel(member.name)}
|
||||
title={member.name}
|
||||
>{`${index === 0 && group.slug !== 'unassigned' ? `${group.label} · ` : ''}${number == null ? member.name : `Lobby ${number}`}`}</a
|
||||
>
|
||||
{#if index === 0 && group.target}<span
|
||||
class="coverage"
|
||||
class:warning={group.ready < group.target}
|
||||
>{group.ready} / {group.target} spares</span
|
||||
>{/if}
|
||||
</div>
|
||||
{#if member.blocker && !activeIds.has(member.operationId ?? '')}<span class="member-note">{scalingReason(member.blocker)}</span>{/if}
|
||||
{#if member.blocker && !activeIds.has(member.operationId ?? '')}<span
|
||||
class="member-note"
|
||||
>{scalingReason(member.blocker)}</span
|
||||
>{/if}
|
||||
</td>
|
||||
<td><Badge variant={memberTone(member.state)}>{memberStatus(member.state)}</Badge>{#if member.operationId}<a class="job-link" href={`/provisioning/${member.operationId}`}>View job</a>{/if}</td>
|
||||
<td
|
||||
><Badge variant={memberTone(member.state)}
|
||||
>{memberStatus(member.state)}</Badge
|
||||
>{#if member.operationId}<a
|
||||
class="job-link"
|
||||
href={`/provisioning/${member.operationId}`}
|
||||
>View job</a
|
||||
>{/if}</td
|
||||
>
|
||||
<td class="players">{member.players ?? '—'}</td>
|
||||
<td class="observation">{#if member.observedAt !== null}<ObservationTime at={member.observedAt} source={member.source} {now} />{:else}<span class:warning={!pending}>{pending ? 'Awaiting first observation' : 'No recent observation'}</span>{/if}</td>
|
||||
<td class="observation"
|
||||
>{#if member.observedAt !== null}<ObservationTime
|
||||
at={member.observedAt}
|
||||
source={member.source}
|
||||
{now}
|
||||
/>{:else}<span class:warning={!pending}
|
||||
>{pending
|
||||
? 'Awaiting first observation'
|
||||
: 'No recent observation'}</span
|
||||
>{/if}</td
|
||||
>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td><div class="lobby-identity"><span>{group.label}</span>{#if group.target}<span class="coverage warning">0 / {group.target} spares</span>{/if}</div></td><td colspan="3" class="empty-map">No lobbies yet</td></tr>
|
||||
<tr
|
||||
><td
|
||||
><div class="lobby-identity">
|
||||
<span>{group.label}</span>{#if group.target}<span
|
||||
class="coverage warning"
|
||||
>0 / {group.target} spares</span
|
||||
>{/if}
|
||||
</div></td
|
||||
><td colspan="3" class="empty-map">No lobbies yet</td></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
{/each}
|
||||
</table>
|
||||
</div>
|
||||
{#each view.snapshot.blockers.filter(blocker => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}<p class="blocker">{scalingReason(blocker)}</p>{/each}
|
||||
{#each view.snapshot.blockers.filter((blocker) => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}<p
|
||||
class="blocker"
|
||||
>
|
||||
{scalingReason(blocker)}
|
||||
</p>{/each}
|
||||
{:else if tab === 'activity'}
|
||||
<p class="section-note">Recent decision changes · Provisioning updates are grouped by job.</p>
|
||||
<p class="section-note">
|
||||
Recent decision changes · Provisioning updates are grouped by job.
|
||||
</p>
|
||||
<ol class="activity">
|
||||
{#each activity as item (item.id)}
|
||||
<li>
|
||||
<div class="activity-heading"><strong>{item.action === 'create' ? 'Creation requested' : item.action === 'destroy' ? 'Removal requested' : item.operationId ? 'Provisioning in progress' : 'Scaling evaluated'}{item.server ? ` · ${lobbyLabel(item.server)}` : ''}</strong>{#if item.operationState}<StatusBadge state={item.operationState} />{/if}</div>
|
||||
{#if !item.operationId || !activeIds.has(item.operationId)}<p>{item.reason}</p>{/if}
|
||||
<div class="activity-meta"><time datetime={new Date(item.at).toISOString()}>{dates(item.at)}</time>{#if item.evaluations > 1}<span>{item.evaluations} decision changes</span>{/if}{#if item.operationId}<a href={`/provisioning/${item.operationId}`}>View job</a>{/if}</div>
|
||||
<div class="activity-heading">
|
||||
<strong
|
||||
>{item.action === 'create'
|
||||
? 'Creation requested'
|
||||
: item.action === 'destroy'
|
||||
? 'Removal requested'
|
||||
: item.operationId
|
||||
? 'Provisioning in progress'
|
||||
: 'Scaling evaluated'}{item.server
|
||||
? ` · ${lobbyLabel(item.server)}`
|
||||
: ''}</strong
|
||||
>{#if item.operationState}<StatusBadge
|
||||
state={item.operationState}
|
||||
/>{/if}
|
||||
</div>
|
||||
{#if !item.operationId || !activeIds.has(item.operationId)}<p>
|
||||
{item.reason}
|
||||
</p>{/if}
|
||||
<div class="activity-meta">
|
||||
<time datetime={new Date(item.at).toISOString()}>{dates(item.at)}</time
|
||||
>{#if item.evaluations > 1}<span
|
||||
>{item.evaluations} decision changes</span
|
||||
>{/if}{#if item.operationId}<a
|
||||
href={`/provisioning/${item.operationId}`}>View job</a
|
||||
>{/if}
|
||||
</div>
|
||||
</li>
|
||||
{:else}<li class="muted">No scaling activity yet.</li>{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="section-note">Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed</p>
|
||||
<p class="section-note">
|
||||
Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed
|
||||
</p>
|
||||
<div class="metrics">
|
||||
<Stat label="Empty lobby-hours" value={view.totals.emptyLobbyHours.toFixed(2)} />
|
||||
<Stat label="Below spare target" value={view.totals.deficitMinutes.toFixed(1)} unit="min" />
|
||||
<Stat label="Unknown occupancy" value={view.totals.unknownMinutes.toFixed(1)} unit="min" />
|
||||
<Stat
|
||||
label="Below spare target"
|
||||
value={view.totals.deficitMinutes.toFixed(1)}
|
||||
unit="min"
|
||||
/>
|
||||
<Stat
|
||||
label="Unknown occupancy"
|
||||
value={view.totals.unknownMinutes.toFixed(1)}
|
||||
unit="min"
|
||||
/>
|
||||
<Stat label="Create requests" value={view.operations.creates} tween={false} />
|
||||
<Stat label="Removal requests" value={view.operations.removals} tween={false} />
|
||||
<Stat label="Cancelled / failed" value={`${view.operations.cancelled} / ${view.operations.failed}`} tone={view.operations.failed ? 'warning' : 'primary'} />
|
||||
<Stat
|
||||
label="Cancelled / failed"
|
||||
value={`${view.operations.cancelled} / ${view.operations.failed}`}
|
||||
tone={view.operations.failed ? 'warning' : 'primary'}
|
||||
/>
|
||||
</div>
|
||||
{#if view.operations.readinessSamples}
|
||||
<p class="readiness">Request to verified readiness: <strong>{view.operations.readinessMeanSeconds?.toFixed(0)}s average</strong> · {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations.readinessSamples} completed creates</p>
|
||||
<p class="readiness">
|
||||
Request to verified readiness: <strong
|
||||
>{view.operations.readinessMeanSeconds?.toFixed(0)}s average</strong
|
||||
>
|
||||
· {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations
|
||||
.readinessSamples} completed creates
|
||||
</p>
|
||||
{/if}
|
||||
<p class="muted metric-note">Observation gaps are excluded. Readiness includes queue time and does not confirm server browser visibility or successful joins.</p>
|
||||
<p class="muted metric-note">
|
||||
Observation gaps are excluded. Readiness includes queue time and does not confirm
|
||||
server browser visibility or successful joins.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<footer>Checks every 15 seconds <span>·</span> Last evaluated: {view.lastEvaluatedAt ? dates(view.lastEvaluatedAt) : 'Not evaluated yet'}</footer>
|
||||
<footer>
|
||||
Checks every 15 seconds <span>·</span> Last evaluated: {view.lastEvaluatedAt
|
||||
? dates(view.lastEvaluatedAt)
|
||||
: 'Not evaluated yet'}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.policy { margin: 20px 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
|
||||
.heading { padding: 20px 24px 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.identity, .actions, .job-title, .activity-heading { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.more { position: relative; }
|
||||
.more-toggle { border: 1px solid var(--border-subtle); background: transparent; color: var(--text-secondary); border-radius: var(--radius-sm); min-width: 34px; height: 32px; cursor: pointer; letter-spacing: 2px; }
|
||||
.more-toggle:focus-visible, .tab-panel:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.action-menu { position: absolute; z-index: 5; right: 0; top: 40px; width: 240px; max-width: calc(100vw - 80px); padding: 12px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: 0 8px 24px #0005; }
|
||||
.action-menu p { font-size: var(--text-xs); line-height: 1.5; color: var(--text-secondary); margin: 10px 0 0; }
|
||||
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; padding: 24px; }
|
||||
.settings { padding: 0 24px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
.settings p { margin: 0 0 4px; }
|
||||
.muted { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.warning { color: var(--state-warning); }
|
||||
.summary, .job { margin: 18px 24px 0; display: flex; align-items: center; gap: 12px; font-size: var(--text-sm); }
|
||||
.summary { flex-wrap: wrap; color: var(--text-secondary); }
|
||||
.summary.attention { border-left: 2px solid var(--state-warning); padding-left: 12px; }
|
||||
.job { background: var(--surface-raised); border-radius: var(--radius-md); padding: 12px 14px; }
|
||||
.job-copy { flex: 1; min-width: 0; }
|
||||
.job-copy p { margin: 4px 0 0; color: var(--text-secondary); line-height: 1.5; }
|
||||
.job-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--state-warning); }
|
||||
.job-dot.working { background: var(--accent-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
|
||||
.job.attention { border: 1px solid var(--state-warning); }
|
||||
.detail-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 24px 24px 14px; }
|
||||
.tab-panel { min-height: 120px; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||
thead th { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); padding: 8px 16px 12px; }
|
||||
th:first-child, td:first-child { padding-left: 24px; }
|
||||
th:last-child, td:last-child { padding-right: 24px; }
|
||||
td { padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
|
||||
tbody:last-child tr:last-child td { border-bottom: 0; }
|
||||
.lobby-identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; }
|
||||
.coverage { color: var(--text-tertiary); font-size: var(--text-xs); white-space: nowrap; }
|
||||
.coverage.warning { color: var(--state-warning); }
|
||||
.lobby-link { font-weight: 500; text-decoration: none; }
|
||||
.lobby-link:hover, .job-link:hover { text-decoration: underline; }
|
||||
.member-note { display: block; margin-top: 5px; color: var(--state-warning); font-size: var(--text-xs); }
|
||||
.job-link { display: block; margin-top: 6px; font-size: var(--text-xs); text-decoration: none; }
|
||||
.players { font-variant-numeric: tabular-nums; }
|
||||
.observation { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.empty-map { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.section-note { margin: 0; padding: 6px 24px 16px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.activity { margin: 0; padding: 0 24px; list-style: none; }
|
||||
.activity li { padding: 16px 0; border-top: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
.activity-heading strong { font-weight: 500; }
|
||||
.activity p { color: var(--text-secondary); margin: 8px 0; line-height: 1.5; }
|
||||
.activity-meta { margin-top: 8px; display: flex; gap: 12px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; padding: 8px 24px 24px; }
|
||||
.readiness, .metric-note { margin: 0; padding: 0 24px 16px; line-height: 1.6; }
|
||||
.readiness { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.blocker { margin: 12px 24px; color: var(--state-warning); font-size: var(--text-sm); }
|
||||
footer { border-top: 1px solid var(--border-subtle); padding: 12px 24px; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
|
||||
footer span { margin: 0 5px; }
|
||||
.policy {
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.heading {
|
||||
padding: 20px 24px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.identity,
|
||||
.actions,
|
||||
.job-title,
|
||||
.activity-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
.more {
|
||||
position: relative;
|
||||
}
|
||||
.more-toggle {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 34px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.more-toggle:focus-visible,
|
||||
.tab-panel:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: 0;
|
||||
top: 40px;
|
||||
width: 240px;
|
||||
max-width: calc(100vw - 80px);
|
||||
padding: 12px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px #0005;
|
||||
}
|
||||
.action-menu p {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
.settings {
|
||||
padding: 0 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.settings p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.warning {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.summary,
|
||||
.job {
|
||||
margin: 18px 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.summary {
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.summary.attention {
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding-left: 12px;
|
||||
}
|
||||
.job {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.job-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.job-copy p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.job-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--state-warning);
|
||||
}
|
||||
.job-dot.working {
|
||||
background: var(--accent-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 12%, transparent);
|
||||
}
|
||||
.job.attention {
|
||||
border: 1px solid var(--state-warning);
|
||||
}
|
||||
.detail-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 24px 24px 14px;
|
||||
}
|
||||
.tab-panel {
|
||||
min-height: 120px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
thead th {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 24px;
|
||||
}
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: 24px;
|
||||
}
|
||||
td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody:last-child tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lobby-identity {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
.coverage {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage.warning {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.lobby-link {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.lobby-link:hover,
|
||||
.job-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.member-note {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.job-link {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-xs);
|
||||
text-decoration: none;
|
||||
}
|
||||
.players {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.observation {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.empty-map {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.section-note {
|
||||
margin: 0;
|
||||
padding: 6px 24px 16px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.activity {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
list-style: none;
|
||||
}
|
||||
.activity li {
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.activity-heading strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity p {
|
||||
color: var(--text-secondary);
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.activity-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
padding: 8px 24px 24px;
|
||||
}
|
||||
.readiness,
|
||||
.metric-note {
|
||||
margin: 0;
|
||||
padding: 0 24px 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.readiness {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.blocker {
|
||||
margin: 12px 24px;
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
footer {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 12px 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
footer span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.heading { align-items: flex-start; padding: 16px 16px 0; }
|
||||
.counts { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 20px 16px; }
|
||||
.settings { padding-inline: 16px; }
|
||||
.summary, .job { margin-inline: 16px; }
|
||||
.job { flex-wrap: wrap; }
|
||||
.detail-nav { padding: 20px 16px 12px; }
|
||||
.table-scroll { overflow: visible; }
|
||||
table, tbody { display: block; width: 100%; }
|
||||
thead { display: none; }
|
||||
tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); gap: 10px; }
|
||||
td, th:first-child, td:first-child, th:last-child, td:last-child { padding: 0; border: 0; }
|
||||
td:first-child, .observation { grid-column: 1 / -1; }
|
||||
.players::before { content: 'Players: '; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.empty-map { grid-column: 1 / -1; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
|
||||
footer { padding-inline: 16px; }
|
||||
.heading {
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
.counts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.settings {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
.summary,
|
||||
.job {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
.job {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-nav {
|
||||
padding: 20px 16px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
table,
|
||||
tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: 10px;
|
||||
}
|
||||
td,
|
||||
th:first-child,
|
||||
td:first-child,
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
td:first-child,
|
||||
.observation {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.players::before {
|
||||
content: 'Players: ';
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.empty-map {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding-inline: 16px;
|
||||
}
|
||||
footer {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
|
||||
let { history, scope, controls }: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
|
||||
let {
|
||||
history,
|
||||
scope,
|
||||
controls
|
||||
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
|
||||
let metric = $state<'cpu' | 'memory'>('cpu');
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let tableOpen = $state(false);
|
||||
@@ -13,17 +17,22 @@
|
||||
if (selectedAt !== null && points.length) {
|
||||
let closest = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
if (Math.abs(points[index].at - selectedAt) < Math.abs(points[closest].at - selectedAt)) closest = index;
|
||||
if (
|
||||
Math.abs(points[index].at - selectedAt) <
|
||||
Math.abs(points[closest].at - selectedAt)
|
||||
)
|
||||
closest = index;
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
for (let index = points.length - 1; index >= 0; index--) if (value(points[index]) !== null) return index;
|
||||
for (let index = points.length - 1; index >= 0; index--)
|
||||
if (value(points[index]) !== null) return index;
|
||||
return Math.max(0, points.length - 1);
|
||||
});
|
||||
const selected = $derived(points[selectedIndex]);
|
||||
const measured = $derived(points.filter(point => value(point) !== null));
|
||||
const measured = $derived(points.filter((point) => value(point) !== null));
|
||||
const ceiling = $derived.by(() => {
|
||||
const max = Math.max(1, ...measured.map(point => value(point)!));
|
||||
const max = Math.max(1, ...measured.map((point) => value(point)!));
|
||||
const step = 10 ** Math.floor(Math.log10(max)) / 2;
|
||||
return Math.ceil(max / step) * step;
|
||||
});
|
||||
@@ -32,127 +41,508 @@
|
||||
let segment: { x: number; y: number }[] = [];
|
||||
for (const point of points) {
|
||||
const amount = value(point);
|
||||
if (amount === null) { if (segment.length) result.push(segment); segment = []; }
|
||||
else segment.push({ x: x(point.at), y: y(amount) });
|
||||
if (amount === null) {
|
||||
if (segment.length) result.push(segment);
|
||||
segment = [];
|
||||
} else segment.push({ x: x(point.at), y: y(amount) });
|
||||
}
|
||||
if (segment.length) result.push(segment);
|
||||
return result;
|
||||
});
|
||||
const ticks = [1, .75, .5, .25, 0];
|
||||
const ticks = [1, 0.75, 0.5, 0.25, 0];
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
function value(point: CapacityHistoryPoint): number | null {
|
||||
return metric === 'cpu' ? point.cpuCoresAvailable : point.memoryBytesAvailable === null ? null : point.memoryBytesAvailable / 1024 ** 3;
|
||||
return metric === 'cpu'
|
||||
? point.cpuCoresAvailable
|
||||
: point.memoryBytesAvailable === null
|
||||
? null
|
||||
: point.memoryBytesAvailable / 1024 ** 3;
|
||||
}
|
||||
function x(at: number) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1000,
|
||||
((at - history.startAt) / Math.max(1, history.endAt - history.startAt)) * 1000
|
||||
)
|
||||
);
|
||||
}
|
||||
function y(amount: number) {
|
||||
return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220;
|
||||
}
|
||||
function amount(value: number | null, unit: string) {
|
||||
return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`;
|
||||
}
|
||||
function x(at: number) { return Math.max(0, Math.min(1000, (at - history.startAt) / Math.max(1, history.endAt - history.startAt) * 1000)); }
|
||||
function y(amount: number) { return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220; }
|
||||
function amount(value: number | null, unit: string) { return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`; }
|
||||
function describe(point: CapacityHistoryPoint) {
|
||||
return `${$timeFormat.period(point.at)}. ${amount(value(point), metric === 'cpu' ? 'cores available' : 'GiB available')}. ${point.reportingHosts} of ${point.expectedHosts} hosts reporting.`;
|
||||
}
|
||||
function inspect(event: PointerEvent) {
|
||||
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch')) return;
|
||||
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
|
||||
return;
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const at = history.startAt + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * (history.endAt - history.startAt);
|
||||
const at =
|
||||
history.startAt +
|
||||
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) *
|
||||
(history.endAt - history.startAt);
|
||||
let closest = points[0];
|
||||
for (const point of points) if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
|
||||
for (const point of points)
|
||||
if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
|
||||
selectedAt = closest.at;
|
||||
}
|
||||
function move(event: KeyboardEvent) {
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
|
||||
return;
|
||||
event.preventDefault();
|
||||
const index = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1
|
||||
: Math.max(0, Math.min(points.length - 1, selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
const index =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
points.length - 1,
|
||||
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
|
||||
)
|
||||
);
|
||||
selectedAt = points[index].at;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="capacity-history-title">
|
||||
<div class="heading">
|
||||
<div><h2 id="capacity-history-title">Available resources over time</h2><p>{scope} · All workloads</p></div>
|
||||
<div class="controls">{@render controls?.()}<div class="mode" role="group" aria-label="Chart resource">
|
||||
<button type="button" aria-pressed={metric === 'cpu'} onclick={() => metric = 'cpu'}>CPU</button>
|
||||
<button type="button" aria-pressed={metric === 'memory'} onclick={() => metric = 'memory'}>Memory</button>
|
||||
</div></div>
|
||||
<div>
|
||||
<h2 id="capacity-history-title">Available resources over time</h2>
|
||||
<p>{scope} · All workloads</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
{@render controls?.()}
|
||||
<div class="mode" role="group" aria-label="Chart resource">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={metric === 'cpu'}
|
||||
onclick={() => (metric = 'cpu')}>CPU</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={metric === 'memory'}
|
||||
onclick={() => (metric = 'memory')}>Memory</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scope-note">The game filter applies to current launch estimates only.</p>
|
||||
<div class="axis-title">{label} available</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}>{numbers.format(ceiling * tick)}</span>{/each}</div>
|
||||
<div class="plot" role="slider" tabindex="0" aria-label={`${label} history. Use left and right arrows to inspect a period.`}
|
||||
aria-valuemin="0" aria-valuemax={Math.max(0, points.length - 1)} aria-valuenow={selectedIndex}
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
|
||||
>{numbers.format(ceiling * tick)}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div
|
||||
class="plot"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label={`${label} history. Use left and right arrows to inspect a period.`}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={Math.max(0, points.length - 1)}
|
||||
aria-valuenow={selectedIndex}
|
||||
aria-valuetext={selected ? describe(selected) : 'No history available yet'}
|
||||
onpointermove={inspect} onpointerdown={inspect} onkeydown={move}>
|
||||
onpointermove={inspect}
|
||||
onpointerdown={inspect}
|
||||
onkeydown={move}
|
||||
>
|
||||
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
|
||||
{#each ticks as tick}<line class="grid-line" x1="0" x2="1000" y1={y(ceiling * tick)} y2={y(ceiling * tick)} />{/each}
|
||||
{#each ticks as tick}<line
|
||||
class="grid-line"
|
||||
x1="0"
|
||||
x2="1000"
|
||||
y1={y(ceiling * tick)}
|
||||
y2={y(ceiling * tick)}
|
||||
/>{/each}
|
||||
{#each segments as segment}
|
||||
{#if segment.length > 1}<path class="area" d={`M${segment[0].x},230 ${segment.map(point => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`} />{/if}
|
||||
<path class="trend" d={segment.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`).join(' ')} />
|
||||
{#if segment.length === 1}<circle class="point" cx={segment[0].x} cy={segment[0].y} r="3" />{/if}
|
||||
{#if segment.length > 1}<path
|
||||
class="area"
|
||||
d={`M${segment[0].x},230 ${segment.map((point) => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`}
|
||||
/>{/if}
|
||||
<path
|
||||
class="trend"
|
||||
d={segment
|
||||
.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`)
|
||||
.join(' ')}
|
||||
/>
|
||||
{#if segment.length === 1}<circle
|
||||
class="point"
|
||||
cx={segment[0].x}
|
||||
cy={segment[0].y}
|
||||
r="3"
|
||||
/>{/if}
|
||||
{/each}
|
||||
{#if selected}<line class="cursor" x1={x(selected.at)} x2={x(selected.at)} y1="5" y2="230" />
|
||||
{#if value(selected) !== null}<circle class="selected-point" cx={x(selected.at)} cy={y(value(selected)!)} r="4" />{/if}
|
||||
{#if selected}<line
|
||||
class="cursor"
|
||||
x1={x(selected.at)}
|
||||
x2={x(selected.at)}
|
||||
y1="5"
|
||||
y2="230"
|
||||
/>
|
||||
{#if value(selected) !== null}<circle
|
||||
class="selected-point"
|
||||
cx={x(selected.at)}
|
||||
cy={y(value(selected)!)}
|
||||
r="4"
|
||||
/>{/if}
|
||||
{/if}
|
||||
</svg>
|
||||
{#if !measured.length}<div class="empty-chart"><strong>Waiting for capacity history</strong><span>Fresh host reports build this timeline automatically.</span></div>{/if}
|
||||
{#if !measured.length}<div class="empty-chart">
|
||||
<strong>Waiting for capacity history</strong><span
|
||||
>Fresh host reports build this timeline automatically.</span
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true">
|
||||
<span>{$timeFormat.period(history.startAt)}</span><span
|
||||
>{$timeFormat.period(history.endAt)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true"><span>{$timeFormat.period(history.startAt)}</span><span>{$timeFormat.period(history.endAt)}</span></div>
|
||||
</div>
|
||||
{#if selected}
|
||||
<div class="readout" aria-label="Selected history period">
|
||||
<div class="selected-time"><strong>{$timeFormat.period(selected.at)}</strong></div>
|
||||
<dl><div><dt>CPU available</dt><dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd></div>
|
||||
<div><dt>Memory available</dt><dd>{amount(selected.memoryBytesAvailable === null ? null : selected.memoryBytesAvailable / 1024 ** 3, 'GiB')}</dd></div>
|
||||
<div><dt>Reporting hosts</dt><dd>{selected.reportingHosts} / {selected.expectedHosts}</dd></div></dl>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>CPU available</dt>
|
||||
<dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Memory available</dt>
|
||||
<dd>
|
||||
{amount(
|
||||
selected.memoryBytesAvailable === null
|
||||
? null
|
||||
: selected.memoryBytesAvailable / 1024 ** 3,
|
||||
'GiB'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reporting hosts</dt>
|
||||
<dd>{selected.reportingHosts} / {selected.expectedHosts}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
<details class="about"><summary>About this chart</summary>
|
||||
<p class="note">Available host resources after the operating-system reserve, averaged per period. Gaps mean reporting is incomplete. Reporting-host coverage is shown for each selected period. This history includes all workloads on the selected hosts; it does not reconstruct historical launch eligibility, queued jobs, or port availability.</p>
|
||||
<details bind:open={tableOpen}><summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
|
||||
{#if tableOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Capacity history data" tabindex="0"><table>
|
||||
<caption>Measured headroom; a dash means a complete measurement is unavailable.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">CPU cores</th><th scope="col">Memory (GiB)</th><th scope="col">Reporting hosts</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.at)}</th><td>{point.cpuCoresAvailable === null ? '—' : numbers.format(point.cpuCoresAvailable)}</td><td>{point.memoryBytesAvailable === null ? '—' : numbers.format(point.memoryBytesAvailable / 1024 ** 3)}</td><td>{point.reportingHosts} / {point.expectedHosts}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{/if}
|
||||
</details>
|
||||
<details class="about">
|
||||
<summary>About this chart</summary>
|
||||
<p class="note">
|
||||
Available host resources after the operating-system reserve, averaged per period. Gaps
|
||||
mean reporting is incomplete. Reporting-host coverage is shown for each selected period.
|
||||
This history includes all workloads on the selected hosts; it does not reconstruct
|
||||
historical launch eligibility, queued jobs, or port availability.
|
||||
</p>
|
||||
<details bind:open={tableOpen}>
|
||||
<summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
|
||||
{#if tableOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label="Capacity history data"
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<caption
|
||||
>Measured headroom; a dash means a complete measurement is unavailable.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">CPU cores</th><th
|
||||
scope="col">Memory (GiB)</th
|
||||
><th scope="col">Reporting hosts</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each points as point (point.at)}<tr
|
||||
><th scope="row">{$timeFormat.period(point.at)}</th><td
|
||||
>{point.cpuCoresAvailable === null
|
||||
? '—'
|
||||
: numbers.format(point.cpuCoresAvailable)}</td
|
||||
><td
|
||||
>{point.memoryBytesAvailable === null
|
||||
? '—'
|
||||
: numbers.format(
|
||||
point.memoryBytesAvailable / 1024 ** 3
|
||||
)}</td
|
||||
><td>{point.reportingHosts} / {point.expectedHosts}</td></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); } .heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex:1 1 240px; min-width:0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; min-width:0; max-width:100%; }
|
||||
.scope-note { margin:8px 0 0; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
.mode { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.mode button { padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
|
||||
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
|
||||
svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
svg path, svg line { vector-effect: non-scaling-stroke; }
|
||||
.grid-line { stroke: var(--border-subtle); stroke-width: 1; } .area { fill: color-mix(in oklab, var(--accent-primary) 10%, transparent); }
|
||||
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
|
||||
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.x-axis span:last-child { text-align: right; }
|
||||
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; text-align: center; padding: var(--space-4); pointer-events: none; }
|
||||
.empty-chart strong { font-size: var(--text-sm); font-weight: var(--weight-medium); } .empty-chart span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.readout { display:flex; flex-wrap:wrap; gap:12px 24px; align-items:center; margin:16px 0; font-size:var(--text-xs); }
|
||||
.selected-time { color:var(--text-secondary); } .selected-time strong { font-size:var(--text-xs); font-weight:400; }
|
||||
dl { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0; } dl>div { display:flex; gap:6px; } dt { color:var(--text-secondary); } dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin: var(--space-4) 0; max-width: 115ch; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
|
||||
.table-wrap { position: relative; overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
|
||||
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: var(--text-xs); } }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.mode button {
|
||||
padding: 7px 14px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode button[aria-pressed='true'] {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.mode button:focus-visible,
|
||||
.plot:focus-visible,
|
||||
.table-wrap:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.axis-title {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.y-axis {
|
||||
position: relative;
|
||||
height: 176px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.y-axis span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.plot {
|
||||
position: relative;
|
||||
height: 192px;
|
||||
border-radius: 3px;
|
||||
cursor: crosshair;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
svg path,
|
||||
svg line {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.grid-line {
|
||||
stroke: var(--border-subtle);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.area {
|
||||
fill: color-mix(in oklab, var(--accent-primary) 10%, transparent);
|
||||
}
|
||||
.trend {
|
||||
fill: none;
|
||||
stroke: var(--accent-primary);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.point,
|
||||
.selected-point {
|
||||
fill: var(--accent-primary-hover);
|
||||
}
|
||||
.selected-point {
|
||||
stroke: var(--surface);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.cursor {
|
||||
stroke: var(--text-tertiary);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
.x-axis {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.x-axis span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
.empty-chart {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.empty-chart strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.empty-chart span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.readout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.selected-time {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.selected-time strong {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
dl > div {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.65;
|
||||
margin: var(--space-4) 0;
|
||||
max-width: 115ch;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.readout {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
dl {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.x-axis {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
let { hosts, returnTo }: { hosts: CapacityHost[]; returnTo: string } = $props();
|
||||
let expanded = $state<string[]>([]);
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
function count(value: number | null) { return value === null ? '—' : numbers.format(value); }
|
||||
function memory(value: number | null) { return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`; }
|
||||
function count(value: number | null) {
|
||||
return value === null ? '—' : numbers.format(value);
|
||||
}
|
||||
function memory(value: number | null) {
|
||||
return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`;
|
||||
}
|
||||
function restriction(host: CapacityHost) {
|
||||
if (!host.connected) return 'Offline';
|
||||
return host.readiness === 'ready' ? 'Not eligible' : hostReadinessLabel(host.readiness);
|
||||
@@ -15,50 +19,220 @@
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
|
||||
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
|
||||
<td class="estimate">{count(host.estimatedServers)}</td>
|
||||
<td class="limitation">{#if host.eligible}<strong>{host.limitingResource ?? 'Measurements pending'}</strong>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}>{host.reasons[0]}</a>{/if}{/if}</td>
|
||||
<td>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null ? '' : ' cores'}</td>
|
||||
<td>{memory(host.memoryBytesAvailable)}</td>
|
||||
<td>{host.portsAvailable}</td>
|
||||
<td><button aria-label={`Details for ${host.name}`} aria-expanded={expanded.includes(host.id)} aria-controls={`capacity-host-${host.id}`} onclick={() => expanded = expanded.includes(host.id) ? expanded.filter(id => id !== host.id) : [...expanded, host.id]}>Details</button></td>
|
||||
</tr>
|
||||
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}><td colspan="7" class="detail-cell">
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
|
||||
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
|
||||
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
|
||||
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
|
||||
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
|
||||
</div>{/each}</dl>
|
||||
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
|
||||
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th
|
||||
><th>Memory available</th><th>Free ports</th><th
|
||||
><span class="sr-only">Details</span></th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td
|
||||
><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}
|
||||
>{host.name}</a
|
||||
></td
|
||||
>
|
||||
<td class="estimate">{count(host.estimatedServers)}</td>
|
||||
<td class="limitation"
|
||||
>{#if host.eligible}<strong
|
||||
>{host.limitingResource ?? 'Measurements pending'}</strong
|
||||
>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a
|
||||
class="reason"
|
||||
href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}
|
||||
>{host.reasons[0]}</a
|
||||
>{/if}{/if}</td
|
||||
>
|
||||
<td
|
||||
>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null
|
||||
? ''
|
||||
: ' cores'}</td
|
||||
>
|
||||
<td>{memory(host.memoryBytesAvailable)}</td>
|
||||
<td>{host.portsAvailable}</td>
|
||||
<td
|
||||
><button
|
||||
aria-label={`Details for ${host.name}`}
|
||||
aria-expanded={expanded.includes(host.id)}
|
||||
aria-controls={`capacity-host-${host.id}`}
|
||||
onclick={() =>
|
||||
(expanded = expanded.includes(host.id)
|
||||
? expanded.filter((id) => id !== host.id)
|
||||
: [...expanded, host.id])}>Details</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}
|
||||
><td colspan="7" class="detail-cell">
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>
|
||||
{#each host.reasons as reason}<li>{reason}</li>{/each}
|
||||
</ul>{/if}
|
||||
<p>
|
||||
Games ready: {host.games.length
|
||||
? host.games
|
||||
.map((game) =>
|
||||
game === 't5' ? 'Black Ops I' : 'Black Ops II'
|
||||
)
|
||||
.join(', ')
|
||||
: 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.
|
||||
</p>
|
||||
<p>
|
||||
Server limits: CPU {count(host.limits.cpu)} · Memory {count(
|
||||
host.limits.memory
|
||||
)} · Ports {host.limits.ports}.
|
||||
</p>
|
||||
<dl>
|
||||
{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div
|
||||
>
|
||||
<dt>{allowance.label}</dt>
|
||||
<dd>
|
||||
{count(allowance.value.cpuCores)} cores · {memory(
|
||||
allowance.value.memoryBytes
|
||||
)}
|
||||
</dd>
|
||||
<dd class="secondary">
|
||||
{allowance.value.source === 'bootstrap'
|
||||
? 'Default allowance'
|
||||
: `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}
|
||||
</dd>
|
||||
</div>{/each}
|
||||
</dl>
|
||||
<p>
|
||||
Starting servers may need more resources. The controller checks
|
||||
current capacity before reserving a host and port.
|
||||
</p>
|
||||
<p>
|
||||
{host.updatedAt
|
||||
? `Reported ${$timeFormat.dateTime(host.updatedAt)}`
|
||||
: 'No host report yet'}
|
||||
</p>
|
||||
</div>
|
||||
</td></tr
|
||||
>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-wrap { position:relative; overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
table { width:100%; min-width:800px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
|
||||
th { padding:12px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
|
||||
td { padding:14px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); font-variant-numeric:tabular-nums; }
|
||||
tr:hover>td { background:var(--surface-raised); }
|
||||
a { color:var(--accent-primary-hover); } .name { color:var(--text-primary); font-weight:600; text-decoration:none; }
|
||||
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
|
||||
.estimate { font-weight:600; } .limitation { max-width:280px; } .limitation strong { font-weight:500; }
|
||||
.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; margin-top:4px; }
|
||||
button { border:0; background:none; color:var(--accent-primary-hover); font:inherit; cursor:pointer; padding:4px; }
|
||||
button:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.detail-cell { background:var(--surface-sunken); } .detail-content { display:grid; gap:12px; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
p,dl,ul { margin:0; } ul { padding-left:18px; line-height:1.7; }
|
||||
dl { display:flex; gap:40px; flex-wrap:wrap; } dt { color:var(--text-primary); font-weight:500; } dd { margin:6px 0 0; }
|
||||
.secondary { color:var(--text-tertiary); }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr:hover > td {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.name:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.estimate {
|
||||
font-weight: 600;
|
||||
}
|
||||
.limitation {
|
||||
max-width: 280px;
|
||||
}
|
||||
.limitation strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.reason {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent-primary-hover);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.detail-cell {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
p,
|
||||
dl,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
ul {
|
||||
padding-left: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
dd {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import type { RebalancePreview, RebalanceReport, RebalanceRun, RebalanceSettings } from '$lib/rebalancing.js';
|
||||
import type {
|
||||
RebalancePreview,
|
||||
RebalanceReport,
|
||||
RebalanceRun,
|
||||
RebalanceSettings
|
||||
} from '$lib/rebalancing.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { rebalanceRunLabels } from '$ui/rebalancing-presentation.js';
|
||||
@@ -9,8 +14,14 @@
|
||||
import RebalancePreviewView from './RebalancePreviewView.svelte';
|
||||
import RebalanceRunView from './RebalanceRunView.svelte';
|
||||
|
||||
let { canManage, preferredHost = null, preferredRun = null }: {
|
||||
canManage: boolean; preferredHost?: string | null; preferredRun?: string | null;
|
||||
let {
|
||||
canManage,
|
||||
preferredHost = null,
|
||||
preferredRun = null
|
||||
}: {
|
||||
canManage: boolean;
|
||||
preferredHost?: string | null;
|
||||
preferredRun?: string | null;
|
||||
} = $props();
|
||||
let report = $state<RebalanceReport | null>(null);
|
||||
let selectedHosts = $state<string[]>([]);
|
||||
@@ -29,132 +40,472 @@
|
||||
let sequence = 0;
|
||||
let loadRequest: AbortController | null = null;
|
||||
let actionRequest: AbortController | null = null;
|
||||
const activeRun = $derived(report?.runs.find(run => run.state === 'running' || run.state === 'stopping') ?? null);
|
||||
const run = $derived(report?.runs.find(item => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null);
|
||||
const validSettings = $derived(selectedHosts.length >= 2 && selectedHosts.length <= 100 && Number.isInteger(maxMoves) && maxMoves! >= 1 && maxMoves! <= 20);
|
||||
const activeRun = $derived(
|
||||
report?.runs.find((run) => run.state === 'running' || run.state === 'stopping') ?? null
|
||||
);
|
||||
const run = $derived(
|
||||
report?.runs.find((item) => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null
|
||||
);
|
||||
const validSettings = $derived(
|
||||
selectedHosts.length >= 2 &&
|
||||
selectedHosts.length <= 100 &&
|
||||
Number.isInteger(maxMoves) &&
|
||||
maxMoves! >= 1 &&
|
||||
maxMoves! <= 20
|
||||
);
|
||||
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||
function editSettings() { preview = null; actionError = ''; notice = ''; }
|
||||
function editSettings() {
|
||||
preview = null;
|
||||
actionError = '';
|
||||
notice = '';
|
||||
}
|
||||
function toggleHost(id: string, checked: boolean) {
|
||||
selectedHosts = checked ? [...selectedHosts, id] : selectedHosts.filter(value => value !== id);
|
||||
selectedHosts = checked
|
||||
? [...selectedHosts, id]
|
||||
: selectedHosts.filter((value) => value !== id);
|
||||
editSettings();
|
||||
}
|
||||
function cancelLoad() { sequence++; loadRequest?.abort(); loadRequest = null; loading = false; }
|
||||
function cancelLoad() {
|
||||
sequence++;
|
||||
loadRequest?.abort();
|
||||
loadRequest = null;
|
||||
loading = false;
|
||||
}
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (busy || loading) return;
|
||||
const current = ++sequence;
|
||||
const controller = new AbortController();
|
||||
loadRequest = controller; loading = true;
|
||||
loadRequest = controller;
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/rebalancing', { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]) });
|
||||
const response = await fetch('/api/rebalancing', {
|
||||
signal: AbortSignal.any([
|
||||
controller.signal,
|
||||
AbortSignal.timeout(20_000),
|
||||
...(signal ? [signal] : [])
|
||||
])
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not refresh rebalancing.');
|
||||
if (current !== sequence) return;
|
||||
report = body as RebalanceReport; refreshError = ''; checkedAt = Date.now(); now = Date.now();
|
||||
report = body as RebalanceReport;
|
||||
refreshError = '';
|
||||
checkedAt = Date.now();
|
||||
now = Date.now();
|
||||
if (!initialized) {
|
||||
selectedHosts = preferredHost ? report.hosts.filter(host => host.id === preferredHost && host.eligible).map(host => host.id)
|
||||
: report.hosts.filter(host => host.eligible).map(host => host.id).slice(0, 100);
|
||||
selectedHosts = preferredHost
|
||||
? report.hosts
|
||||
.filter((host) => host.id === preferredHost && host.eligible)
|
||||
.map((host) => host.id)
|
||||
: report.hosts
|
||||
.filter((host) => host.eligible)
|
||||
.map((host) => host.id)
|
||||
.slice(0, 100);
|
||||
initialized = true;
|
||||
} else {
|
||||
const eligible = new Set(report.hosts.filter(host => host.eligible).map(host => host.id));
|
||||
const available = selectedHosts.filter(id => eligible.has(id));
|
||||
const eligible = new Set(
|
||||
report.hosts.filter((host) => host.eligible).map((host) => host.id)
|
||||
);
|
||||
const available = selectedHosts.filter((id) => eligible.has(id));
|
||||
if (available.length !== selectedHosts.length) {
|
||||
selectedHosts = available; preview = null;
|
||||
notice = 'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
|
||||
selectedHosts = available;
|
||||
preview = null;
|
||||
notice =
|
||||
'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
if (current === sequence && !controller.signal.aborted && !signal?.aborted) refreshError = (cause as Error).message;
|
||||
if (current === sequence && !controller.signal.aborted && !signal?.aborted)
|
||||
refreshError = (cause as Error).message;
|
||||
return false;
|
||||
} finally { if (current === sequence) { loadRequest = null; loading = false; } }
|
||||
} finally {
|
||||
if (current === sequence) {
|
||||
loadRequest = null;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function act(action: 'preview' | 'start' | 'stop') {
|
||||
if (busy || !canManage) return;
|
||||
const payload = action === 'preview' ? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
|
||||
: action === 'start' ? { action, previewId: preview?.id } : { action, id: run?.id };
|
||||
cancelLoad(); busy = action; actionError = ''; notice = '';
|
||||
const payload =
|
||||
action === 'preview'
|
||||
? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
|
||||
: action === 'start'
|
||||
? { action, previewId: preview?.id }
|
||||
: { action, id: run?.id };
|
||||
cancelLoad();
|
||||
busy = action;
|
||||
actionError = '';
|
||||
notice = '';
|
||||
if (action === 'preview') preview = null;
|
||||
const controller = new AbortController(); actionRequest = controller;
|
||||
const controller = new AbortController();
|
||||
actionRequest = controller;
|
||||
try {
|
||||
const response = await fetch('/api/rebalancing', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) });
|
||||
const response = await fetch('/api/rebalancing', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Rebalance request failed.');
|
||||
if (action === 'preview') preview = body.preview;
|
||||
else {
|
||||
const updated = body.run as RebalanceRun;
|
||||
if (report) report = { ...report, runs: [updated, ...report.runs.filter(item => item.id !== updated.id)] };
|
||||
selectedRun = updated.id; preview = null;
|
||||
notice = action === 'start' ? 'Rebalance started. Moves run one at a time.' : 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
|
||||
if (report)
|
||||
report = {
|
||||
...report,
|
||||
runs: [updated, ...report.runs.filter((item) => item.id !== updated.id)]
|
||||
};
|
||||
selectedRun = updated.id;
|
||||
preview = null;
|
||||
notice =
|
||||
action === 'start'
|
||||
? 'Rebalance started. Moves run one at a time.'
|
||||
: 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
|
||||
}
|
||||
now = Date.now();
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
|
||||
if (!controller.signal.aborted)
|
||||
actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) { busy = null; actionRequest = null; await load(); }
|
||||
if (!controller.signal.aborted) {
|
||||
busy = null;
|
||||
actionRequest = null;
|
||||
await load();
|
||||
}
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
const stop = pollVisible(load, 10_000);
|
||||
const timer = setInterval(() => now = Date.now(), 5_000);
|
||||
return () => { stop(); clearInterval(timer); cancelLoad(); actionRequest?.abort(); };
|
||||
const timer = setInterval(() => (now = Date.now()), 5_000);
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(timer);
|
||||
cancelLoad();
|
||||
actionRequest?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="rebalance-title">
|
||||
<div class="heading"><div><h2 id="rebalance-title">Rebalance instances</h2><p>Spread existing instances across hosts according to CPU and memory use relative to each host’s capacity.</p></div>
|
||||
{#if checkedAt && !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="rebalance-title">Rebalance instances</h2>
|
||||
<p>
|
||||
Spread existing instances across hosts according to CPU and memory use relative to
|
||||
each host’s capacity.
|
||||
</p>
|
||||
</div>
|
||||
{#if checkedAt && !refreshError}<Freshness
|
||||
updatedAt={checkedAt}
|
||||
intervalLabel="Updates every 10 seconds"
|
||||
/>{/if}
|
||||
</div>
|
||||
<p class="help">Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing a temporary outage; its address or port may change. The server identity, map, key, and recorded deployed files are retained. Local mutable files are not copied.</p>
|
||||
{#if refreshError}<div class="error" role="alert"><p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p><Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button></div>{/if}
|
||||
<p class="help">
|
||||
Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing
|
||||
a temporary outage; its address or port may change. The server identity, map, key, and
|
||||
recorded deployed files are retained. Local mutable files are not copied.
|
||||
</p>
|
||||
{#if refreshError}<div class="error" role="alert">
|
||||
<p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p>
|
||||
<Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if actionError}<p class="error" role="alert">{actionError}</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if !report && loading}<p role="status">Loading hosts and rebalance runs…</p>{/if}
|
||||
{#if report}
|
||||
{#if canManage}
|
||||
<form onsubmit={event => { event.preventDefault(); void act('preview'); }}>
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void act('preview');
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={!!busy || !!activeRun}>
|
||||
<legend>Participating hosts</legend>
|
||||
<p class="help">Choose at least two hosts. Existing lobbies may move between any selected hosts.</p>
|
||||
{#if preferredHost && !report.hosts.some(host => host.id === preferredHost && host.eligible)}<p class="help">The host opened from the shortcut is currently unavailable. Choose eligible hosts below.</p>{/if}
|
||||
<div class="host-selection">{#each report.hosts as host (host.id)}
|
||||
<label class:unavailable={!host.eligible}><input type="checkbox" checked={selectedHosts.includes(host.id)} disabled={!host.eligible} onchange={event => toggleHost(host.id, event.currentTarget.checked)} />
|
||||
<span><strong>{host.name}</strong>{#if host.reason}<small>{host.reason}</small>{/if}</span></label>
|
||||
{/each}</div>
|
||||
<div class="settings"><label>Game<select bind:value={game} onchange={editSettings}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Maximum moves<input type="number" min="1" max="20" step="1" required bind:value={maxMoves} oninput={editSettings} /></label>
|
||||
<Button type="submit" disabled={!validSettings || !!busy || !!activeRun || !!refreshError} loading={busy === 'preview'}>Preview rebalance</Button>
|
||||
<p class="help">
|
||||
Choose at least two hosts. Existing lobbies may move between any selected
|
||||
hosts.
|
||||
</p>
|
||||
{#if preferredHost && !report.hosts.some((host) => host.id === preferredHost && host.eligible)}<p
|
||||
class="help"
|
||||
>
|
||||
The host opened from the shortcut is currently unavailable. Choose
|
||||
eligible hosts below.
|
||||
</p>{/if}
|
||||
<div class="host-selection">
|
||||
{#each report.hosts as host (host.id)}
|
||||
<label class:unavailable={!host.eligible}
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(host.id)}
|
||||
disabled={!host.eligible}
|
||||
onchange={(event) =>
|
||||
toggleHost(host.id, event.currentTarget.checked)}
|
||||
/>
|
||||
<span
|
||||
><strong>{host.name}</strong>{#if host.reason}<small
|
||||
>{host.reason}</small
|
||||
>{/if}</span
|
||||
></label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="settings">
|
||||
<label
|
||||
>Game<select bind:value={game} onchange={editSettings}
|
||||
><option value="all">All games</option><option value="t5"
|
||||
>Black Ops I</option
|
||||
><option value="t6">Black Ops II</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Maximum moves<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
required
|
||||
bind:value={maxMoves}
|
||||
oninput={editSettings}
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!validSettings || !!busy || !!activeRun || !!refreshError}
|
||||
loading={busy === 'preview'}>Preview rebalance</Button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{#if activeRun}<p class="help">A rebalance is {activeRun.state === 'stopping' ? 'finishing its current move' : 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id} <button class="text-button" onclick={() => selectedRun = activeRun.id}>View active run</button>{/if}</p>{/if}
|
||||
{:else}<p class="help">Operators and administrators can preview, start, and stop rebalance runs.</p>{/if}
|
||||
{#if preview}<RebalancePreviewView {preview} {expired} disabled={!!busy || !!activeRun || !!refreshError} starting={busy === 'start'} onstart={() => void act('start')} />{/if}
|
||||
{#if activeRun}<p class="help">
|
||||
A rebalance is {activeRun.state === 'stopping'
|
||||
? 'finishing its current move'
|
||||
: 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id}
|
||||
<button class="text-button" onclick={() => (selectedRun = activeRun.id)}
|
||||
>View active run</button
|
||||
>{/if}
|
||||
</p>{/if}
|
||||
{:else}<p class="help">
|
||||
Operators and administrators can preview, start, and stop rebalance runs.
|
||||
</p>{/if}
|
||||
{#if preview}<RebalancePreviewView
|
||||
{preview}
|
||||
{expired}
|
||||
disabled={!!busy || !!activeRun || !!refreshError}
|
||||
starting={busy === 'start'}
|
||||
onstart={() => void act('start')}
|
||||
/>{/if}
|
||||
<section class="runs" aria-labelledby="rebalance-runs-title">
|
||||
<div class="heading"><h3 id="rebalance-runs-title">Rebalance runs</h3>{#if report.runs.length}<label class="run-picker"><span class="sr-only">Select rebalance run</span><select value={run?.id ?? ''} onchange={event => selectedRun = event.currentTarget.value}>{#each report.runs as item (item.id)}<option value={item.id}>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[item.state]}</option>{/each}</select></label>{/if}</div>
|
||||
{#if preferredRun && !report.runs.some(item => item.id === preferredRun)}<p class="help">The linked run is not in the recent history.</p>{/if}
|
||||
{#if run}<RebalanceRunView {run} {canManage} busy={!!busy} stopping={busy === 'stop'} onstop={() => void act('stop')} />
|
||||
<div class="heading">
|
||||
<h3 id="rebalance-runs-title">Rebalance runs</h3>
|
||||
{#if report.runs.length}<label class="run-picker"
|
||||
><span class="sr-only">Select rebalance run</span><select
|
||||
value={run?.id ?? ''}
|
||||
onchange={(event) => (selectedRun = event.currentTarget.value)}
|
||||
>{#each report.runs as item (item.id)}<option value={item.id}
|
||||
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
|
||||
item.state
|
||||
]}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>{/if}
|
||||
</div>
|
||||
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
|
||||
class="help"
|
||||
>
|
||||
The linked run is not in the recent history.
|
||||
</p>{/if}
|
||||
{#if run}<RebalanceRunView
|
||||
{run}
|
||||
{canManage}
|
||||
busy={!!busy}
|
||||
stopping={busy === 'stop'}
|
||||
onstop={() => void act('stop')}
|
||||
/>
|
||||
{:else}<p class="help">No rebalance runs yet.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { display:grid; gap:var(--space-5); padding:24px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); min-width:0; }
|
||||
h2,h3,p { margin:0; } h2 { font-size:var(--text-lg); } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; } .heading>div>p { margin-top:8px; color:var(--text-secondary); }
|
||||
.heading,.settings { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
fieldset { border:0; padding:0; margin:0; display:grid; gap:12px; min-width:0; } legend { font-size:var(--text-sm); font-weight:600; padding:0 0 8px; }
|
||||
.host-selection { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:8px; }
|
||||
.host-selection label { display:flex; align-items:flex-start; gap:10px; padding:12px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; }
|
||||
.host-selection input { margin:3px 0 0; accent-color:var(--accent-primary); }
|
||||
.host-selection span { display:grid; gap:4px; min-width:0; } .host-selection strong { font-size:var(--text-sm); font-weight:500; overflow-wrap:anywhere; }
|
||||
.host-selection small { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; } .host-selection .unavailable { opacity:0.65; cursor:default; }
|
||||
.settings { justify-content:flex-start; align-items:end; } .settings label { display:grid; gap:8px; font-size:var(--text-xs); } .settings input { width:110px; }
|
||||
select,input[type='number'] { box-sizing:border-box; padding:9px 12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:var(--text-sm) var(--font-body); min-width:0; }
|
||||
input:disabled,select:disabled { opacity:0.6; } .run-picker { max-width:100%; } .run-picker select { max-width:100%; }
|
||||
.runs { display:grid; grid-template-columns:minmax(0,1fr); gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
|
||||
.error { display:flex; gap:12px; align-items:center; justify-content:space-between; padding:12px 16px; color:var(--state-danger); border:1px solid var(--state-danger); border-radius:var(--radius-sm); font-size:var(--text-sm); overflow-wrap:anywhere; } .notice { color:var(--state-online); }
|
||||
.text-button { color:var(--accent-primary-hover); } .text-button { border:0; padding:0; background:none; font:inherit; cursor:pointer; text-decoration:underline; }
|
||||
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
||||
@media(max-width:600px) { .panel { padding:16px; } }
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading > div > p {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.heading,
|
||||
.settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
legend {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.host-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.host-selection label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.host-selection input {
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.host-selection span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.host-selection strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-selection small {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.host-selection .unavailable {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.settings {
|
||||
justify-content: flex-start;
|
||||
align-items: end;
|
||||
}
|
||||
.settings label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.settings input {
|
||||
width: 110px;
|
||||
}
|
||||
select,
|
||||
input[type='number'] {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
font: var(--text-sm) var(--font-body);
|
||||
min-width: 0;
|
||||
}
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.run-picker {
|
||||
max-width: 100%;
|
||||
}
|
||||
.run-picker select {
|
||||
max-width: 100%;
|
||||
}
|
||||
.runs {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
color: var(--state-danger);
|
||||
border: 1px solid var(--state-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.text-button {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,38 +4,207 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
let { preview, expired, disabled = false, starting = false, onstart }: {
|
||||
preview: RebalancePreview; expired: boolean; disabled?: boolean; starting?: boolean; onstart: () => void;
|
||||
let {
|
||||
preview,
|
||||
expired,
|
||||
disabled = false,
|
||||
starting = false,
|
||||
onstart
|
||||
}: {
|
||||
preview: RebalancePreview;
|
||||
expired: boolean;
|
||||
disabled?: boolean;
|
||||
starting?: boolean;
|
||||
onstart: () => void;
|
||||
} = $props();
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
function percent(value: number) { return `${numbers.format(value)}%`; }
|
||||
function percent(value: number) {
|
||||
return `${numbers.format(value)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="preview" aria-labelledby="rebalance-preview-title">
|
||||
<div class="heading"><h3 id="rebalance-preview-title">Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}</h3><span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span></div>
|
||||
<div class="heading">
|
||||
<h3 id="rebalance-preview-title">
|
||||
Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}
|
||||
</h3>
|
||||
<span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0"><table><thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody>
|
||||
{#each preview.hosts as host (host.id)}<tr><td>{host.name}</td><td>{host.before.instances} → {host.after.instances}</td><td>{percent(host.before.cpuPercent)} → {percent(host.after.cpuPercent)}</td><td>{percent(host.before.memoryPercent)} → {percent(host.after.memoryPercent)}</td></tr>{/each}
|
||||
</tbody></table></div>
|
||||
<p class="help">Current → projected distribution. CPU and memory estimates include the full load on each participating host.</p>
|
||||
{#if preview.moves.length}<ol class="moves">{#each preview.moves as move}<li><a href={contextHref(`/servers/${encodeURIComponent(move.server)}`, '/capacity?rebalance=1')}>{move.server}</a><span>{move.sourceHostName} → {move.targetHostName}</span></li>{/each}</ol>
|
||||
{:else}<p>No eligible moves would improve the distribution enough. Occupied lobbies and other excluded instances stay on their current hosts.</p>{/if}
|
||||
{#if preview.warnings.length}<ul class="warnings">{#each preview.warnings as warning}<li>{warning}</li>{/each}</ul>{/if}
|
||||
{#if preview.exclusions.length}<details><summary>{preview.exclusions.length} excluded {plural(preview.exclusions.length, 'instance')}</summary><ul class="exclusions">{#each preview.exclusions as exclusion}<li><strong>{exclusion.server}</strong><span>{exclusion.reason}</span></li>{/each}</ul></details>{/if}
|
||||
<div class="start"><Button variant="primary" disabled={!preview.moves.length || disabled || expired} loading={starting} onclick={onstart}>Start rebalance</Button><p class="help">{expired ? 'This preview has expired. Preview again before starting.' : 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}</p></div>
|
||||
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody
|
||||
>
|
||||
{#each preview.hosts as host (host.id)}<tr
|
||||
><td>{host.name}</td><td
|
||||
>{host.before.instances} → {host.after.instances}</td
|
||||
><td
|
||||
>{percent(host.before.cpuPercent)} → {percent(
|
||||
host.after.cpuPercent
|
||||
)}</td
|
||||
><td
|
||||
>{percent(host.before.memoryPercent)} → {percent(
|
||||
host.after.memoryPercent
|
||||
)}</td
|
||||
></tr
|
||||
>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help">
|
||||
Current → projected distribution. CPU and memory estimates include the full load on each
|
||||
participating host.
|
||||
</p>
|
||||
{#if preview.moves.length}<ol class="moves">
|
||||
{#each preview.moves as move}<li>
|
||||
<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(move.server)}`,
|
||||
'/capacity?rebalance=1'
|
||||
)}>{move.server}</a
|
||||
><span>{move.sourceHostName} → {move.targetHostName}</span>
|
||||
</li>{/each}
|
||||
</ol>
|
||||
{:else}<p>
|
||||
No eligible moves would improve the distribution enough. Occupied lobbies and other
|
||||
excluded instances stay on their current hosts.
|
||||
</p>{/if}
|
||||
{#if preview.warnings.length}<ul class="warnings">
|
||||
{#each preview.warnings as warning}<li>{warning}</li>{/each}
|
||||
</ul>{/if}
|
||||
{#if preview.exclusions.length}<details>
|
||||
<summary
|
||||
>{preview.exclusions.length} excluded {plural(
|
||||
preview.exclusions.length,
|
||||
'instance'
|
||||
)}</summary
|
||||
>
|
||||
<ul class="exclusions">
|
||||
{#each preview.exclusions as exclusion}<li>
|
||||
<strong>{exclusion.server}</strong><span>{exclusion.reason}</span>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}
|
||||
<div class="start">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!preview.moves.length || disabled || expired}
|
||||
loading={starting}
|
||||
onclick={onstart}>Start rebalance</Button
|
||||
>
|
||||
<p class="help">
|
||||
{expired
|
||||
? 'This preview has expired. Preview again before starting.'
|
||||
: 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.preview { display:grid; gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
|
||||
h3,p { margin:0; } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; }
|
||||
.heading,.start { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
|
||||
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); font-variant-numeric:tabular-nums; }
|
||||
th,td { padding:12px 16px; } th { background:var(--surface-sunken); color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; } td { border-top:1px solid var(--border-subtle); }
|
||||
.moves,.exclusions { margin:0; padding-left:20px; display:grid; gap:12px; font-size:var(--text-sm); } .moves span,.exclusions span { display:block; margin-top:4px; color:var(--text-secondary); font-size:var(--text-xs); } .exclusions { margin-top:12px; } .exclusions strong { font-weight:500; }
|
||||
.warnings { border-left:2px solid var(--state-warning); padding:0 0 0 22px; margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; }
|
||||
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
|
||||
.start { justify-content:flex-start; } .start p { flex:1 1 280px; }
|
||||
a { color:var(--accent-primary-hover); } a:focus-visible,summary:focus-visible,.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.preview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading,
|
||||
.start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
th {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
td {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.moves,
|
||||
.exclusions {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.moves span,
|
||||
.exclusions span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.exclusions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.exclusions strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.warnings {
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding: 0 0 0 22px;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.start p {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
a:focus-visible,
|
||||
summary:focus-visible,
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,150 @@
|
||||
<script lang="ts">
|
||||
import type { RebalanceRun } from '$lib/rebalancing.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { rebalanceMoveLabels, rebalanceRunLabels, rebalanceRunHref } from '$ui/rebalancing-presentation.js';
|
||||
import {
|
||||
rebalanceMoveLabels,
|
||||
rebalanceRunLabels,
|
||||
rebalanceRunHref
|
||||
} from '$ui/rebalancing-presentation.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
let { run, canManage, busy = false, stopping = false, onstop }: {
|
||||
run: RebalanceRun; canManage: boolean; busy?: boolean; stopping?: boolean; onstop: () => void;
|
||||
let {
|
||||
run,
|
||||
canManage,
|
||||
busy = false,
|
||||
stopping = false,
|
||||
onstop
|
||||
}: {
|
||||
run: RebalanceRun;
|
||||
canManage: boolean;
|
||||
busy?: boolean;
|
||||
stopping?: boolean;
|
||||
onstop: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="heading"><div class="run-status"><Badge variant={run.state === 'failed' ? 'danger' : run.state === 'done' ? 'online' : run.state === 'stopped' ? 'neutral' : 'info'}>{rebalanceRunLabels[run.state]}</Badge><span>{run.moves.filter(move => move.state === 'done').length} of {run.moves.length} moves completed</span><a href={rebalanceRunHref(run.id)}>Link to run</a></div>
|
||||
{#if canManage && run.state === 'running'}<Button disabled={busy} loading={stopping} onclick={onstop}>Stop remaining moves</Button>{/if}</div>
|
||||
<div class="heading">
|
||||
<div class="run-status">
|
||||
<Badge
|
||||
variant={run.state === 'failed'
|
||||
? 'danger'
|
||||
: run.state === 'done'
|
||||
? 'online'
|
||||
: run.state === 'stopped'
|
||||
? 'neutral'
|
||||
: 'info'}>{rebalanceRunLabels[run.state]}</Badge
|
||||
><span
|
||||
>{run.moves.filter((move) => move.state === 'done').length} of {run.moves.length} moves completed</span
|
||||
><a href={rebalanceRunHref(run.id)}>Link to run</a>
|
||||
</div>
|
||||
{#if canManage && run.state === 'running'}<Button
|
||||
disabled={busy}
|
||||
loading={stopping}
|
||||
onclick={onstop}>Stop remaining moves</Button
|
||||
>{/if}
|
||||
</div>
|
||||
<p role="status">{run.message}</p>
|
||||
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">Stopping withdraws unstarted moves. A move already in progress continues through health verification.</p>{/if}
|
||||
<ol class="run-moves">{#each run.moves as move (move.id)}<li><div class="heading"><div class="move-identity"><strong>{move.server}</strong><span>{move.sourceHostName} → {move.targetHostName}</span></div><Badge variant={move.state === 'failed' ? 'danger' : move.state === 'done' ? 'online' : move.state === 'moving' ? 'info' : 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge></div><p>{move.message}</p>{#if move.operationId}<a href={contextHref(`/provisioning/${encodeURIComponent(move.operationId)}`, rebalanceRunHref(run.id))}>View provisioning →</a>{/if}</li>{/each}</ol>
|
||||
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">
|
||||
Stopping withdraws unstarted moves. A move already in progress continues through health
|
||||
verification.
|
||||
</p>{/if}
|
||||
<ol class="run-moves">
|
||||
{#each run.moves as move (move.id)}<li>
|
||||
<div class="heading">
|
||||
<div class="move-identity">
|
||||
<strong>{move.server}</strong><span
|
||||
>{move.sourceHostName} → {move.targetHostName}</span
|
||||
>
|
||||
</div>
|
||||
<Badge
|
||||
variant={move.state === 'failed'
|
||||
? 'danger'
|
||||
: move.state === 'done'
|
||||
? 'online'
|
||||
: move.state === 'moving'
|
||||
? 'info'
|
||||
: 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge
|
||||
>
|
||||
</div>
|
||||
<p>{move.message}</p>
|
||||
{#if move.operationId}<a
|
||||
href={contextHref(
|
||||
`/provisioning/${encodeURIComponent(move.operationId)}`,
|
||||
rebalanceRunHref(run.id)
|
||||
)}>View provisioning →</a
|
||||
>{/if}
|
||||
</li>{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
p { margin:0; font-size:var(--text-sm); line-height:1.6; }
|
||||
.heading,.run-status { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
.run-status { justify-content:flex-start; font-size:var(--text-xs); color:var(--text-secondary); } .run-moves { margin:0; padding:0; list-style:none; display:grid; gap:12px; }
|
||||
.run-moves li { display:grid; gap:8px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } .run-moves p,.run-moves a { font-size:var(--text-xs); } .run-moves p { color:var(--text-secondary); }
|
||||
.move-identity { display:grid; gap:5px; font-size:var(--text-sm); min-width:0; overflow-wrap:anywhere; } .move-identity strong { font-weight:500; } .move-identity span { font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
a { color:var(--accent-primary-hover); } a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
@media(max-width:600px) { .run-moves li { padding:12px; } }
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading,
|
||||
.run-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.run-status {
|
||||
justify-content: flex-start;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.run-moves {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.run-moves li {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.run-moves p,
|
||||
.run-moves a {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.run-moves p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.move-identity {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.move-identity strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.move-identity span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.run-moves li {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,19 +3,29 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '$ui/metrics.js';
|
||||
import {
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent,
|
||||
metricPeriodLabels
|
||||
} from '$ui/metrics.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
|
||||
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } =
|
||||
$props();
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const format = $derived(createTimeFormatter(timeZone));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const mode = $derived(
|
||||
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent'
|
||||
);
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex(point => point.at === selectedAt);
|
||||
const existing = points.findIndex((point) => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
|
||||
return Math.max(0, points.length - 1);
|
||||
@@ -24,32 +34,43 @@
|
||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||
const ceiling = $derived.by(() => {
|
||||
if (mode === 'percent') return 100;
|
||||
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
|
||||
const max = Math.max(0, ...points.map((point) => point.observed + point.unknown));
|
||||
if (!max) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
||||
});
|
||||
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
||||
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
|
||||
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
|
||||
const ticks = $derived(
|
||||
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
|
||||
fraction,
|
||||
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
|
||||
}))
|
||||
);
|
||||
const periodOptions = $derived(
|
||||
points.map((point) => ({
|
||||
value: String(point.at),
|
||||
label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}`
|
||||
}))
|
||||
);
|
||||
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function setMode(value: 'hours' | 'percent') {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
|
||||
if (value === 'percent') url.searchParams.delete('chartMode');
|
||||
else url.searchParams.set('chartMode', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function selectPeriod(at: number) {
|
||||
selectedAt = at;
|
||||
const point = points.find(item => item.at === at);
|
||||
const point = points.find((item) => item.at === at);
|
||||
if (!point) return;
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
|
||||
}
|
||||
function segmentHeight(point: MetricPoint, value: number) {
|
||||
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
|
||||
return denominator > 0 ? value / denominator * 100 : 0;
|
||||
return denominator > 0 ? (value / denominator) * 100 : 0;
|
||||
}
|
||||
function move(direction: number) {
|
||||
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
@@ -58,129 +79,600 @@
|
||||
function chartKey(event: KeyboardEvent, index: number) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
|
||||
);
|
||||
selectPeriod(points[next].at);
|
||||
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
(event.currentTarget as HTMLElement).parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('button')
|
||||
[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading"><div><h2 id="metrics-trend-title">Server activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="metrics-trend-title">Server activity over time</h2>
|
||||
<p>
|
||||
{mode === 'percent'
|
||||
? 'Share of captured server time, including unknown status.'
|
||||
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain
|
||||
less elapsed time.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'percent'}
|
||||
onclick={() => setMode('percent')}>Percent</button
|
||||
><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}
|
||||
>Hours</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span
|
||||
><i class="unknown"></i>Unknown</span
|
||||
><span class="partial-key">* Partial period</span>
|
||||
</div>
|
||||
<div class="axis-title">
|
||||
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
|
||||
</div>
|
||||
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
|
||||
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
|
||||
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
|
||||
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
|
||||
>{item.value}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div
|
||||
class="plot"
|
||||
role="group"
|
||||
aria-label="Server time by period. Select a bar or use the period selector below."
|
||||
>
|
||||
<div class="grid-lines" aria-hidden="true">
|
||||
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
|
||||
></span>{/each}
|
||||
</div>
|
||||
{#each points as point, index (point.at)}
|
||||
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
|
||||
<button
|
||||
class="bar"
|
||||
class:selected={point.at === selected?.at}
|
||||
class:partial={point.partial}
|
||||
type="button"
|
||||
tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
|
||||
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
|
||||
onclick={() => selectPeriod(point.at)}
|
||||
onkeydown={(event) => chartKey(event, index)}
|
||||
>
|
||||
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
|
||||
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
|
||||
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
|
||||
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
|
||||
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
|
||||
<span
|
||||
class="segment unknown"
|
||||
style:height={`${segmentHeight(point, point.unknown)}%`}
|
||||
></span>
|
||||
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}
|
||||
></span>
|
||||
<span
|
||||
class="segment occupied"
|
||||
style:height={`${segmentHeight(point, point.active)}%`}
|
||||
></span>
|
||||
{#if point.observed + point.unknown === 0}<span
|
||||
class="no-time"
|
||||
aria-hidden="true">·</span
|
||||
>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
|
||||
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
|
||||
{#each periodLabels as item}<span
|
||||
style:left={`${item.position}%`}
|
||||
title={format.period(points[item.index].startAt)}
|
||||
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
|
||||
<p class="period-note">
|
||||
{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}
|
||||
</p>
|
||||
{#if selected}
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
|
||||
<details class="inspection"><summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
|
||||
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
|
||||
{:else}<dl>
|
||||
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
|
||||
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
|
||||
<div class="selection-summary" aria-label="Selected period summary">
|
||||
<time
|
||||
>{format.period(selected.startAt)}{selected.partial
|
||||
? ' · Partial period'
|
||||
: ''}</time
|
||||
><span
|
||||
>{captured
|
||||
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
|
||||
: 'No captured time · not evidence of idle servers'}</span
|
||||
>
|
||||
</div>
|
||||
<details class="inspection">
|
||||
<summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select">
|
||||
<Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}
|
||||
><Select
|
||||
value={selectedValue}
|
||||
options={periodOptions}
|
||||
onchange={(event) =>
|
||||
selectPeriod(
|
||||
Number((event.currentTarget as HTMLSelectElement).value)
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<div class="step-buttons">
|
||||
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
|
||||
>← Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={selectedIndex === points.length - 1}
|
||||
onclick={() => move(1)}>Next →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">
|
||||
{format.period(selected.startAt)} – {format.period(selected.endAt)}
|
||||
{#if selected.partial}<span class="partial-label">Partial period</span>{/if}
|
||||
</p>
|
||||
{#if captured === 0}<p class="empty">
|
||||
No captured time in this period. Missing observations do not mean the fleet
|
||||
was idle.
|
||||
</p>
|
||||
{:else}<dl>
|
||||
<div>
|
||||
<dt>With players</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.active)}<small
|
||||
>{metricPercent(selected.active, captured)} captured · {metricPercent(
|
||||
selected.active,
|
||||
selected.observed
|
||||
)} known</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Idle</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.idle)}<small
|
||||
>{metricPercent(selected.idle, captured)} of captured time</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unknown</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.unknown)}<small
|
||||
>{metricPercent(selected.unknown, captured)} of captured time</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Player-hours</dt>
|
||||
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="denominator">
|
||||
{metricDuration(captured)} captured server time · {metricDuration(
|
||||
selected.observed
|
||||
)} known. Percentage bars include unknown time; the occupancy KPI excludes it.
|
||||
</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
<details class="time-data"><summary>View exact time data</summary>
|
||||
<details class="time-data">
|
||||
<summary>View exact time data</summary>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
|
||||
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0">
|
||||
<table>
|
||||
<caption
|
||||
>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from
|
||||
captured observations.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">With players</th><th scope="col"
|
||||
>Idle</th
|
||||
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each points as point (point.at)}<tr
|
||||
><th scope="row"
|
||||
>{format.period(point.startAt)}<small
|
||||
>to {format.period(point.endAt)}{point.partial
|
||||
? ' · Partial'
|
||||
: ''}</small
|
||||
></th
|
||||
><td>{metricDuration(point.active)}</td><td
|
||||
>{metricDuration(point.idle)}</td
|
||||
><td>{metricDuration(point.unknown)}</td><td
|
||||
>{point.observed ? metricNumber(point.playerHours) : '—'}</td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selection-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
|
||||
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.occupied { background: var(--chart-active); }
|
||||
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), var(--chart-idle); }
|
||||
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.partial-key { color: var(--text-secondary); }
|
||||
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
|
||||
.y-axis { position: relative; text-align: right; font-size: var(--text-xs); color: var(--text-tertiary); height: 210px; }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
|
||||
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
|
||||
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
|
||||
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
|
||||
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
|
||||
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
|
||||
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
|
||||
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
|
||||
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); line-height: 1; }
|
||||
.bar.partial { border-inline: 1px dashed var(--border-strong); }
|
||||
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
|
||||
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
|
||||
.x-axis.with-dates { height: 3em; }
|
||||
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
|
||||
.x-axis small { margin: 0; }
|
||||
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
|
||||
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
|
||||
.period-select { flex: 1 1 250px; max-width: 450px; }
|
||||
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
|
||||
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
|
||||
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
|
||||
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
|
||||
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
|
||||
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; }
|
||||
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; margin: var(--space-3) 0 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
details { margin-top: var(--space-4); font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
|
||||
.table-wrap { position: relative; max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
thead th { color: var(--text-secondary); }
|
||||
th:first-child { text-align: left; }
|
||||
tbody th { font-weight: var(--weight-regular); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.mode button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode button[aria-pressed='true'] {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mode button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.heading p {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.legend span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.legend i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.occupied {
|
||||
background: var(--chart-active);
|
||||
}
|
||||
.idle {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0 5px,
|
||||
color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px
|
||||
),
|
||||
var(--chart-idle);
|
||||
}
|
||||
.unknown {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 3px,
|
||||
var(--chart-unknown-stripe) 3px 5px
|
||||
);
|
||||
}
|
||||
.partial-key {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.axis-title {
|
||||
margin-top: var(--space-5);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.y-axis {
|
||||
position: relative;
|
||||
text-align: right;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
height: 210px;
|
||||
}
|
||||
.y-axis span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.plot {
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 210px;
|
||||
gap: 4px;
|
||||
isolation: isolate;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.grid-lines span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.bar {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.bar.selected {
|
||||
outline: 1px solid var(--text-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.bar:focus-visible {
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.bar:hover {
|
||||
background: color-mix(in oklab, var(--accent-primary) 8%, transparent);
|
||||
}
|
||||
.segment {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.no-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
}
|
||||
.bar.partial {
|
||||
border-inline: 1px dashed var(--border-strong);
|
||||
}
|
||||
.partial-marker {
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.x-axis {
|
||||
grid-column: 2;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 1.6em;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.x-axis.with-dates {
|
||||
height: 3em;
|
||||
}
|
||||
.x-axis > span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.x-axis small {
|
||||
margin: 0;
|
||||
}
|
||||
.period-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
.period-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.period-select {
|
||||
flex: 1 1 250px;
|
||||
max-width: 450px;
|
||||
}
|
||||
.step-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.readout {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.interval {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.partial-label {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.denominator {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.legend {
|
||||
gap: 10px;
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.plot {
|
||||
gap: 3px;
|
||||
}
|
||||
dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.readout {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.step-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,25 +8,57 @@
|
||||
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
const recommendations = $derived(metricRecommendations(report.maps));
|
||||
const highlighted = $derived(recommendations.filter(item => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')).slice(0, 3));
|
||||
const remaining = $derived(recommendations.filter(item => !highlighted.includes(item)));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const highlighted = $derived(
|
||||
recommendations
|
||||
.filter(
|
||||
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
|
||||
)
|
||||
.slice(0, 3)
|
||||
);
|
||||
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const query = $derived((params.get('insightQ') ?? '').slice(0, 100));
|
||||
const sort = $derived(params.get('insightSort') === 'map' ? 'map' : 'priority');
|
||||
const visibleRemaining = $derived(remaining.filter(item => `${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0));
|
||||
const visibleRemaining = $derived(
|
||||
remaining
|
||||
.filter((item) =>
|
||||
`${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) =>
|
||||
sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0
|
||||
)
|
||||
);
|
||||
function updateContext(key: string, value: string) {
|
||||
const url = new URL(page.url); url.search = contextSearch;
|
||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
const limited = $derived(recommendations.filter(item => item.tentative).length);
|
||||
const limited = $derived(recommendations.filter((item) => item.tentative).length);
|
||||
</script>
|
||||
|
||||
{#snippet finding(suggestion: ReturnType<typeof metricRecommendations>[number])}
|
||||
<article class="recommendation">
|
||||
<div><strong>{suggestion.map}</strong><small>{suggestion.game.toUpperCase()}{suggestion.tentative ? ' · Limited evidence' : ''}</small></div>
|
||||
<div><strong>{suggestion.title}</strong><details><summary>Evidence and considerations</summary><p>{suggestion.evidence}</p><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
|
||||
<div>
|
||||
<strong>{suggestion.map}</strong><small
|
||||
>{suggestion.game.toUpperCase()}{suggestion.tentative
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{suggestion.title}</strong>
|
||||
<details>
|
||||
<summary>Evidence and considerations</summary>
|
||||
<p>{suggestion.evidence}</p>
|
||||
<p>{suggestion.reason}</p>
|
||||
<p>{suggestion.outcome}</p>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
{/snippet}
|
||||
<section class="explore" aria-labelledby="metrics-explore-title">
|
||||
@@ -34,32 +66,133 @@
|
||||
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
|
||||
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
||||
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
|
||||
{#if limited}<p class="notice">{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, player limits, and observation duration before changing hosting.</p>{/if}
|
||||
{#if remaining.length}<details class="remaining"><summary>Other findings ({remaining.length})</summary>
|
||||
<div class="finding-controls"><Field label="Find a map"><Input type="search" value={query} placeholder="Map or game…" oninput={event => updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /></Field><Field label="Sort findings"><Select value={sort} options={[{ value: 'priority', label: 'Priority' }, { value: 'map', label: 'Map A–Z' }]} onchange={event => updateContext('insightSort', (event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>No other findings match this search.</p>{/each}</details>{/if}
|
||||
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
|
||||
<ul>
|
||||
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
|
||||
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
|
||||
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
|
||||
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
|
||||
</ul>
|
||||
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
|
||||
</details>
|
||||
|
||||
{#if limited}<p class="notice">
|
||||
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting,
|
||||
player limits, and observation duration before changing hosting.
|
||||
</p>{/if}
|
||||
{#if remaining.length}<details class="remaining">
|
||||
<summary>Other findings ({remaining.length})</summary>
|
||||
<div class="finding-controls">
|
||||
<Field label="Find a map"
|
||||
><Input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Map or game…"
|
||||
oninput={(event) =>
|
||||
updateContext(
|
||||
'insightQ',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/></Field
|
||||
><Field label="Sort findings"
|
||||
><Select
|
||||
value={sort}
|
||||
options={[
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'map', label: 'Map A–Z' }
|
||||
]}
|
||||
onchange={(event) =>
|
||||
updateContext(
|
||||
'insightSort',
|
||||
(event.currentTarget as HTMLSelectElement).value
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>
|
||||
No other findings match this search.
|
||||
</p>{/each}
|
||||
</details>{/if}
|
||||
<details class="rules">
|
||||
<summary>How Rules v1 chooses a suggestion</summary>
|
||||
<ul>
|
||||
<li>
|
||||
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion *
|
||||
100}% of known-capacity time.
|
||||
</li>
|
||||
<li>
|
||||
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}%
|
||||
occupied known time can suggest a tentative additional-lobby trial. Occupancy does
|
||||
not establish saturation.
|
||||
</li>
|
||||
<li>
|
||||
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization *
|
||||
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure *
|
||||
100}% or capacity is unavailable.
|
||||
</li>
|
||||
<li>
|
||||
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or
|
||||
checking capacity reporting.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity,
|
||||
or substantial unknown status marks advice as tentative. These are trial policies, not validated
|
||||
demand forecasts. Collection gaps are omitted from all rules.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.finding-controls { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.explore { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
.recommendation { display: grid; grid-template-columns: minmax(140px, .6fr) minmax(0, 2fr); gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
small { display: block; color: var(--text-secondary); margin-top: 6px; font-size: var(--text-xs); }
|
||||
details { font-size: var(--text-xs); margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.remaining, .rules { margin-top: 20px; }
|
||||
.notice { border-left: 2px solid var(--state-warning); padding-left: 12px; }
|
||||
li { margin: 8px 0; color: var(--text-secondary); }
|
||||
@media (max-width: 540px) { .recommendation { grid-template-columns: 1fr; gap: 8px; } }
|
||||
.finding-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.explore {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.recommendation {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 2fr);
|
||||
gap: 20px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
details {
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 10px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.remaining,
|
||||
.rules {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.notice {
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding-left: 12px;
|
||||
}
|
||||
li {
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.recommendation {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,100 +7,469 @@
|
||||
|
||||
type Heatmap = MetricsData['heatmap'];
|
||||
type Cell = Heatmap['cells'][number];
|
||||
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
|
||||
let {
|
||||
heatmap,
|
||||
disabled = false,
|
||||
onTimeZoneChange
|
||||
}: {
|
||||
heatmap: Heatmap;
|
||||
disabled?: boolean;
|
||||
onTimeZoneChange: (value: string) => void;
|
||||
} = $props();
|
||||
const id = $props.id();
|
||||
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const hours = Array.from({ length: 24 }, (_, hour) => hour);
|
||||
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
|
||||
const zones = $derived(
|
||||
[...new Set([...availableTimeZones(), heatmap.timeZone])]
|
||||
.sort()
|
||||
.map((zone) => ({ value: zone, label: zone.replaceAll('_', ' ') }))
|
||||
);
|
||||
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
|
||||
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
|
||||
const hourOptions = hours.map((hour) => ({ value: String(hour), label: hourLabel(hour) }));
|
||||
let selectedKey = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
|
||||
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
|
||||
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
|
||||
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
|
||||
const cells = $derived(
|
||||
new Map(heatmap.cells.map((cell) => [cell.weekday * 24 + cell.hour, cell]))
|
||||
);
|
||||
const selected = $derived(
|
||||
cells.get(selectedKey ?? -1) ??
|
||||
heatmap.cells.find((cell) => cell.observed + cell.unknown > 0) ??
|
||||
heatmap.cells[0]
|
||||
);
|
||||
function hourLabel(hour: number) {
|
||||
return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`;
|
||||
}
|
||||
function cellAt(day: number, hour: number): Cell {
|
||||
return (
|
||||
cells.get(day * 24 + hour) ?? {
|
||||
weekday: day,
|
||||
hour,
|
||||
active: 0,
|
||||
observed: 0,
|
||||
idle: 0,
|
||||
unknown: 0,
|
||||
playerHours: 0,
|
||||
distinctDates: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
function description(cell: Cell) {
|
||||
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
|
||||
}
|
||||
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
|
||||
function select(day: number, hour: number) {
|
||||
selectedKey = day * 24 + hour;
|
||||
announcement = description(cellAt(day, hour));
|
||||
}
|
||||
function move(event: KeyboardEvent, cell: Cell) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
||||
return;
|
||||
event.preventDefault();
|
||||
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
|
||||
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
|
||||
const day = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
6,
|
||||
cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)
|
||||
)
|
||||
);
|
||||
const hour =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? 23
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
23,
|
||||
cell.hour +
|
||||
(event.key === 'ArrowLeft'
|
||||
? -1
|
||||
: event.key === 'ArrowRight'
|
||||
? 1
|
||||
: 0)
|
||||
)
|
||||
);
|
||||
const key = day * 24 + hour;
|
||||
select(day, hour);
|
||||
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
|
||||
(event.currentTarget as HTMLElement)
|
||||
.closest('table')
|
||||
?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)
|
||||
?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
|
||||
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
|
||||
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
|
||||
<p>
|
||||
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="zone">
|
||||
<Field label="Report timezone"
|
||||
><Select
|
||||
value={heatmap.timeZone}
|
||||
options={zones}
|
||||
{disabled}
|
||||
onchange={(event) =>
|
||||
onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span>0% <i class="scale"></i> 100% with players</span><span
|
||||
><i class="unknown-example"></i>Unknown only</span
|
||||
><span><i class="missing-example">·</i>No captured time</span>
|
||||
</div>
|
||||
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
|
||||
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
|
||||
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
|
||||
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
|
||||
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
|
||||
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
|
||||
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
|
||||
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
|
||||
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
|
||||
{/each}</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label="Activity heatmap; use arrow keys between cells or the selectors below"
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<caption class="sr-only"
|
||||
>Weekday and local hour activity. Cell details include known time and distinct
|
||||
recorded dates.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Day</th>{#each hours as hour}<th scope="col"
|
||||
>{String(hour).padStart(2, '0')}</th
|
||||
>{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each weekdays as day, weekday}<tr
|
||||
><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell =
|
||||
cellAt(weekday, hour)}
|
||||
<td
|
||||
><button
|
||||
type="button"
|
||||
class="cell"
|
||||
class:known={cell.observed > 0}
|
||||
class:unknown={cell.observed === 0 && cell.unknown > 0}
|
||||
class:mixed={cell.observed > 0 && cell.unknown > 0}
|
||||
class:missing={cell.observed + cell.unknown === 0}
|
||||
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, (100 * cell.active) / cell.observed)) : 0}%`}
|
||||
data-key={weekday * 24 + hour}
|
||||
tabindex={selected?.weekday === weekday &&
|
||||
selected?.hour === hour
|
||||
? 0
|
||||
: -1}
|
||||
aria-pressed={selected?.weekday === weekday &&
|
||||
selected?.hour === hour}
|
||||
aria-label={description(cell)}
|
||||
title={description(cell)}
|
||||
onclick={() => select(weekday, hour)}
|
||||
onkeydown={(event) => move(event, cell)}
|
||||
><span aria-hidden="true"
|
||||
>{cell.observed > 0
|
||||
? ''
|
||||
: cell.unknown > 0
|
||||
? '?'
|
||||
: '·'}</span
|
||||
></button
|
||||
></td
|
||||
>
|
||||
{/each}</tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
<p class="note">
|
||||
A striped corner marks additional unknown time. Color uses known time only. Repeated
|
||||
daylight-saving clock hours share a cell; collection gaps stay uncaptured.
|
||||
</p>
|
||||
{#if selected}
|
||||
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
|
||||
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
|
||||
<div class="selectors">
|
||||
<Field label="Inspect weekday"
|
||||
><Select
|
||||
value={String(selected.weekday)}
|
||||
options={dayOptions}
|
||||
onchange={(event) =>
|
||||
select(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
selected.hour
|
||||
)}
|
||||
/></Field
|
||||
><Field label="Inspect hour"
|
||||
><Select
|
||||
value={String(selected.hour)}
|
||||
options={hourOptions}
|
||||
onchange={(event) =>
|
||||
select(
|
||||
selected.weekday,
|
||||
Number((event.currentTarget as HTMLSelectElement).value)
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<div class="readout">
|
||||
<strong
|
||||
>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}</strong
|
||||
>
|
||||
{#if selected.observed + selected.unknown === 0}<p>
|
||||
No captured time for this weekday and hour. This does not mean the fleet was
|
||||
idle.
|
||||
</p>
|
||||
{:else}<dl>
|
||||
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
|
||||
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
|
||||
</dl>{/if}
|
||||
<div>
|
||||
<dt>Time with players</dt>
|
||||
<dd>
|
||||
{metricPercent(selected.active, selected.observed)}<small
|
||||
>{metricDuration(selected.active)} of {metricDuration(
|
||||
selected.observed
|
||||
)} known</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unknown server time</dt>
|
||||
<dd>{metricDuration(selected.unknown)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Player-hours</dt>
|
||||
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Distinct recorded dates</dt>
|
||||
<dd>{selected.distinctDates}</dd>
|
||||
</div>
|
||||
</dl>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
|
||||
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
|
||||
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.legend span { display: flex; gap: 5px; align-items: center; }
|
||||
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
|
||||
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
|
||||
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.missing-example { border: 1px dashed var(--border-control); }
|
||||
.table-wrap { position: relative; overflow-x: auto; max-width: 100%; padding: 3px; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
|
||||
th { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); text-align: center; padding: 0 0 3px; }
|
||||
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
|
||||
td { padding: 0; }
|
||||
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
|
||||
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
|
||||
.cell.missing { border-style: dashed; background: transparent; }
|
||||
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
|
||||
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
|
||||
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
|
||||
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
|
||||
dt { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.heatmap {
|
||||
min-width: 0;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin: var(--space-2) 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.zone {
|
||||
flex: 0 1 250px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.legend span {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.scale {
|
||||
width: 64px;
|
||||
height: 9px;
|
||||
background: linear-gradient(to right, var(--surface-raised), var(--chart-active));
|
||||
border-radius: 3px;
|
||||
}
|
||||
.unknown-example,
|
||||
.missing-example {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-style: normal;
|
||||
}
|
||||
.unknown-example,
|
||||
.cell.unknown {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 3px,
|
||||
var(--chart-unknown-stripe) 3px 5px
|
||||
);
|
||||
}
|
||||
.missing-example {
|
||||
border: 1px dashed var(--border-control);
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
padding: 3px;
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 3px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: normal;
|
||||
font-size: var(--text-xs);
|
||||
text-align: center;
|
||||
padding: 0 0 3px;
|
||||
}
|
||||
th:first-child {
|
||||
width: 35px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--surface);
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
}
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell.known {
|
||||
background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised));
|
||||
}
|
||||
.cell.missing {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
}
|
||||
.cell.mixed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 2px,
|
||||
var(--chart-unknown-stripe) 2px 4px
|
||||
);
|
||||
}
|
||||
.cell[aria-pressed='true'] {
|
||||
outline: 1px solid var(--text-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.cell:focus-visible {
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.selectors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 190px));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.readout {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.readout > strong {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
dt {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
font-size: var(--text-md);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.heatmap {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.zone {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.readout {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.selectors {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,36 +7,127 @@
|
||||
const prior = $derived(report.previous);
|
||||
const pressure = $derived(report.pooledPressure);
|
||||
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
|
||||
const occupancyDelta = $derived(comparable ? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
|
||||
const playerDelta = $derived(comparable && prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) : comparable && report.summary.playerHours === 0 ? 0 : null);
|
||||
const previousLabel = $derived(`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`);
|
||||
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
|
||||
const occupancyDelta = $derived(
|
||||
comparable
|
||||
? 100 *
|
||||
(report.summary.active / report.summary.observed -
|
||||
prior.summary.active / prior.summary.observed)
|
||||
: null
|
||||
);
|
||||
const playerDelta = $derived(
|
||||
comparable && prior.summary.playerHours > 0
|
||||
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
|
||||
: comparable && report.summary.playerHours === 0
|
||||
? 0
|
||||
: null
|
||||
);
|
||||
const previousLabel = $derived(
|
||||
`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`
|
||||
);
|
||||
function signed(value: number, unit: string) {
|
||||
return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="summary">
|
||||
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time with players"
|
||||
value={metricPercent(report.summary.active, report.summary.observed)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{occupancyDelta === null
|
||||
? 'Comparison unavailable'
|
||||
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}
|
||||
</p>
|
||||
<small
|
||||
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed
|
||||
server time</small
|
||||
>
|
||||
</Card>
|
||||
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
|
||||
<details><summary>What are player-hours?</summary><p>Players × observed time. One player for one hour equals one player-hour.</p></details>
|
||||
<Card
|
||||
><Stat
|
||||
label="Player-hours"
|
||||
value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{playerDelta === null
|
||||
? comparable && report.summary.playerHours > 0
|
||||
? 'New · no previous player-hours'
|
||||
: 'Comparison unavailable'
|
||||
: `${signed(playerDelta, '%')} vs ${previousLabel}`}
|
||||
</p>
|
||||
<details>
|
||||
<summary>What are player-hours?</summary>
|
||||
<p>Players × observed time. One player for one hour equals one player-hour.</p>
|
||||
</details>
|
||||
</Card>
|
||||
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
|
||||
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time all lobbies were full"
|
||||
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{pressure.knownMapHours > 0
|
||||
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data`
|
||||
: 'Capacity data unavailable'}
|
||||
</p>
|
||||
<small>Combined map history · each map measured separately</small>
|
||||
</Card>
|
||||
</div>
|
||||
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
|
||||
{#if report.maps.some(map => map.capacityObserved <= 0)}<p class="notice">{report.maps.filter(map => map.capacityObserved <= 0).length} of {report.maps.length} maps lack capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.</p>{/if}
|
||||
{#if prior.partialHistory}<p class="notice">
|
||||
Partial previous history. Comparisons use available observations without extrapolation.
|
||||
</p>{/if}
|
||||
{#if report.maps.some((map) => map.capacityObserved <= 0)}<p class="notice">
|
||||
{report.maps.filter((map) => map.capacityObserved <= 0).length} of {report.maps.length} maps lack
|
||||
capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.
|
||||
</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">
|
||||
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time
|
||||
with players.” Collection gaps are separate.
|
||||
</p>{/if}
|
||||
|
||||
<style>
|
||||
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
p { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
small, details { display: block; margin-top: 8px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
|
||||
summary { cursor: pointer; }
|
||||
.notice { margin: 0; padding-left: 12px; border-left: 2px solid var(--state-warning); }
|
||||
.quality { margin: 0; font-size: var(--text-xs); }
|
||||
@media (max-width: 760px) { .summary { grid-template-columns: 1fr; gap: 8px; } }
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
small,
|
||||
details {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--state-warning);
|
||||
}
|
||||
.quality {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import {
|
||||
metricPage,
|
||||
compareMetricValues,
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent
|
||||
} from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
@@ -10,45 +16,97 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
let {
|
||||
maps,
|
||||
servers,
|
||||
view = 'maps'
|
||||
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' },
|
||||
{ key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
|
||||
{ key: 'server', label: 'Instance' },
|
||||
{ key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' },
|
||||
{ key: 'unknown', label: 'Unknown time' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
type MapSort = (typeof mapColumns)[number]['key'];
|
||||
type ServerSort = (typeof serverColumns)[number]['key'];
|
||||
type Direction = 'asc' | 'desc';
|
||||
// Shallow navigation changes page.state, not page.url. Store the working
|
||||
// search string there so filters react immediately and survive Back/Forward.
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapSort = $derived(
|
||||
mapColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('mapSort')
|
||||
)?.key ?? 'playerHours'
|
||||
);
|
||||
const serverSort = $derived(
|
||||
serverColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('serverSort')
|
||||
)?.key ?? 'utilization'
|
||||
);
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
}));
|
||||
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
const serverDirection = $derived<Direction>(
|
||||
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
|
||||
);
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
|
||||
const mapRows = $derived(
|
||||
maps
|
||||
.filter((map) =>
|
||||
`${map.game} ${gameName(map.game)} ${map.map}`
|
||||
.toLowerCase()
|
||||
.includes(mapQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
mapSort === 'map'
|
||||
? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
mapValue(a, mapSort),
|
||||
mapValue(b, mapSort),
|
||||
mapDirection
|
||||
);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
})
|
||||
);
|
||||
const serverRows = $derived(
|
||||
servers
|
||||
.filter((server) =>
|
||||
`${server.server} ${serverTitle(server.server)}`
|
||||
.toLowerCase()
|
||||
.includes(serverQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
serverSort === 'server'
|
||||
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
|
||||
(serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
serverValue(a, serverSort),
|
||||
serverValue(b, serverSort),
|
||||
serverDirection
|
||||
);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
})
|
||||
);
|
||||
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
|
||||
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
|
||||
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
|
||||
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
|
||||
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
|
||||
function gameName(game: string) {
|
||||
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
}
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
|
||||
@@ -56,87 +114,388 @@
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
|
||||
if (key === 'utilization')
|
||||
return server.observed > 0 ? server.active / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
|
||||
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
function sortMap(key: MapSort) {
|
||||
updateContext({
|
||||
mapSort: key,
|
||||
mapDirection:
|
||||
key === mapSort
|
||||
? mapDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'map'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
function sortServer(key: ServerSort) {
|
||||
updateContext({
|
||||
serverSort: key,
|
||||
serverDirection:
|
||||
key === serverSort
|
||||
? serverDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'server'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
||||
<div class="heading">
|
||||
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
|
||||
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
<div class="search">
|
||||
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
|
||||
><Input
|
||||
type="search"
|
||||
value={view === 'maps' ? mapQuery : serverQuery}
|
||||
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
|
||||
oninput={(event) =>
|
||||
updateContext({
|
||||
[view === 'maps' ? 'mapQ' : 'serverQ']: (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value
|
||||
})}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if currentPage.total}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
|
||||
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
|
||||
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
|
||||
</th>
|
||||
{/each}</tr></thead>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
|
||||
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
|
||||
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
|
||||
</dl></details></td>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}>{#snippet actions()}<Button size="sm" onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}>Clear search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
|
||||
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={column.key === 'details'
|
||||
? undefined
|
||||
: (view === 'maps' ? mapSort : serverSort) === column.key
|
||||
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined}
|
||||
>
|
||||
{#if column.key === 'details'}Details{:else}<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
view === 'maps'
|
||||
? sortMap(column.key as MapSort)
|
||||
: sortServer(column.key as ServerSort)}
|
||||
>{column.label}<span aria-hidden="true"
|
||||
>{(view === 'maps' ? mapSort : serverSort) ===
|
||||
column.key
|
||||
? (view === 'maps'
|
||||
? mapDirection
|
||||
: serverDirection) === 'asc'
|
||||
? '↑'
|
||||
: '↓'
|
||||
: '↕'}</span
|
||||
></button
|
||||
>{/if}
|
||||
</th>
|
||||
{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row"
|
||||
>{map.map}<small
|
||||
>{gameName(map.game)}{map.observed < 1 ||
|
||||
map.capacityObserved < 1 ||
|
||||
map.unknown > map.observed / 4
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
></th
|
||||
>
|
||||
<td class="player-hours"
|
||||
>{map.observed ? metricNumber(map.playerHours) : '—'}
|
||||
<div class="comparison" aria-hidden="true">
|
||||
<i
|
||||
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
|
||||
></i>
|
||||
</div></td
|
||||
>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only">{map.map} </span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Observed instance time</dt>
|
||||
<dd>{metricDuration(map.observed)}</dd>
|
||||
<dt>Unknown status</dt>
|
||||
<dd>{metricDuration(map.unknown)}</dd>
|
||||
<dt>All lobbies full</dt>
|
||||
<dd>
|
||||
{map.capacityObserved
|
||||
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
|
||||
: 'Capacity data unavailable'}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row"
|
||||
>{#if server.currentRegistered}<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(server.server)}`,
|
||||
`/metrics${contextSearch}`
|
||||
)}
|
||||
title="Open current instance with this name"
|
||||
>{serverTitle(server.server)}</a
|
||||
>{:else}{serverTitle(server.server)}<small>Historical</small
|
||||
>{/if}</th
|
||||
>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td
|
||||
>{metricDuration(server.observed)}</td
|
||||
><td>{metricDuration(server.unknown)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only"
|
||||
>{serverTitle(server.server)}
|
||||
</span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Recorded name</dt>
|
||||
<dd><code>{server.server}</code></dd>
|
||||
<dt>Time with players</dt>
|
||||
<dd>{metricDuration(server.active)}</dd>
|
||||
<dt>Idle</dt>
|
||||
<dd>
|
||||
{metricPercent(server.idle, server.observed)} · {metricDuration(
|
||||
server.idle
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
|
||||
>{#snippet actions()}<Button
|
||||
size="sm"
|
||||
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
|
||||
>Clear search</Button
|
||||
>{/snippet}</EmptyState
|
||||
>{/if}
|
||||
<div class="footer">
|
||||
<span
|
||||
>{currentPage.start}–{currentPage.end} of {currentPage.total}
|
||||
{view === 'maps' ? 'maps' : 'instances'}</span
|
||||
>
|
||||
{#if currentPage.pages > 1}<div class="paging">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === 1}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
|
||||
>Previous</Button
|
||||
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === currentPage.pages}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
|
||||
>Next</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
|
||||
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}</p></details>
|
||||
{#if view === 'instances'}<p class="identity-note">
|
||||
Grouped by recorded name; links open the current instance, which may differ from the
|
||||
historical registration.
|
||||
</p>{/if}
|
||||
<details class="table-help">
|
||||
<summary>About these metrics</summary>
|
||||
<p>
|
||||
{view === 'maps'
|
||||
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
|
||||
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.search { flex: 0 1 240px; }
|
||||
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
|
||||
th:first-child { text-align: left; min-width: 160px; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
|
||||
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
|
||||
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.footer { padding: 16px 20px; }
|
||||
details { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; }
|
||||
dl { text-align: left; min-width: 170px; max-width: 300px; }
|
||||
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.heading {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
.search {
|
||||
flex: 0 1 240px;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
vertical-align: top;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
min-width: 160px;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-sunken);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.comparison {
|
||||
height: 3px;
|
||||
width: 90px;
|
||||
margin: 8px 0 0 auto;
|
||||
background: var(--surface-raised);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.comparison i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--chart-active);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.footer,
|
||||
.paging {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
details {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
dl {
|
||||
text-align: left;
|
||||
min-width: 170px;
|
||||
max-width: 300px;
|
||||
}
|
||||
dt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
dd {
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity-note {
|
||||
margin: 0;
|
||||
padding: 0 20px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.table-help {
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
.table-help p {
|
||||
max-width: 90ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,12 +23,7 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="badge v-{variant} s-{size}"
|
||||
class:uppercase
|
||||
class:mono
|
||||
{title}
|
||||
>
|
||||
<span class="badge v-{variant} s-{size}" class:uppercase class:mono {title}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
|
||||
@@ -54,8 +49,14 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.s-sm { padding: 3px 8px; font-size: var(--text-xs); }
|
||||
.s-md { padding: 5px 12px; font-size: var(--text-sm); }
|
||||
.s-sm {
|
||||
padding: 3px 8px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.s-md {
|
||||
padding: 5px 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.v-neutral {
|
||||
background: var(--surface-raised);
|
||||
@@ -68,12 +69,12 @@
|
||||
border-color: color-mix(in srgb, var(--state-online) 25%, transparent);
|
||||
}
|
||||
.v-warning {
|
||||
background: rgba(255, 176, 32, 0.10);
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
color: var(--state-warning);
|
||||
border-color: rgba(255, 176, 32, 0.28);
|
||||
}
|
||||
.v-danger {
|
||||
background: rgba(255, 86, 112, 0.10);
|
||||
background: rgba(255, 86, 112, 0.1);
|
||||
color: var(--state-danger);
|
||||
border-color: rgba(255, 86, 112, 0.28);
|
||||
}
|
||||
|
||||
@@ -13,19 +13,27 @@
|
||||
}
|
||||
|
||||
let { items, fallback }: Props = $props();
|
||||
const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group);
|
||||
const group = $derived(
|
||||
navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group
|
||||
);
|
||||
const trail = $derived.by(() => {
|
||||
const parent = items.findIndex(item => item.href);
|
||||
const parent = items.findIndex((item) => item.href);
|
||||
const context = $page.url.searchParams.get('returnTo');
|
||||
const result = [...items];
|
||||
if (parent >= 0) {
|
||||
const href = safeReturnHref(context, fallback ?? items[parent].href!);
|
||||
const original = items[parent];
|
||||
const label = href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
|
||||
? original.label : returnLabel(href).replace(/^Back to /, '').replace(/^./, char => char.toUpperCase());
|
||||
const label =
|
||||
href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
|
||||
? original.label
|
||||
: returnLabel(href)
|
||||
.replace(/^Back to /, '')
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
result[parent] = { label, href };
|
||||
}
|
||||
return !context && group && group.label !== result[0]?.label ? [{ label: group.label }, ...result] : result;
|
||||
return !context && group && group.label !== result[0]?.label
|
||||
? [{ label: group.label }, ...result]
|
||||
: result;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -36,7 +44,8 @@
|
||||
{#if c.href && i < trail.length - 1}
|
||||
<a href={c.href}>{c.label}</a>
|
||||
{:else}
|
||||
<span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span>
|
||||
<span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span
|
||||
>
|
||||
{/if}
|
||||
{#if i < trail.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
|
||||
</li>
|
||||
@@ -69,8 +78,18 @@
|
||||
text-decoration: none;
|
||||
transition: color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.crumbs a:hover { color: var(--text-primary); }
|
||||
.crumbs a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
.crumbs [aria-current='page'] { color: var(--text-primary); }
|
||||
.sep { color: var(--text-quaternary); }
|
||||
.crumbs a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.crumbs a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.crumbs [aria-current='page'] {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sep {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
href={disabled || loading ? undefined : href}
|
||||
tabindex={disabled || loading ? -1 : undefined}
|
||||
aria-busy={loading}
|
||||
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
|
||||
onclick={(event) => {
|
||||
if (disabled || loading) event.preventDefault();
|
||||
else onclick?.(event);
|
||||
}}
|
||||
>
|
||||
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
|
||||
<span class="label">{@render children?.()}</span>
|
||||
@@ -98,19 +101,36 @@
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
.btn:focus { outline: none; }
|
||||
.btn:focus-visible { box-shadow: var(--focus-ring); }
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
.btn:focus-visible {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
|
||||
.btn:active:not(:disabled):not([aria-disabled='true']) {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
|
||||
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
|
||||
.s-sm {
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.s-md {
|
||||
padding: 9px 16px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.s-lg {
|
||||
padding: 12px 22px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.v-primary {
|
||||
@@ -153,17 +173,27 @@
|
||||
|
||||
/* Loading */
|
||||
.is-loading .label,
|
||||
.is-loading .slot { opacity: 0; }
|
||||
.is-loading .slot {
|
||||
opacity: 0;
|
||||
}
|
||||
.spinner {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.btn { white-space: normal; line-height: 1.35; }
|
||||
.btn {
|
||||
white-space: normal;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,7 +48,16 @@
|
||||
{tabindex}
|
||||
{style}
|
||||
{onclick}
|
||||
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
|
||||
onkeydown={(event: KeyboardEvent) => {
|
||||
if (
|
||||
role === 'button' &&
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault();
|
||||
(event.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -84,14 +93,26 @@
|
||||
backends downgrade poorly, and we don't want blurred noise. */
|
||||
|
||||
/* Padding scale */
|
||||
.p-none { padding: 0; }
|
||||
.p-sm { padding: var(--space-4); }
|
||||
.p-md { padding: var(--space-5); }
|
||||
.p-lg { padding: var(--space-6); }
|
||||
.p-none {
|
||||
padding: 0;
|
||||
}
|
||||
.p-sm {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.p-md {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.p-lg {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* Elevation */
|
||||
.e-flat { box-shadow: none; }
|
||||
.e-raised { box-shadow: var(--shadow-raised); }
|
||||
.e-flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
.e-raised {
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.e-interactive {
|
||||
box-shadow: none;
|
||||
@@ -106,6 +127,9 @@
|
||||
box-shadow: var(--focus-ring), var(--shadow-raised);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.p-md, .p-lg { padding: var(--space-4); }
|
||||
.p-md,
|
||||
.p-lg {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,19 +2,39 @@
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false), title, description, busy = false, wide = false, drawer = false, children, actions }:
|
||||
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; drawer?: boolean; children?: Snippet; actions?: Snippet } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
description,
|
||||
busy = false,
|
||||
wide = false,
|
||||
drawer = false,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
open?: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
busy?: boolean;
|
||||
wide?: boolean;
|
||||
drawer?: boolean;
|
||||
children?: Snippet;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
const id = $props.id();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
returnFocus =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
function close() { if (!busy) open = false; }
|
||||
function close() {
|
||||
if (!busy) open = false;
|
||||
}
|
||||
async function closed() {
|
||||
open = false;
|
||||
await tick();
|
||||
@@ -22,22 +42,103 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class:wide class:drawer aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
|
||||
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
|
||||
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
|
||||
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class:wide
|
||||
class:drawer
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
oncancel={(event) => {
|
||||
if (busy) event.preventDefault();
|
||||
}}
|
||||
onclose={closed}
|
||||
onclick={(event) => {
|
||||
if (event.target === dialog) {
|
||||
const r = dialog!.getBoundingClientRect();
|
||||
if (
|
||||
event.clientX < r.left ||
|
||||
event.clientX > r.right ||
|
||||
event.clientY < r.top ||
|
||||
event.clientY > r.bottom
|
||||
)
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id={`${id}-title`}>{title}</h2>
|
||||
{#if description}<p id={`${id}-description`}>{description}</p>{/if}
|
||||
</div>
|
||||
<Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button>
|
||||
</header>
|
||||
<div class="body">{@render children?.()}</div>
|
||||
{#if actions}<footer>{@render actions()}</footer>{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
|
||||
dialog.wide { width: min(880px, calc(100vw - 32px)); }
|
||||
dialog.drawer { position:fixed; inset:0 0 0 auto; margin:0; width:min(560px, 100vw); height:100dvh; max-height:100dvh; max-width:100vw; border-radius:0; border:0; border-left:1px solid var(--border-subtle); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.body { padding: var(--space-6); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
|
||||
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
|
||||
dialog {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
}
|
||||
dialog.wide {
|
||||
width: min(880px, calc(100vw - 32px));
|
||||
}
|
||||
dialog.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
margin: 0;
|
||||
width: min(560px, 100vw);
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
max-width: 100vw;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
header,
|
||||
.body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
footer {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
headingLevel?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
let { title, description = null, icon, actions, spacious = false, headingLevel = 2 }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
description = null,
|
||||
icon,
|
||||
actions,
|
||||
spacious = false,
|
||||
headingLevel = 2
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty" class:spacious>
|
||||
@@ -34,7 +41,9 @@
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
.spacious { padding: var(--space-12) var(--space-6); }
|
||||
.spacious {
|
||||
padding: var(--space-12) var(--space-6);
|
||||
}
|
||||
.icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
@@ -14,15 +14,23 @@
|
||||
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
|
||||
const id = $props.id();
|
||||
setContext<FieldContext>(fieldContext, {
|
||||
get labelId() { return `${id}-label`; },
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
get labelId() {
|
||||
return `${id}-label`;
|
||||
},
|
||||
get messageId() {
|
||||
return error || hint ? `${id}-message` : undefined;
|
||||
},
|
||||
get invalid() {
|
||||
return !!error;
|
||||
}
|
||||
});
|
||||
|
||||
function connectLabel(node: HTMLDivElement) {
|
||||
const connect = () => {
|
||||
const label = node.querySelector<HTMLLabelElement>('label.label');
|
||||
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
|
||||
const control = node.querySelector<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>('input:not([type="hidden"]), select, textarea');
|
||||
if (!label || !control || forId) return;
|
||||
if (!control.id) control.id = `${id}-control`;
|
||||
label.htmlFor = control.id;
|
||||
@@ -63,11 +71,21 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.req { color: var(--state-danger); margin-left: 3px; }
|
||||
.control { display: flex; flex-direction: column; }
|
||||
.req {
|
||||
color: var(--state-danger);
|
||||
margin-left: 3px;
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.msg {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.msg-hint { color: var(--text-tertiary); }
|
||||
.msg-error { color: var(--state-danger); }
|
||||
.msg-hint {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.msg-error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,10 +74,16 @@
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.input.mono { font-family: var(--font-mono); }
|
||||
.input::placeholder { color: var(--text-quaternary); }
|
||||
.input.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.input:hover { border-color: var(--border-control-hover); }
|
||||
.input:hover {
|
||||
border-color: var(--border-control-hover);
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $props();
|
||||
let {
|
||||
total,
|
||||
limit = $bindable(25),
|
||||
step = 25,
|
||||
label = 'items',
|
||||
onchange
|
||||
}: {
|
||||
total: number;
|
||||
limit?: number;
|
||||
step?: number;
|
||||
label?: string;
|
||||
onchange?: (limit: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if total > step}
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => { limit += step; onchange?.(limit); }}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
<div class="list-footer">
|
||||
<span role="status">Showing {Math.min(limit, total)} of {total} {label}</span
|
||||
>{#if limit < total}<Button
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
limit += step;
|
||||
onchange?.(limit);
|
||||
}}>Show {Math.min(step, total - limit)} more</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||
|
||||
<style>
|
||||
.list-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,14 +10,7 @@
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle = null,
|
||||
eyebrow = null,
|
||||
size = 'md',
|
||||
crumbs,
|
||||
actions
|
||||
}: Props = $props();
|
||||
let { title, subtitle = null, eyebrow = null, size = 'md', crumbs, actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="section-header s-{size}">
|
||||
@@ -49,7 +42,12 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.titles { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
@@ -65,8 +63,12 @@
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.s-md .title { font-size: var(--text-2xl); }
|
||||
.s-lg .title { font-size: var(--text-3xl); }
|
||||
.s-md .title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.s-lg .title {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
@@ -81,9 +83,19 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.s-lg .title { font-size: var(--text-2xl); }
|
||||
.s-md .title { font-size: var(--text-xl); }
|
||||
.row { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
|
||||
.actions { width: 100%; }
|
||||
.s-lg .title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.s-md .title {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,14 +81,19 @@
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.select:hover { border-color: var(--border-control-hover); }
|
||||
.select:hover {
|
||||
border-color: var(--border-control-hover);
|
||||
}
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
|
||||
}
|
||||
.select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chev {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
width = '100%',
|
||||
height = '1em',
|
||||
radius = 'sm',
|
||||
block = false
|
||||
}: Props = $props();
|
||||
let { width = '100%', height = '1em', radius = 'sm', block = false }: Props = $props();
|
||||
|
||||
let style = $derived(`width:${width};height:${height};`);
|
||||
</script>
|
||||
@@ -21,19 +16,28 @@
|
||||
<style>
|
||||
.sk {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--surface-raised) 0%,
|
||||
var(--surface-overlay) 50%,
|
||||
var(--surface-raised) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--surface-raised) 0%,
|
||||
var(--surface-overlay) 50%,
|
||||
var(--surface-raised) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s var(--ease-linear) infinite;
|
||||
}
|
||||
.block { display: block; }
|
||||
.r-sm { border-radius: var(--radius-sm); }
|
||||
.r-md { border-radius: var(--radius-md); }
|
||||
.r-lg { border-radius: var(--radius-lg); }
|
||||
.r-pill { border-radius: var(--radius-pill); }
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.r-sm {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.r-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.r-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.r-pill {
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,16 +87,36 @@
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.trailing { display: inline-flex; }
|
||||
.trailing {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.s-sm .value { font-size: var(--text-md); }
|
||||
.s-md .value { font-size: var(--text-xl); }
|
||||
.s-lg .value { font-size: var(--text-2xl); }
|
||||
.s-xl .value { font-size: var(--text-3xl); }
|
||||
.s-sm .value {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.s-md .value {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.s-lg .value {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.s-xl .value {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.t-primary .value { color: var(--text-primary); }
|
||||
.t-accent .value { color: var(--accent-primary); }
|
||||
.t-online .value { color: var(--state-online); }
|
||||
.t-warning .value { color: var(--state-warning); }
|
||||
.t-danger .value { color: var(--state-danger); }
|
||||
.t-primary .value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.t-accent .value {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.t-online .value {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.t-warning .value {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.t-danger .value {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
|
||||
const status = $derived(statusPresentation(state));
|
||||
</script>
|
||||
|
||||
<Badge variant={status.tone} {size}><StatusPip {state} size="sm" />{status.label}</Badge>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
const shape = $derived(statusShapes[resolvedTone]);
|
||||
</script>
|
||||
|
||||
<span class="pip s-{size} t-{resolvedTone} shape-{shape}" role={label ? 'img' : undefined} aria-label={label ?? undefined} aria-hidden={label ? undefined : true}></span>
|
||||
<span
|
||||
class="pip s-{size} t-{resolvedTone} shape-{shape}"
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label ?? undefined}
|
||||
aria-hidden={label ? undefined : true}
|
||||
></span>
|
||||
|
||||
<style>
|
||||
.pip {
|
||||
@@ -24,17 +29,53 @@
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.s-sm { width: 10px; height: 10px; }
|
||||
.s-md { width: 12px; height: 12px; }
|
||||
.t-online { color: var(--state-online); }
|
||||
.t-warning { color: var(--state-warning); }
|
||||
.t-danger { color: var(--state-danger); }
|
||||
.t-info { color: var(--state-info); }
|
||||
.t-neutral { color: var(--state-neutral); }
|
||||
.shape-circle { border-radius: 50%; background: currentColor; }
|
||||
.shape-square { border-radius: 2px; background: currentColor; }
|
||||
.shape-triangle { background: currentColor; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||
.shape-hollow, .shape-ring { border: 2px solid currentColor; border-radius: 50%; }
|
||||
.shape-ring { border-right-color: transparent; animation: status-spin 1s linear infinite; }
|
||||
@keyframes status-spin { to { transform: rotate(360deg); } }
|
||||
.s-sm {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.s-md {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.t-online {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.t-warning {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.t-danger {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.t-info {
|
||||
color: var(--state-info);
|
||||
}
|
||||
.t-neutral {
|
||||
color: var(--state-neutral);
|
||||
}
|
||||
.shape-circle {
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.shape-square {
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
.shape-triangle {
|
||||
background: currentColor;
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.shape-hollow,
|
||||
.shape-ring {
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.shape-ring {
|
||||
border-right-color: transparent;
|
||||
animation: status-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,15 @@
|
||||
}
|
||||
|
||||
const uid = $props.id();
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||
let {
|
||||
tabs,
|
||||
value = $bindable(),
|
||||
onchange,
|
||||
ariaLabel,
|
||||
size = 'md',
|
||||
panelId,
|
||||
idPrefix = uid
|
||||
}: Props = $props();
|
||||
let tablist = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
|
||||
@@ -36,24 +44,33 @@
|
||||
const bounds = node.getBoundingClientRect();
|
||||
const target = tab.getBoundingClientRect();
|
||||
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
|
||||
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
|
||||
else if (target.right > bounds.right - 4)
|
||||
node.scrollLeft += target.right - bounds.right + 4;
|
||||
};
|
||||
reveal();
|
||||
observer = new ResizeObserver(reveal);
|
||||
observer.observe(node);
|
||||
observer.observe(tab);
|
||||
});
|
||||
return () => { cancelled = true; observer?.disconnect(); };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const enabled = tabs.filter(t => !t.disabled);
|
||||
const enabled = tabs.filter((t) => !t.disabled);
|
||||
if (!enabled.length) return;
|
||||
const index = enabled.findIndex(t => t.id === value);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
|
||||
const index = enabled.findIndex((t) => t.id === value);
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? enabled.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) %
|
||||
enabled.length;
|
||||
select(enabled[next].id, false);
|
||||
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
|
||||
}
|
||||
@@ -119,12 +136,26 @@
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.s-sm .tab { padding: 6px 12px; font-size: var(--text-xs); }
|
||||
.s-md .tab { padding: 8px 16px; font-size: var(--text-sm); }
|
||||
.s-sm .tab {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.s-md .tab {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled):not(.active) { color: var(--text-primary); }
|
||||
.tab:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.tab:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.tab:hover:not(:disabled):not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.tab:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--surface-raised);
|
||||
@@ -144,9 +175,11 @@
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-numeric);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums; }
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tab.active .badge {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-variant-numeric: tabular-nums; }
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,14 +66,25 @@
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
|
||||
.textarea::placeholder { color: var(--text-quaternary); }
|
||||
.textarea:hover { border-color: var(--border-control-hover); }
|
||||
.textarea.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-mono);
|
||||
}
|
||||
.textarea::placeholder {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
.textarea:hover {
|
||||
border-color: var(--border-control-hover);
|
||||
}
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
|
||||
}
|
||||
.textarea:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user