Make UI language, timestamps and live feedback consistent
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
|
||||
let query = $state('');
|
||||
let filter = $state<FleetFilter>('all');
|
||||
@@ -177,7 +178,7 @@
|
||||
{/if}
|
||||
{#if servers.length}
|
||||
<div class="overview" aria-label="Fleet summary">
|
||||
<span><strong>{healthy.length ? players : '—'}</strong> players · <strong>{liveCount}</strong> live {liveCount === 1 ? 'match' : 'matches'}</span>
|
||||
<span><strong>{healthy.length ? players : '—'}</strong> {plural(players, 'player')} · <strong>{liveCount}</strong> live {plural(liveCount, 'match', 'matches')}</span>
|
||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}>{empty} empty {empty === 1 ? 'lobby' : 'lobbies'}</button>
|
||||
{#if campaigns}<a class="metric-link" href="/campaigns">{campaigns} active {campaigns === 1 ? 'campaign' : 'campaigns'}</a>{/if}
|
||||
@@ -227,7 +228,7 @@
|
||||
<div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
|
||||
{@const count = servers.filter(server => matchesView(server, '', item.id)).length}
|
||||
<label class="filter-chip" class:selected={filter === item.id}>
|
||||
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${count} servers`} />
|
||||
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${count} ${plural(count, 'server')}`} />
|
||||
{item.label}<span> {count}</span>
|
||||
</label>
|
||||
{/each}</div>
|
||||
@@ -276,7 +277,7 @@
|
||||
</tbody>{/each}
|
||||
</table></div>
|
||||
{/if}
|
||||
<footer class="fleet-footer"><span>{filtered.length} of {servers.length} servers</span><a href="/maintenance?view=versions">Plutonium versions →</a></footer>
|
||||
<footer class="fleet-footer"><span>{filtered.length} of {servers.length} {plural(servers.length, 'server')}</span><a href="/maintenance?view=versions">Plutonium versions →</a></footer>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET() {
|
||||
const checks: SetupCheck[] = [
|
||||
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
|
||||
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
|
||||
{ id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
|
||||
{ id: 'game-files', title: 'Game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
|
||||
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
|
||||
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
|
||||
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
|
||||
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
@@ -18,7 +19,10 @@
|
||||
let editing = $state<ScalingPolicy | null>(null);
|
||||
let editorOpen = $state(false);
|
||||
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
|
||||
let updatedAt = $state(Date.now());
|
||||
let refreshing = $state(false);
|
||||
let generation = 0;
|
||||
let pendingRefreshes = 0;
|
||||
$effect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => notice = '', 6000);
|
||||
@@ -26,12 +30,14 @@
|
||||
});
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
const version = ++generation;
|
||||
pendingRefreshes++; refreshing = true;
|
||||
try {
|
||||
const response = await fetch('/api/autoscaling', { signal });
|
||||
if (!response.ok) throw new Error('Could not refresh policies. The last successful report is shown.');
|
||||
const result = await response.json();
|
||||
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; }
|
||||
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; }
|
||||
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; updatedAt = Date.now(); }
|
||||
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; return false; }
|
||||
finally { refreshing = --pendingRefreshes > 0; }
|
||||
}
|
||||
onMount(() => pollVisible(refresh, 15_000, false));
|
||||
function edit(policy: ScalingPolicy | null) {
|
||||
@@ -86,8 +92,9 @@
|
||||
<svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose.">
|
||||
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate && report.policies.length}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
|
||||
{#snippet actions()}{#if canOperate && report.policies.length}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<Freshness {updatedAt} error={refreshError ? 'Policy updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => void refresh()} retryDisabled={refreshing || busy} />
|
||||
{#if report.globalPaused}<p class="banner">Autoscaling is paused globally. Outstanding jobs are still tracked and may finish.</p>{/if}
|
||||
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
|
||||
{#if actionError}<p role="alert">{actionError}</p>{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
@@ -44,7 +45,6 @@
|
||||
const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
|
||||
let now = $state(Date.now());
|
||||
|
||||
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
|
||||
function deadlineLabel(beta: BetaListRow) {
|
||||
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
|
||||
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000);
|
||||
@@ -121,7 +121,7 @@
|
||||
<td><a class="beta-link" href={contextHref('/betas/' + beta.id, $page.url)}>{beta.notes || `Beta #${beta.id}`}</a>{#if beta.notes}<span class="beta-id">#{beta.id}</span>{/if}</td>
|
||||
<td class="files">{#if beta.scripts.length === 1}{beta.scripts[0].name} <span>{artifactVersionLabel(beta.scripts[0].version)}</span>{:else}{beta.scripts.length} files{/if}</td>
|
||||
<td><Badge variant={presentation.tone} size="md">{presentation.label}</Badge>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td>
|
||||
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><time datetime={beta.opened_at}>{age(beta.opened_at)}</time><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
|
||||
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><RelativeTime value={beta.opened_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { countLabel, plural } from '$ui/plural.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -123,7 +124,9 @@
|
||||
throw new Error(body.error ?? 'Promotion could not be started.');
|
||||
}
|
||||
reviewOpen = false;
|
||||
success = `Promotion created. ${body.result.awaitingActivation ?? 0} servers await activation${body.result.failedServers?.length ? `; ${body.result.failedServers.length} servers need attention` : ''}.`;
|
||||
const awaiting = body.result.awaitingActivation ?? 0;
|
||||
const failed = body.result.failedServers?.length ?? 0;
|
||||
success = `Promotion created. ${countLabel(awaiting, 'server')} ${plural(awaiting, 'awaits', 'await')} activation${failed ? `; ${countLabel(failed, 'server')} ${plural(failed, 'needs', 'need')} attention` : ''}.`;
|
||||
await load();
|
||||
} catch (error) { reviewError = (error as Error).message; }
|
||||
finally { acting = null; }
|
||||
@@ -192,7 +195,7 @@
|
||||
</div>
|
||||
{#if blocker}<p class:reason={presentation.key === 'attention'}>{blocker}</p>{/if}
|
||||
{#if retryBlocked && !closed && ['paused','failed'].includes(test?.outcome ?? '')}<p class="muted">Deployment was incomplete. Roll back before opening a fresh beta.</p>{/if}
|
||||
{#if promotion && (promotion.pending || promotion.failed)}<p class="muted">{#if promotion.pending}{promotion.pending} awaiting activation{/if}{#if promotion.pending && promotion.failed} · {/if}{#if promotion.failed}{promotion.failed} need attention{/if}</p>{/if}
|
||||
{#if promotion && (promotion.pending || promotion.failed)}<p class="muted">{#if promotion.pending}{promotion.pending} awaiting activation{/if}{#if promotion.pending && promotion.failed}{' · '}{/if}{#if promotion.failed}{promotion.failed} {plural(promotion.failed, 'needs', 'need')} attention{/if}</p>{/if}
|
||||
{#if deadline && !closed && test?.outcome !== 'passed'}
|
||||
<details class="deadline" class:urgent={remaining <= 120}><summary>{deadlineText}</summary><p>Deadline: <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time>. Waiting, pausing, and retrying use the same testing window. Deploying a new revision starts a fresh window.</p></details>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { countLabel } from '$ui/plural.js';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
@@ -75,7 +76,7 @@
|
||||
|
||||
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
|
||||
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
|
||||
function gameLabel(game:string) { return game==='t5'?'Black Ops I (T5)':game==='t6'?'Black Ops II (T6)':game; }
|
||||
function gameLabel(game:string) { return game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game; }
|
||||
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
|
||||
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
|
||||
function reconcileSelection() {
|
||||
@@ -229,7 +230,7 @@
|
||||
<h3>Instances that must pass</h3>
|
||||
<p>At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.</p>
|
||||
<label class="instance-count">Instances per game<input type="number" min="1" max="1000" step="1" value={instanceCount??''} oninput={event=>instanceCount=event.currentTarget.value===''?null:event.currentTarget.valueAsNumber}/><span class="muted">The same number applies to every game in this beta.</span></label>
|
||||
<p class="muted">{applicableGames.map(game=>`${gameLabel(game)}: ${candidates.filter(server=>server.game===game).length} compatible registered servers`).join(' · ')}</p>
|
||||
<p class="muted">{applicableGames.map(game=>`${gameLabel(game)}: ${countLabel(candidates.filter(server=>server.game===game).length, 'compatible registered server')}`).join(' · ')}</p>
|
||||
<p class="muted">Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies.</p>
|
||||
</div>
|
||||
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if}
|
||||
@@ -244,7 +245,7 @@
|
||||
{#if selectionMode==='manual' && candidateServers.length}
|
||||
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
|
||||
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
|
||||
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
||||
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${countLabel(server.last_players, 'player')} · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
||||
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
||||
{/if}
|
||||
</div></details>
|
||||
@@ -254,7 +255,7 @@
|
||||
<ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}{#if pick.scriptId!==null && detailById[pick.scriptId]}<br/><span class="muted">{scopeText(detailById[pick.scriptId].script)}</span>{/if}</li>{/each}</ul>
|
||||
<div class="review-heading"><h3>Test instances ({serverCount})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div>
|
||||
{#if selectionMode==='automatic'}
|
||||
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {count} instance{count===1?'':'s'} must pass</li>{/each}</ul>
|
||||
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {countLabel(count, 'server')} must pass</li>{/each}</ul>
|
||||
<p>The least-used available servers are chosen when you start the beta. Selection uses current player counts and ensures every file has a matching participant. If there are not enough available servers, the beta will not start.</p>
|
||||
{:else}<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul>{/if}
|
||||
<div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs each selected version only on matching test servers and restarts them when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { page } from '$app/stores';
|
||||
@@ -69,14 +70,6 @@
|
||||
return pollVisible(loadCampaigns, 15000);
|
||||
});
|
||||
|
||||
function ago(iso: string): string {
|
||||
const elapsed = refreshedAt - new Date(iso).getTime();
|
||||
if (elapsed < 60_000) return 'Just now';
|
||||
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} min ago`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} hr ago`;
|
||||
const days = Math.floor(elapsed / 86_400_000);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
|
||||
@@ -138,7 +131,7 @@
|
||||
<td><div class="status"><Badge variant={outcome.tone} size="md">{outcome.label}</Badge>
|
||||
{#if showProgress}<progress max={campaign.servers.length || 1} value={outcome.completed} aria-label={`${outcome.completed} of ${campaign.servers.length} servers complete`}></progress>{/if}
|
||||
</div></td>
|
||||
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(campaign.started_at)}`}><time datetime={campaign.started_at}>{ago(campaign.started_at)}</time><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(campaign.started_at)}</span></button></td>
|
||||
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(campaign.started_at)}`}><RelativeTime value={campaign.started_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(campaign.started_at)}</span></button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '$ui/plural.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
@@ -163,9 +164,6 @@
|
||||
v === 'held' ? 'warning' :
|
||||
'neutral';
|
||||
}
|
||||
function fmt(iso: string | null): string {
|
||||
return $timeFormat.dateTime(iso);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric Cherry</title></svelte:head>
|
||||
@@ -191,14 +189,14 @@
|
||||
</SectionHeader>
|
||||
{#if rollbackError}<p class="err" role="alert">{rollbackError}</p>{/if}
|
||||
<div class="metadata">
|
||||
<span>Started {fmt(detail.deployment.started_at)}</span>
|
||||
<span>Started <RelativeTime value={detail.deployment.started_at} /></span>
|
||||
{#if detail.deployment.beta_id}<span>Source: <a href={contextHref(`/betas/${detail.deployment.beta_id}`, $page.url)}>Beta #{detail.deployment.beta_id}</a></span>{/if}
|
||||
{#if detail.watchdogIncidentId}<span>Source: <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>Maintenance incident</a></span>{/if}
|
||||
<details class="campaign-details">
|
||||
<summary>Campaign details</summary>
|
||||
<div class="campaign-details-body">
|
||||
<div class="campaign-id"><span class="mono">{detail.deployment.id}</span><Button size="sm" onclick={copyId}>Copy ID</Button><span role="status">{copyStatus}</span></div>
|
||||
{#if detail.deployment.finished_at}<p>Finished {fmt(detail.deployment.finished_at)}</p>{/if}
|
||||
{#if detail.deployment.finished_at}<p>Finished <RelativeTime value={detail.deployment.finished_at} /></p>{/if}
|
||||
{#if detail.deployment.triggered_by && detail.deployment.triggered_by !== 'ui'}<p>Triggered by {detail.deployment.triggered_by}</p>{/if}
|
||||
<p>{detail.watchdogIncidentId ? 'Maintenance rebuilds instance files with the latest promoted setup after checking that the server is safe to update.' : 'Changes wait for an empty server and restart capacity. Rollback snapshots are captured before applying files, then servers restart and verify health. Tested servers retain their pre-beta snapshots.'}</p>
|
||||
</div>
|
||||
@@ -216,7 +214,7 @@
|
||||
<div class="progress-counts">
|
||||
{#if counts.processing}<span>{counts.processing} processing</span>{/if}
|
||||
{#if counts.queued}<span>{counts.queued} queued</span>{/if}
|
||||
{#if counts.attention}<span class="attention">{counts.attention} need attention</span>{/if}
|
||||
{#if counts.attention}<span class="attention">{counts.attention} {plural(counts.attention, 'needs', 'need')} attention</span>{/if}
|
||||
{#if counts.inactive}<span>{counts.inactive} inactive</span>{/if}
|
||||
{#if detail.servers.length === 0}<span>No servers recorded</span>
|
||||
{:else if counts.complete === detail.servers.length}<span>All servers activated</span>{/if}
|
||||
@@ -266,9 +264,9 @@
|
||||
<div><dt>Server ID</dt><dd class="mono">{s.server}</dd></div>
|
||||
{#if s.players !== null}<div><dt>Players at last check</dt><dd>{s.players}</dd></div>{/if}
|
||||
<div><dt>Attempts</dt><dd>{s.attempts}</dd></div>
|
||||
{#if s.checked_at}<div><dt>Last checked</dt><dd>{fmt(s.checked_at)}</dd></div>{/if}
|
||||
{#if s.restarted_at}<div><dt>Restart requested</dt><dd>{fmt(s.restarted_at)}</dd></div>{/if}
|
||||
{#if s.server_deleted_at}<div><dt>Deleted</dt><dd>{fmt(s.server_deleted_at)}</dd></div>{/if}
|
||||
{#if s.checked_at}<div><dt>Last checked</dt><dd><RelativeTime value={s.checked_at} /></dd></div>{/if}
|
||||
{#if s.restarted_at}<div><dt>Restart requested</dt><dd><RelativeTime value={s.restarted_at} /></dd></div>{/if}
|
||||
{#if s.server_deleted_at}<div><dt>Deleted</dt><dd><RelativeTime value={s.server_deleted_at} /></dd></div>{/if}
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
{#if !s.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(s.server)}?tab=logs`, $page.url)}>Open logs</a><a href={contextHref(`/servers/${encodeURIComponent(s.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a>{/if}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
|
||||
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import { plural } from '$ui/plural.js';
|
||||
|
||||
let { data }: { data: CapacityData } = $props();
|
||||
let snapshot = $state<CapacityData | null>(null);
|
||||
@@ -81,16 +82,16 @@
|
||||
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
|
||||
<div class="toolbar">
|
||||
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
|
||||
<Field label="Launch game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
|
||||
<Field label="Game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
|
||||
<div class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
|
||||
</div>
|
||||
<section class="overview" aria-labelledby="capacity-now-title">
|
||||
<div class="overview-heading"><div>
|
||||
<h2 id="capacity-now-title">{#if summary.estimatedServers === null}Capacity estimate unavailable{:else}Estimated room for <strong>{count(summary.estimatedServers)}</strong> more {summary.estimatedServers === 1 ? 'instance' : 'instances'}{/if}</h2>
|
||||
<h2 id="capacity-now-title">{#if summary.estimatedServers === null}Capacity estimate unavailable{:else}Estimated room for <strong>{count(summary.estimatedServers)}</strong> more {plural(summary.estimatedServers, 'server')}{/if}</h2>
|
||||
<p>{scopeName} · {summary.eligibleHosts} eligible {summary.eligibleHosts === 1 ? 'host' : 'hosts'}{report.query.game === 'all' ? '' : ` · ${report.query.game === 't5' ? 'Black Ops I' : 'Black Ops II'}`}</p>
|
||||
</div><div class="queue-context">
|
||||
{#if summary.queued !== null && summary.queued > 0}<a href="/provisioning"><strong>{count(summary.queued)} {summary.queued === 1 ? 'instance' : 'instances'} queued →</strong></a>
|
||||
{:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued instances'}</span>{/if}<small>Fleet-wide</small>
|
||||
{#if summary.queued !== null && summary.queued > 0}<a href="/provisioning"><strong>{count(summary.queued)} {plural(summary.queued, 'server')} queued →</strong></a>
|
||||
{:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued servers'}</span>{/if}<small>Fleet-wide</small>
|
||||
</div></div>
|
||||
<dl class="resources" aria-label="Current resource headroom">
|
||||
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div>
|
||||
@@ -106,10 +107,10 @@
|
||||
{:else}<CapacityHosts hosts={report.current.hosts} returnTo={page.url.pathname + page.url.search} />{/if}
|
||||
</section>
|
||||
<details class="methodology"><summary>Capacity details</summary>
|
||||
<p>Additional instances are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-instance allowance. The smallest resource allowance sets that host’s limit. With All games selected, hosts ready for either T5 or T6 are counted once.</p>
|
||||
<p>Additional servers are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-server allowance. The smallest resource allowance sets that host’s limit. With All games selected, hosts ready for either Black Ops I or Black Ops II are counted once.</p>
|
||||
<p>CPU and memory availability account for the operating-system reserve and pending starts. Free ports are counted on eligible hosts.</p>
|
||||
<p>This is a planning estimate. Queued launches do not reserve capacity, and starting instances may require a larger allowance. Placement also checks current host readiness, game files, runtime, and startup slots.</p>
|
||||
<p>{count(summary.activeStartups)} instances starting across the fleet. Launches wait for capacity and startup slots automatically.</p>
|
||||
<p>This is a planning estimate. Queued launches do not reserve capacity, and starting servers may require a larger allowance. Placement also checks current host readiness, game files, runtime, and concurrent start limits.</p>
|
||||
<p>{summary.activeStartups === null ? 'The number of starting servers is unavailable.' : `${count(summary.activeStartups)} ${plural(summary.activeStartups, 'server')} starting across the fleet.`} Servers wait for capacity and a concurrent start slot automatically.</p>
|
||||
<p>{report.current.updatedAt ? `Report updated ${$timeFormat.dateTime(report.current.updatedAt)}.` : 'Waiting for host reports.'} Refreshes every 30 seconds while visible.</p>
|
||||
</details>
|
||||
<CapacityChart history={report.history} scope={scopeName}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '$ui/plural.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
@@ -152,9 +153,9 @@
|
||||
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
|
||||
<a class="collection-row" href={contextHref(`/collections/launches/${collection.id}`, $page.url)}>
|
||||
<div class="row-head"><strong>{collection.name}</strong><Badge variant={launchVariant(collection)}>{launchLabel(collection)}</Badge></div>
|
||||
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}{#if collection.cancelled}<span>{collection.cancelled} cancelled</span>{/if}{#if collection.cleanupActive}<span>{collection.cleanupActive} cleaning up</span>{/if}{#if collection.cleanupFailed}<span class="error">{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1 ? ' needs' : 's need'} attention</span>{/if}</div>
|
||||
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} {plural(collection.failed, 'needs', 'need')} attention</span>{/if}{#if collection.cancelled}<span>{collection.cancelled} cancelled</span>{/if}{#if collection.cleanupActive}<span>{collection.cleanupActive} cleaning up</span>{/if}{#if collection.cleanupFailed}<span class="error">{collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention</span>{/if}</div>
|
||||
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span><span class="cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span></div>
|
||||
<div class="collection-footer"><span class="meta">Queued {$timeFormat.dateTime(collection.created_at)}</span><span class="collection-link">{inProgress(collection) ? 'Follow progress' : 'View results'} →</span></div>
|
||||
<div class="collection-footer"><span class="meta">Queued <RelativeTime value={collection.created_at} /></span><span class="collection-link">{inProgress(collection) ? 'Follow progress' : 'View results'} →</span></div>
|
||||
</a>
|
||||
{/each}</div>
|
||||
{:else if !launchError}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '$ui/plural.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
@@ -106,7 +107,7 @@
|
||||
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div>
|
||||
<span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} need attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
|
||||
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
|
||||
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
|
||||
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
|
||||
<span class="progress-cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span>
|
||||
@@ -118,7 +119,7 @@
|
||||
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
|
||||
{#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
|
||||
</dl>
|
||||
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} need attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} cleanups need attention.</p>
|
||||
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} {plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention.</p>
|
||||
{#if cancellationRequested}
|
||||
<p class="cancellation-note">Completed launches and retained servers are kept. Removals that already started or are shared with another collection may still finish. Follow each cancelled server's cleanup below until removal is confirmed.</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -37,8 +39,7 @@
|
||||
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
|
||||
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
|
||||
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
|
||||
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
|
||||
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
|
||||
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I' : 'Black Ops II';
|
||||
|
||||
async function responseBody(response: Response) {
|
||||
let body;
|
||||
@@ -102,7 +103,7 @@
|
||||
}
|
||||
if (disposed) return;
|
||||
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.');
|
||||
phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`;
|
||||
phase = 'published'; folder = null; notice = `${gameTitle(game, true)} game files are ready. Active hosts will download and verify the latest files automatically.`;
|
||||
if (folderInput) folderInput.value = '';
|
||||
await load();
|
||||
}
|
||||
@@ -209,7 +210,7 @@
|
||||
{#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
|
||||
{#if canManage && showUpload}
|
||||
<section class="card" id="game-upload">
|
||||
<div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
|
||||
<div class="heading"><h2>{phase === 'published' ? `${gameTitle(game, true)} files uploaded` : uploadId ? `Continue ${gameTitle(game, true)} upload` : `Upload ${gameTitle(game, true)} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
|
||||
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically. Running servers keep using their existing files.</p>
|
||||
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
|
||||
<form onsubmit={startUpload}>
|
||||
@@ -227,7 +228,7 @@
|
||||
</div>{/if}
|
||||
<div class="upload-actions">
|
||||
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button>
|
||||
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${game.toUpperCase()} files`}</Button>{/if}
|
||||
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${gameTitle(game, true)} files`}</Button>{/if}
|
||||
{#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
@@ -241,13 +242,13 @@
|
||||
<section class="card">
|
||||
<div class="heading">
|
||||
<div><h2>{gameName(library.game)}</h2>{#if latest}<p class="muted">{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files</p>{/if}</div>
|
||||
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
|
||||
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${gameTitle(library.game, true)} files` : `Upload ${gameTitle(library.game, true)} files`}</Button>{/if}
|
||||
</div>
|
||||
{#if latest}
|
||||
<div class="heading"><span class="muted">Latest verified upload</span><Badge variant={latest.caches.some(cache => cache.status === 'failed') ? 'danger' : latest.caches.length && latest.caches.every(cache => cache.status === 'ready') ? 'online' : 'neutral'}>{latest.caches.length ? `${latest.caches.filter(cache => cache.status === 'ready').length} / ${latest.caches.length} host copies ready` : 'No hosts'}</Badge></div>
|
||||
<GameFileHosts caches={latest.caches} {hosts} latest />
|
||||
{:else}
|
||||
<p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
|
||||
<p class="muted">Upload the complete {gameName(library.game)} game folder once. Hosts will download and verify it automatically as part of setup.</p>
|
||||
{/if}
|
||||
{#if unfinished.length}
|
||||
<div class="uploads">
|
||||
@@ -255,7 +256,7 @@
|
||||
{#each unfinished as item (item.id)}
|
||||
{@const browserUpload = !item.sourceHostId}
|
||||
<article class="upload-entry">
|
||||
<div class="heading"><p>Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
|
||||
<div class="heading"><p>Started <RelativeTime value={item.createdAt} /> · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
|
||||
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
|
||||
{#if browserUpload}
|
||||
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
|
||||
@@ -266,14 +267,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if latest || history.length}<details class="library-details"><summary>Details</summary><div class="detail-content">
|
||||
{#if latest}<p class="muted">Uploaded {uploadDate(latest)}</p>{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
|
||||
{#if latest}<p class="muted">Uploaded <RelativeTime value={latest.publishedAt ?? latest.createdAt} /></p>{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
|
||||
{#if history.length}
|
||||
<details class="history">
|
||||
<summary>Previous uploads ({history.length})</summary>
|
||||
<p class="muted">Old host copies are removed once no instances use them. Controller files are removed after all host copies are gone; upload history is kept.</p>
|
||||
{#each history as item (item.id)}
|
||||
<article class="upload-entry">
|
||||
<h3>Uploaded {uploadDate(item)}</h3>
|
||||
<h3>Uploaded <RelativeTime value={item.publishedAt ?? item.createdAt} /></h3>
|
||||
{#if item.filesRemoved}<p class="muted">Files removed from controller · upload history retained</p>{:else if item.filesRemoving}<p class="muted">Removing unused controller files · upload history retained</p>{/if}
|
||||
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
|
||||
{@render uploadDetails(item)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { hostListStatus, stableHostOrder } from '$ui/host-overview.js';
|
||||
import { countLabel, plural } from '$ui/plural.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
|
||||
@@ -52,7 +53,7 @@
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="ui-error">{error} Showing the last loaded hosts. <Button variant="ghost" size="sm" onclick={() => load()}>Retry</Button></p>{/if}
|
||||
{#if hosts.length}
|
||||
<div class="summary"><span>{hosts.length} host{hosts.length === 1 ? '' : 's'} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} need attention</button>{/if}</div>
|
||||
<div class="summary"><span>{countLabel(hosts.length, 'host')} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} {plural(attention, 'needs', 'need')} attention</button>{/if}</div>
|
||||
{#if filtersVisible}<div class="filters">
|
||||
<input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} />
|
||||
<select aria-label="Filter hosts by status" bind:value={status}><option value="">All statuses</option><option value="attention">Needs attention</option><option value="offline">Offline</option><option value="preparing">Preparing</option><option value="draining">Draining</option><option value="disabled">Disabled</option><option value="deleting">Deleting</option><option value="ready">Ready</option></select>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -8,6 +10,8 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Host, CapacityCalibration } from '$lib/hosts.js';
|
||||
import { hostCalibrationHint } from '$ui/host-overview.js';
|
||||
import { countLabel } from '$ui/plural.js';
|
||||
import { hostAgentLaneLabel, hostAgentLaneState, hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -74,8 +78,8 @@
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
|
||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
||||
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested <RelativeTime value={host.decommission.requestedAt} />. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
|
||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: <RelativeTime value={host.lastHeartbeat} missing="Not yet reported" /></span></div>
|
||||
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
|
||||
{#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/if}
|
||||
</section>
|
||||
@@ -84,7 +88,7 @@
|
||||
{#if tab === 'overview'}
|
||||
<section class="panel"><h2>Agent workers</h2>
|
||||
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)}
|
||||
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: {lane.lastPollAt ? new Date(lane.lastPollAt).toLocaleString() : 'Not yet reported'}</dd>
|
||||
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: <RelativeTime value={lane.lastPollAt} missing="Not yet reported" /></dd>
|
||||
{/each}</dl>{:else}<p>This agent has not reported worker status yet.</p>{/if}
|
||||
</section>
|
||||
<section class="panel"><h2>Inventory</h2><dl>
|
||||
@@ -93,15 +97,15 @@
|
||||
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
|
||||
<dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
|
||||
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt>
|
||||
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({allowance.samples} samples)</dd>
|
||||
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({countLabel(allowance.samples, 'sample')})</dd>
|
||||
{/each}</dl>
|
||||
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
|
||||
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if}
|
||||
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>{hostCalibrationHint(host.inventory.agentVersion)} Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running.</p>{/if}
|
||||
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
|
||||
</section>{/if}
|
||||
{:else if tab === 'servers'}
|
||||
<section class="panel" aria-labelledby="running-instances-heading">
|
||||
<h2 id="running-instances-heading">Running instances · {instances.length}</h2>
|
||||
<h2 id="running-instances-heading">Running servers · {instances.length}</h2>
|
||||
<p>Current instances on this host, including any that are starting or unavailable.</p>
|
||||
{#if !host.connected && instances.length}<p role="status">The host is offline. Instance details reflect the latest reports.</p>{/if}
|
||||
{#if instances.length}
|
||||
@@ -119,7 +123,7 @@
|
||||
{:else if tab === 'setup'}
|
||||
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
|
||||
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2>
|
||||
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl>
|
||||
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd><RelativeTime value={host.inventory.agentUpdate?.checkedAt} missing="Not reported" /></dd></dl>
|
||||
{#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if}
|
||||
{#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if}
|
||||
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
|
||||
@@ -137,7 +141,7 @@
|
||||
<section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
|
||||
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section><section class="panel" id="agent-access"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||||
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||||
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
|
||||
import type { PageData } from './$types';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import { hostOperationTitle } from '$lib/host-operation-labels.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state('');
|
||||
let updatedAt = $state(Date.now());
|
||||
let request: AbortController | null = null;
|
||||
let sequence = 0;
|
||||
const history = $derived(snapshot?.source === data ? snapshot.history : data.history);
|
||||
@@ -54,7 +59,7 @@
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error('Could not refresh operation history.');
|
||||
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; }
|
||||
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; updatedAt = Date.now(); }
|
||||
}
|
||||
catch {
|
||||
if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) {
|
||||
@@ -64,7 +69,7 @@
|
||||
} finally { if (current === sequence) { refreshing = false; request = null; } }
|
||||
}
|
||||
beforeNavigate(cancel);
|
||||
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); });
|
||||
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; updatedAt = Date.now(); }); });
|
||||
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
|
||||
</script>
|
||||
|
||||
@@ -72,11 +77,11 @@
|
||||
|
||||
<section class="page" aria-busy={changingPage}>
|
||||
<SectionHeader title="Host operation history" eyebrow="Operations" subtitle="Remote operations, newest first. Filtered views retain their host and server context.">
|
||||
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
|
||||
{#snippet actions()}<Freshness {updatedAt} error={refreshError} intervalLabel="Updates every 15 seconds" onRetry={() => void refresh()} />{/snippet}
|
||||
</SectionHeader>
|
||||
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink fallback={data.scope.operation ? '/activity' : '/hosts'} />{/if}
|
||||
{#if data.scope.operation}<div class="selected-operation"><div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div><a href={allOperationsHref}>View all operations</a></div>{/if}
|
||||
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${data.scope.server}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
|
||||
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${serverTitle(data.scope.server)}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
|
||||
<div class="history-filters">
|
||||
<nav class="filter-links" aria-label="Operation history filters">
|
||||
{#each filters as filter}
|
||||
@@ -86,7 +91,6 @@
|
||||
</nav>
|
||||
<p>{selectedFilter.description}</p>
|
||||
</div>
|
||||
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
|
||||
<section class="panel" aria-label="Host operation history">
|
||||
{#if !history.operations.length}<p class="empty" role="status">{data.scope.operation ? 'This operation is unavailable in the selected history. It may have expired or be excluded by the current filters.' : selectedFilter.empty}</p>
|
||||
{:else}
|
||||
@@ -96,15 +100,16 @@
|
||||
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">Target</th><th scope="col">Source</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead>
|
||||
<tbody>{#each history.operations as operation (operation.id)}
|
||||
<tr>
|
||||
<td class="operation">{operation.kind === 'plutonium.prepare' ? 'Prepare Plutonium cache' : operation.kind}<small>{operation.id}</small></td>
|
||||
<td class="operation">{hostOperationTitle(operation.kind)}<details><summary>Technical details</summary><small>{operation.kind}</small><small>{operation.id}</small></details></td>
|
||||
<td>{#if operation.hostName !== null}<a href={contextHref(`/hosts/${operation.hostId}`, $page.url)}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
|
||||
<td class="target">
|
||||
<span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span>
|
||||
<span>{operation.serverName ? serverTitle(operation.serverName) : operation.instanceId ? 'Server container' : 'Host-level'}</span>
|
||||
{#if operation.instanceId && !operation.serverName}<small>{operation.instanceId}</small>{/if}
|
||||
{#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
|
||||
</td>
|
||||
<td class="source">{operation.source ?? 'Not recorded'}</td>
|
||||
<td class="state" class:failed={operation.state === 'failed'}>{operation.state}</td>
|
||||
<td class="updated">{$timeFormat.dateTime(operation.updatedAt)}</td>
|
||||
<td><StatusBadge state={operation.state === 'completed' ? 'done' : operation.state === 'pending' ? 'queued' : operation.state === 'accepted' ? 'running' : operation.state} /></td>
|
||||
<td class="updated"><RelativeTime value={operation.updatedAt} /></td>
|
||||
<td class="result">{operation.error ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
@@ -137,7 +142,7 @@
|
||||
.filter-links a.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.filter-links a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||
.panel { min-width: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.table-wrap { overflow: auto; }
|
||||
.table-wrap { position: relative; overflow: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||
table { width: 100%; min-width: 1050px; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||
td, th { padding: 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
|
||||
@@ -148,8 +153,7 @@
|
||||
.target { min-width: 180px; max-width: 320px; }
|
||||
.target-path { font-family: var(--font-mono); }
|
||||
.source { min-width: 120px; max-width: 220px; }
|
||||
.state { text-transform: capitalize; }
|
||||
.failed { color: var(--state-danger); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.updated { white-space: nowrap; }
|
||||
.result { min-width: 160px; max-width: 420px; }
|
||||
.pagination { padding: 16px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
let { data: initial }: { data: import('./$types').PageData } = $props();
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page, navigating } from '$app/stores';
|
||||
@@ -298,7 +298,7 @@
|
||||
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
|
||||
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
|
||||
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <RelativeTime value={data.summary.firstTrackedAt} /> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <RelativeTime value={data.summary.updatedAt} />.{/if}</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { playerCompletionLabel } from '$ui/player-match.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
@@ -39,7 +41,6 @@
|
||||
if (scope.players) params.set('players', scope.players);
|
||||
return `/leaderboard?${params}`;
|
||||
});
|
||||
const date = (value: string) => $timeFormat.dateTime(value);
|
||||
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
|
||||
const overviewMetrics = playerStatMetrics.filter(metric => ['kills', 'roundsSurvived', 'revives', 'gamesCompleted'].includes(metric.key));
|
||||
const otherMetrics = playerStatMetrics.filter(metric => ['headshots', 'downs', 'bestKills', 'bestScore'].includes(metric.key));
|
||||
@@ -90,7 +91,7 @@
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<table><thead><tr><th scope="col">Map</th>{#each records as record}<th scope="col">{record.label}</th>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th scope="col">Total rounds</th></tr></thead>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? date(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} 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></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -100,13 +101,13 @@
|
||||
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable match history for keyboard users.) -->
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
|
||||
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Completion</th></tr></thead><tbody>{#each profile.matches as match (match.id)}<tr><th scope="row"><a href={contextHref(`/matches/${match.id}`, $page.url)}>{match.mapName} · Match #{match.id}</a><small>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</small></th><td>{date(match.startedAt)}</td><td>{number.format(match.highestRound)}</td><td>{number.format(match.kills)}</td><td>{number.format(match.revives)}</td><td>{number.format(match.downs)}</td><td>{match.completed ? 'Credited' : 'Not credited'}</td></tr>{/each}</tbody></table>
|
||||
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Final report</th></tr></thead><tbody>{#each profile.matches as match (match.id)}<tr><th scope="row"><a href={contextHref(`/matches/${match.id}`, $page.url)}>{match.mapName} · Match #{match.id}</a><small>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</small></th><td><RelativeTime value={match.startedAt} /></td><td>{number.format(match.highestRound)}</td><td>{number.format(match.kills)}</td><td>{number.format(match.revives)}</td><td>{number.format(match.downs)}</td><td>{playerCompletionLabel(match)}</td></tr>{/each}</tbody></table>
|
||||
</div>
|
||||
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} {profile.matchesTotal === 1 ? 'game' : 'games'}</span><div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div></div>
|
||||
{:else}<EmptyState headingLevel={3} title="No completed matches in this selection" description="Choose another game, map, or squad size to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '', players: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="metrics-title"><div class="panel-heading"><h2 id="metrics-title">Lifetime statistics</h2><p>All games, maps and squad sizes. Only completed matches contribute.</p></div><dl class="metric-list">{#each otherMetrics as metric}<div><dt>{metric.label}</dt><dd>{number.format(stats[metric.key])}</dd></div>{/each}{#each efficiencyMetrics as metric}<div><dt>{metric.label}</dt><dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualified for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>{/each}</dl></section>
|
||||
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div><p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds counts observed progression. Kills, headshots, revives and downs use each match's highest reported counters.</p><p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</p></details>
|
||||
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen <RelativeTime value={profile.firstSeen} /></span><span>Last seen <RelativeTime value={profile.lastSeen} /></span></div><p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds counts observed progression. Kills, headshots, revives and downs use each match's highest reported counters.</p><p>Highest reported score is the largest observed points balance, not total points earned. Games completed counts matches where the player appears in the final report; it does not indicate a win or uninterrupted survival. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</p></details>
|
||||
</section>
|
||||
<Dialog bind:open={filterOpen} title="Player filters" description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged.">
|
||||
<div class="scope-controls" aria-busy={filtering}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -23,6 +24,7 @@
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
|
||||
import { countLabel, plural } from '$ui/plural.js';
|
||||
|
||||
let data = $state<MaintenanceOverview | null>(null);
|
||||
let error = $state('');
|
||||
@@ -182,14 +184,14 @@
|
||||
<p class="muted">Use <a href="/maintenance?view=overview#activity">active work and attention</a> to cancel waiting maintenance or retry a failed verification after inspection.</p>
|
||||
{:else}
|
||||
{#if view === 'overview'}
|
||||
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
|
||||
<div class="summary"><span><strong>{attention}</strong> {plural(attention, 'needs', 'need')} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {plural(unknown.length, 'version')} unverified</span></div>
|
||||
{#if data.runtime.schedule.nextAt}<p class="muted">Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · <a href="/maintenance?view=policies#schedule">Manage schedule</a></p>{/if}
|
||||
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}</p>{/if}
|
||||
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked <RelativeTime value={data.recovery.lastScanAt} /></p>{/if}
|
||||
{#if scanErrors.length}
|
||||
<section class="scan-errors" aria-labelledby="scan-errors-heading">
|
||||
<div class="row-heading"><h2 id="scan-errors-heading">Checks need attention</h2><Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry checks</Button></div>
|
||||
<ul class="error-groups">{#each scanErrors as group (group.key)}
|
||||
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}</h3>
|
||||
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${countLabel(group.servers.length, 'server')}` : ''}</h3>
|
||||
<p>{group.description}</p>
|
||||
{#if group.servers.length}<ul class="affected-servers">{#each group.servers as name}<li><a href={contextHref(`/servers/${encodeURIComponent(name)}?tab=diagnostics`, $page.url)}>{serverTitle(name)}</a></li>{/each}</ul>{/if}
|
||||
<details><summary>Technical details</summary><ul class="technical-details">{#each group.details as detail}<li>{#if detail.server}<strong>{serverTitle(detail.server)}</strong>{/if}<pre>{detail.message}</pre></li>{/each}</ul></details>
|
||||
@@ -207,15 +209,15 @@
|
||||
<section class="card" id="updates">
|
||||
<div class="row-heading"><h2>Plutonium updates</h2>{#if updatableServers.length}<Button disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: updatableServers })}>Update outdated servers on restart</Button>{/if}</div>
|
||||
<p>Published revision: <strong>{data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'}</strong> · <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a></p>
|
||||
{#if data.runtime.release.checkedAt}<p class="muted">Checked {$timeFormat.dateTime(data.runtime.release.checkedAt)}</p>{/if}
|
||||
{#if data.runtime.release.checkedAt}<p class="muted">Checked <RelativeTime value={data.runtime.release.checkedAt} /></p>{/if}
|
||||
{#if data.runtime.release.error}<p class="ui-error" role="alert">{data.runtime.release.error} Displayed release information is cached.</p>{/if}
|
||||
<p class="muted">Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. Unknown versions require a successful check before automatic updates.</p>
|
||||
<div class="version-groups">{#each versionGroups as group (group.key)}
|
||||
<article class="version-group">
|
||||
<div class="row-heading">
|
||||
<div class="version-summary"><strong>{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
|
||||
<div class="version-summary"><strong>{countLabel(group.versions.length, 'server')} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
|
||||
<div class="version-actions">
|
||||
{#if group.pending.length}<span>{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending</span>{/if}
|
||||
{#if group.pending.length}<span>{countLabel(group.pending.length, 'restart')} pending</span>{/if}
|
||||
{#if group.status === 'unknown'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry version check</Button>
|
||||
{:else if group.updatable.length}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: group.updatable })}>Update {group.updatable.length === 1 ? 'server' : `${group.updatable.length} servers`} on restart</Button>{/if}
|
||||
</div>
|
||||
@@ -226,7 +228,7 @@
|
||||
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a>
|
||||
{#if pending}<span class="pending-job"><StatusBadge state={pending.state} />{#if pending.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(pending.provisioning_id)}`, $page.url)}>View restart</a>{:else}<a href="#activity">View pending restart</a>{/if}</span>{/if}
|
||||
</div>
|
||||
{#if version.checked_at}<small>Last checked <time datetime={version.checked_at}>{$timeFormat.dateTime(version.checked_at)}</time></small>{/if}
|
||||
{#if version.checked_at}<small>Last checked <RelativeTime value={version.checked_at} /></small>{/if}
|
||||
{#if version.error}<pre class="version-error">{version.error}</pre>{/if}
|
||||
</li>
|
||||
{/each}</ul>
|
||||
@@ -242,7 +244,7 @@
|
||||
<article class="card" id={item.key}>
|
||||
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
|
||||
{#if item.detail}<p>{item.detail}</p>{/if}
|
||||
<p class="muted">{$timeFormat.dateTime(item.at)}{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
|
||||
<p class="muted"><RelativeTime value={item.at} />{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
|
||||
<div class="links">{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.job ? 'View restart operation' : 'View evidence and recovery'}</a>{/if}
|
||||
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if}
|
||||
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -133,7 +134,7 @@
|
||||
<div class="row-heading"><h3>{action.action === 'restart' ? 'Restart server' : 'Rebuild server'}</h3>{#if action.operation_state}<StatusBadge state={action.operation_state} />{/if}</div>
|
||||
{#if action.operation_stage}<p>{action.operation_stage}</p>{/if}
|
||||
{#if action.operation_error}<p class="error">{action.operation_error}</p>{/if}
|
||||
<time datetime={action.created_at}>{$timeFormat.dateTime(action.created_at)}</time>
|
||||
<span class="time"><RelativeTime value={action.created_at} /></span>
|
||||
<div class="links"><a href={contextHref(`/provisioning/${encodeURIComponent(action.operation_id)}`, $page.url)}>View provisioning</a>{#if action.campaign_id}<a href={contextHref(`/campaigns/${encodeURIComponent(action.campaign_id)}`, $page.url)}>View repair campaign</a>{/if}</div>
|
||||
</article>
|
||||
{/each}
|
||||
@@ -165,7 +166,7 @@
|
||||
{#if !events.length}<EmptyState title="No activity recorded yet" />
|
||||
{:else}<ol class="timeline">
|
||||
{#each events.slice(0, eventLimit) as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>
|
||||
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol><ListFooter total={events.length} bind:limit={eventLimit} label="events" />{/if}
|
||||
</section>
|
||||
@@ -190,7 +191,7 @@
|
||||
.links, .row-heading a { font-size: var(--text-sm); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
dl { display: flex; gap: 16px 32px; flex-wrap: wrap; margin: 0; }
|
||||
dt, .muted, time, summary, .notice { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dt, .muted, .time, summary, .notice { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 6px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||
dd code { display: block; margin-top: 8px; color: var(--text-secondary); }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import type { MatchDetail } from '$lib/match-history.js';
|
||||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -48,8 +48,8 @@
|
||||
<h2>Match details</h2>
|
||||
<dl class="details">
|
||||
<div class="server"><dt>Server</dt><dd><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></dd></div>
|
||||
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
|
||||
<div><dt>Ended</dt><dd>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</dd></div>
|
||||
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><RelativeTime value={match.startedAt} /></dd></div>
|
||||
<div><dt>Ended</dt><dd>{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if}</dd></div>
|
||||
<div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
|
||||
</dl>
|
||||
{#if !match.startObserved}<p class="note">The match start was not recorded. The first observation is shown above; the full game time is unknown.</p>{/if}
|
||||
@@ -76,7 +76,7 @@
|
||||
</th>
|
||||
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
|
||||
{#each stats as stat}<td class:round={stat.key === 'highestRound'}>{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}</td>{/each}
|
||||
<td><time datetime={player.lastSeenAt}>{$timeFormat.dateTime(player.lastSeenAt)}</time></td>
|
||||
<td><RelativeTime value={player.lastSeenAt} /></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
@@ -13,6 +13,8 @@
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
|
||||
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
||||
import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
|
||||
@@ -45,7 +47,10 @@
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
let fleetActionsOpen = $state(false);
|
||||
let now = $state(Date.now());
|
||||
let historyUpdatedAt = $state<string | null>(null);
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let historyUpdatedAt = $state<number | null>(null);
|
||||
let previousHistoryQuery = '';
|
||||
let historyResults = $state<HTMLDivElement | null>(null);
|
||||
let cancelling = $state(false);
|
||||
let cancellationNotice = $state('');
|
||||
$effect(() => {
|
||||
@@ -84,7 +89,7 @@
|
||||
const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}];
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
let refreshing = $state(false);
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
@@ -92,40 +97,49 @@
|
||||
const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('Could not load provisioning activity.');
|
||||
const result: ProvisioningListPage = await res.json();
|
||||
active = result.deployments; now = Date.now();
|
||||
if (signal?.aborted) return;
|
||||
active = result.deployments; now = Date.now(); updatedAt = now;
|
||||
needsReview = result.needsReview;
|
||||
error = '';
|
||||
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; }
|
||||
finally { loading = false; refreshing = false; }
|
||||
}
|
||||
// History is fetched on navigation/filter changes or explicit refresh, never by the active poll.
|
||||
// History has its own visible-page poll; filter changes discard incompatible
|
||||
// rows, while refreshes keep the current page available until replacement.
|
||||
$effect(() => {
|
||||
if (tab !== 'history') return;
|
||||
const params = new URLSearchParams({ view: 'history', action, status: historyState, source,
|
||||
server: search, limit: String(limit), offset: String(offset) });
|
||||
void historyRefresh;
|
||||
const controller = new AbortController();
|
||||
historyLoading = true;
|
||||
historyError = '';
|
||||
history = [];
|
||||
historyTotal = 0;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load provisioning history.');
|
||||
const result: ProvisioningListPage = await response.json();
|
||||
if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; historyUpdatedAt = new Date().toISOString(); now = Date.now(); }
|
||||
} catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) historyLoading = false; }
|
||||
})();
|
||||
return () => controller.abort();
|
||||
return untrack(() => {
|
||||
if (previousHistoryQuery !== params.toString()) {
|
||||
history = []; historyTotal = 0; historyUpdatedAt = null; historyError = '';
|
||||
previousHistoryQuery = params.toString();
|
||||
}
|
||||
let disposed = false;
|
||||
let first = true;
|
||||
const stop = pollVisible(async signal => {
|
||||
// A new record can shift this page. Keep keyboard focus stable
|
||||
// until it leaves the results; an explicit Retry still runs.
|
||||
if (!first && historyResults?.contains(document.activeElement)) return;
|
||||
first = false;
|
||||
historyLoading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/provisioning?${params}`, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load provisioning history.');
|
||||
const result: ProvisioningListPage = await response.json();
|
||||
if (!signal.aborted) {
|
||||
history = result.deployments; historyTotal = result.total;
|
||||
historyUpdatedAt = Date.now(); now = Date.now(); historyError = '';
|
||||
}
|
||||
} catch (err) { if (!signal.aborted) historyError = (err as Error).message; return false; }
|
||||
finally { if (!disposed) historyLoading = false; }
|
||||
}, 15000);
|
||||
return () => { disposed = true; stop(); };
|
||||
});
|
||||
});
|
||||
onMount(() => { const stop = pollVisible(load, 5000); const clock = setInterval(() => now = Date.now(), 30000); return () => { stop(); clearInterval(clock); }; });
|
||||
function fmt(value: string) { return $timeFormat.dateTime(value); }
|
||||
function age(value: string) {
|
||||
const minutes = Math.max(0, Math.floor((now - Date.parse(value)) / 60000));
|
||||
return minutes < 1 ? 'Just now' : minutes < 60 ? `${minutes} min ago` : minutes < 1440 ? `${Math.floor(minutes / 60)} hr ago` : `${Math.floor(minutes / 1440)}d ago`;
|
||||
}
|
||||
const filtered = $derived(action !== 'all' || source !== 'all' || historyState !== 'all' || Boolean(search));
|
||||
const visibleActive = $derived(active.filter(row => {
|
||||
if (action !== 'all' && row.action !== action) return false;
|
||||
@@ -173,18 +187,18 @@
|
||||
</div>
|
||||
<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
<DeployServerDialog bind:open />
|
||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
|
||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Removal queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
|
||||
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
|
||||
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
|
||||
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={() => void load()}>Retry</button></p>{/if}
|
||||
{#if error && tab === 'active'}<p role="alert" class="error">{error} {#if updatedAt}Showing the last loaded operations.{/if}</p>{/if}
|
||||
<div class="queue-heading">
|
||||
{#if tab === 'active'}<p class="queue-summary"><strong>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p>
|
||||
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
|
||||
<div class="queue-tools">
|
||||
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
|
||||
{#if tab === 'active'}<span class="muted">{error ? 'Updates interrupted' : 'Updates every 5s'}</span>
|
||||
{:else}<span class="muted">{historyUpdatedAt ? `Updated ${age(historyUpdatedAt).toLowerCase()}` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if}
|
||||
{#if tab === 'active'}<Freshness {updatedAt} error={error ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void load()} retryDisabled={refreshing} />
|
||||
{:else}<Freshness updatedAt={historyUpdatedAt} error={historyError ? 'History updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => historyRefresh++} retryDisabled={historyLoading} />{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if tab === 'active'}
|
||||
@@ -192,14 +206,16 @@
|
||||
{:else if !visibleActive.length}<EmptyState title={filtered ? 'No matching active operations' : 'No active operations'} description={filtered ? 'Try another search or clear the filters.' : undefined} />
|
||||
{:else}{@render operationTable(visibleActive)}{/if}
|
||||
{:else}
|
||||
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
|
||||
{#if historyLoading}<p class="muted">Loading provisioning history…</p>
|
||||
<div class="history-results" bind:this={historyResults}>
|
||||
{#if historyError}<p role="alert" class="error">{historyError} {#if historyUpdatedAt}Showing the last loaded history.{/if}</p>{/if}
|
||||
{#if historyLoading && !historyUpdatedAt}<p class="muted">Loading provisioning history…</p>
|
||||
{:else if !historyError && !history.length}<EmptyState title="No matching operations" description="Try another search or clear the filters." />
|
||||
{:else}{@render operationTable(history)}{/if}
|
||||
<nav class="pagination" aria-label="Provisioning history pages">
|
||||
<span role="status">{history.length ? `${offset + 1}–${offset + history.length}` : '0'} of {historyTotal} operations</span>
|
||||
{#if historyTotal > limit || offset > 0}<div class="queue-tools"><Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button><Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button></div>{/if}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +233,7 @@
|
||||
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
|
||||
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
|
||||
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
|
||||
<td><button type="button" class="timestamp" aria-label={`Started ${fmt(row.started_at)}`}><time datetime={row.started_at}>{age(row.started_at)}</time><span class="exact-time" aria-hidden="true">{fmt(row.started_at)}</span></button></td>
|
||||
<td><RelativeTime value={row.started_at} /></td>
|
||||
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
|
||||
</tr>
|
||||
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
|
||||
@@ -234,7 +250,7 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.page,.panels { display:grid; gap:24px; min-width:0; }
|
||||
.page,.panels,.history-results { display:grid; gap:24px; min-width:0; }
|
||||
.toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; }.view-tabs { max-width:100%; }
|
||||
label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }.search { flex:1; min-width:220px; }
|
||||
input,select { padding:10px 12px; min-height:40px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); }
|
||||
@@ -246,8 +262,7 @@
|
||||
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
|
||||
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
|
||||
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
|
||||
.text-button,.timestamp { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
|
||||
.timestamp { position:relative; cursor:help; }.exact-time { display:none; position:absolute; right:0; bottom:100%; padding:6px 10px; z-index:1; background:var(--surface-overlay); border:1px solid var(--border-strong); border-radius:var(--radius-sm); color:var(--text-primary); font-size:var(--text-xs); }.timestamp:hover .exact-time,.timestamp:focus .exact-time { display:block; }
|
||||
.text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
|
||||
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
|
||||
dl { display:flex; flex-wrap:wrap; gap:12px 32px; margin:0; }dt { color:var(--text-tertiary); font-size:var(--text-xs); }dd { margin:0; }.mono { font-family:var(--font-mono); }
|
||||
.detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); }
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import type { PageData } from './$types';
|
||||
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
|
||||
import { actionLabels } from '$lib/provisioning.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
|
||||
@@ -22,6 +21,8 @@
|
||||
import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte';
|
||||
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const initial = untrack(() => data.detail);
|
||||
@@ -58,7 +59,6 @@
|
||||
});
|
||||
onMount(() => pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false));
|
||||
onDestroy(() => controller.dispose());
|
||||
function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; }
|
||||
async function overrideSafety() {
|
||||
if (!deployment || !canOverride || view.busy || detail?.safetyOverrideUnavailableReason) return;
|
||||
const id = deployment.id;
|
||||
@@ -95,17 +95,18 @@
|
||||
<div class="identity-links"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
|
||||
</div>
|
||||
|
||||
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Refresh unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div><Button size="sm" disabled={view.refreshing || !!view.busy} onclick={() => controller.forceRefresh()}>Retry refresh</Button></div>{/if}
|
||||
<Freshness updatedAt={view.refreshedAt ? Date.parse(view.refreshedAt) : null} error={view.loadError ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void controller.forceRefresh()} retryDisabled={view.refreshing || !!view.busy} />
|
||||
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Updates unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div></div>{/if}
|
||||
|
||||
{#if deployment && detail && outcome}
|
||||
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title">
|
||||
<div class="outcome-top"><div class="badges"><StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning">Recovery capacity held</Badge>{/if}</div><span class="record-label">Operation outcome</span></div>
|
||||
<div class="outcome-message" aria-live="polite" aria-atomic="true"><h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p></div>
|
||||
{#if deployment.error}<div class="failure" role="alert"><strong>Recorded error</strong><p>{deployment.error}</p></div>{/if}
|
||||
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · {fmt(detail.activity.failureExcerpt.created_at)}</span>{detail.activity.failureExcerpt.message}</p>{/if}
|
||||
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · <RelativeTime value={detail.activity.failureExcerpt.created_at} /></span>{detail.activity.failureExcerpt.message}</p>{/if}
|
||||
{#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
|
||||
|
||||
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · {fmt(detail.safetyOverride.confirmedAt)}.</p></div>{/if}
|
||||
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · <RelativeTime value={detail.safetyOverride.confirmedAt} />.</p></div>{/if}
|
||||
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
|
||||
<div class="recovery-actions">
|
||||
{#if canOverride}<div><Button variant="danger" disabled={!!view.busy || !!detail.safetyOverrideUnavailableReason} loading={view.busy === 'override'} onclick={overrideSafety}>Admin override…</Button><p class="action-help">{detail.safetyOverrideUnavailableReason ?? 'Confirm to bypass player safety checks for this operation.'}</p></div>{/if}
|
||||
@@ -120,14 +121,13 @@
|
||||
{#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if}
|
||||
{#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if}
|
||||
{#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/if}
|
||||
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded {fmt(detail.progress.recovery.at)}</span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if}
|
||||
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded <RelativeTime value={detail.progress.recovery.at} missing="Not recorded" /></span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if}
|
||||
{#if serverHref && !['done', 'cancelled'].includes(deployment.state)}<div class="diagnostics"><a href={`${serverHref}?tab=logs`}>Open server logs</a><a href={`${serverHref}?tab=diagnostics`}>Diagnose connection</a>{#if detail.progress?.phase === 'verify-map' || detail.progress?.checks.some(check => check.key === 'map' && check.result !== 'pass')}<a href={`${serverHref}?tab=config`}>Review map configuration</a>{/if}</div>{/if}
|
||||
<div class="timing">
|
||||
<div><span>{outcome.active ? 'Total elapsed' : 'Recorded duration'}</span><strong>{elapsedTime(deployment.started_at, deployment.finished_at ? Date.parse(deployment.finished_at) : now)}</strong></div>
|
||||
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(detail.progress?.phaseStartedAt, now)}</strong></div>{/if}
|
||||
<div><span>Last evaluated</span><time datetime={detail.progress?.observedAt ?? undefined}>{fmt(detail.progress?.observedAt)}</time></div>
|
||||
<div><span>Last evaluated</span><RelativeTime value={detail.progress?.observedAt} missing="Not recorded" /></div>
|
||||
</div>
|
||||
<div class="freshness"><span>Page updated <time datetime={view.refreshedAt ?? undefined}>{fmt(view.refreshedAt)}</time> · updates automatically</span><Button size="sm" variant="ghost" disabled={view.refreshing || !!view.busy} loading={view.refreshing} onclick={() => controller.forceRefresh()}>Refresh</Button></div>
|
||||
</section>
|
||||
|
||||
{#key deployment.id}
|
||||
@@ -139,8 +139,8 @@
|
||||
<h2>Host agent execution</h2>
|
||||
<dl class="metadata">
|
||||
<div><dt>Agent state</dt><dd>{detail.agentOperation.state}</dd></div>
|
||||
<div><dt>Command queued</dt><dd>{fmt(detail.agentOperation.createdAt)}</dd></div>
|
||||
<div><dt>Latest agent report</dt><dd>{fmt(detail.agentOperation.updatedAt)}</dd></div>
|
||||
<div><dt>Command queued</dt><dd><RelativeTime value={detail.agentOperation.createdAt} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Latest agent report</dt><dd><RelativeTime value={detail.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Command ID</dt><dd><code>{detail.agentOperation.id}</code></dd></div>
|
||||
</dl>
|
||||
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
|
||||
@@ -160,10 +160,10 @@
|
||||
<Card as="section" padding="md" ariaLabel="Operation details">
|
||||
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
|
||||
<div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div>
|
||||
<div><dt>Requested at</dt><dd><time datetime={deployment.started_at}>{fmt(deployment.started_at)}</time></dd></div>
|
||||
<div><dt>Submission attempted</dt><dd>{fmt(deployment.submitted_at)}</dd></div>
|
||||
<div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : outcome.active ? 'In progress' : 'Not recorded'}</dd></div>
|
||||
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd>{fmt(deployment.server_deleted_at)}</dd></div>{/if}
|
||||
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
|
||||
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
|
||||
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><RelativeTime value={deployment.server_deleted_at} /></dd></div>{/if}
|
||||
</dl></div>
|
||||
</Card>
|
||||
{#if detail.sources.length || detail.collection}<Card as="section" padding="md" ariaLabel="Related work"><div class="context-section"><h2>Related work</h2><p>The requesting work may have further checks after this operation finishes.</p><ul class="related">
|
||||
@@ -175,13 +175,13 @@
|
||||
{#if pool.ownsSlot}<p>This operation holds a slot.</p>{/if}
|
||||
{#if pool.requestOrder !== null}<p>Request order: {pool.requestOrder}. Eligible requests may pass blocked work; this is not a guaranteed start order.</p>{/if}
|
||||
{#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={`/provisioning/${encodeURIComponent(holder.id)}`}>{serverTitle(holder.server)}</a>{:else}<span>{serverTitle(holder.server)} · operation not recorded</span>{/if}</li>{/each}</ul></details>{/if}
|
||||
</div>{/each}<p class="small">Capacity snapshot {fmt(detail.capacity.evaluatedAt)}</p>
|
||||
</div>{/each}<p class="small">Capacity snapshot <RelativeTime value={detail.capacity.evaluatedAt} missing="Not recorded" /></p>
|
||||
</div></Card>{/if}
|
||||
<Card as="section" padding="md" ariaLabel="Recorded launch inputs"><div class="context-section recipe"><h2>{recipeHeading}</h2>
|
||||
{#if detail.recipe}
|
||||
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if}
|
||||
<p>{recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.'}</p>
|
||||
<p class="small">Captured <time datetime={detail.recipe.capturedAt}>{fmt(detail.recipe.capturedAt)}</time></p>
|
||||
<p class="small">Captured <RelativeTime value={detail.recipe.capturedAt} /></p>
|
||||
<LaunchRecipe recipe={detail.recipe} historical selectionStatus={recipeStatus} />
|
||||
<div class="launch-another"><Button variant="ghost" onclick={() => launchOpen = true}>Launch another server →</Button><p>Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</p></div>
|
||||
{:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if}
|
||||
@@ -200,7 +200,7 @@
|
||||
h2, h3, p { margin: 0; }a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
|
||||
p { line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .freshness, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.identity-row { justify-content: space-between; margin-top: calc(-1 * var(--space-3)); gap: var(--space-2) var(--space-5); }.operation-id { gap: var(--space-2); min-width: 0; }.operation-id > span, .identity-links { font-size: var(--text-xs); color: var(--text-tertiary); }.identity-links { gap: var(--space-4); }
|
||||
.outcome { display: grid; gap: var(--space-5); padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.outcome.attention { border-color: color-mix(in srgb, var(--state-warning) 35%, var(--border-strong)); }.outcome-top { justify-content: space-between; }.record-label { color: var(--text-tertiary); font-size: var(--text-xs); }.outcome h2 { font-size: var(--text-xl); line-height: 1.35; letter-spacing: var(--tracking-tight); overflow-wrap: anywhere; }
|
||||
@@ -210,8 +210,7 @@
|
||||
.warnings { border-left: 3px solid var(--state-warning); padding-left: var(--space-4); font-size: var(--text-sm); }.warnings h3 { font-size: var(--text-sm); color: var(--state-warning); }.warnings ul { margin: var(--space-2) 0 0; padding-left: var(--space-4); line-height: 1.6; }.warnings li { overflow-wrap: anywhere; }
|
||||
.notice { color: var(--state-online); font-size: var(--text-sm); }.action-help { color: var(--text-secondary); font-size: var(--text-xs); margin-top: var(--space-2); }.dependency, .diagnostics { font-size: var(--text-sm); }.diagnostics { gap: var(--space-3) var(--space-5); }
|
||||
.recovery-note { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); font-size: var(--text-sm); }.recovery-note p, .recovery-note span { color: var(--text-secondary); }.recovery-note span { font-size: var(--text-xs); }
|
||||
.timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing time { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.freshness { justify-content: space-between; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin-top: calc(-1 * var(--space-2)); }
|
||||
.timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing :global(time) { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); gap: var(--space-8); align-items: start; }.execution-column { display: grid; gap: var(--space-8); min-width: 0; }.context-column { display: grid; gap: var(--space-5); min-width: 0; }.context-section { display: grid; gap: var(--space-4); min-width: 0; }.context-section h2 { font-size: var(--text-md); }.context-section p { font-size: var(--text-sm); color: var(--text-secondary); }.context-section p.small { font-size: var(--text-xs); }
|
||||
.metadata { display: grid; gap: var(--space-4); margin: 0; }.metadata dt { color: var(--text-tertiary); font-size: var(--text-xs); }.metadata dd { margin: var(--space-1) 0 0; font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span, .related code { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); }code { font-family: var(--font-mono); }
|
||||
@@ -219,5 +218,5 @@
|
||||
.launch-another { display: grid; gap: var(--space-2); justify-items: start; border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.launch-another p { font-size: var(--text-xs); }
|
||||
.loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); }
|
||||
@media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.identity-row, .identity-links { align-items: flex-start; }.operation-id { display: block; }.operation-id > span { display: block; margin-bottom: var(--space-1); }.outcome-top { align-items: flex-start; }.record-label { display: none; }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.freshness { align-items: flex-start; }.freshness > span { flex: 1 1 170px; }.detail-grid { gap: var(--space-6); } }
|
||||
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.identity-row, .identity-links { align-items: flex-start; }.operation-id { display: block; }.operation-id > span { display: block; margin-bottom: var(--space-1); }.outcome-top { align-items: flex-start; }.record-label { display: none; }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } }
|
||||
</style>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<section class="library" aria-label="Script library">
|
||||
{#if loading || scripts.length || error}<div class="library-toolbar">
|
||||
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||
<label><span class="sr-only">Filter by status</span><select bind:value={status}>{#each tabs as tab}<option value={tab.value}>{tab.value === 'all' ? 'All statuses' : tab.label}</option>{/each}<option value="empty">No versions</option></select></label>
|
||||
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -112,7 +112,7 @@
|
||||
<tr>
|
||||
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td>
|
||||
<td>{#if key.assigned_servers.length}<span>Assigned</span>{#each key.assigned_servers as name}<div class="assignment"><ServerIdentity {name} /><code>{name}</code></div>{/each}{:else}<span class="available">Available</span>{/if}</td>
|
||||
<td>{$timeFormat.dateTime(key.updated_at)}</td>
|
||||
<td><RelativeTime value={key.updated_at} /></td>
|
||||
<td><Button ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} disabled={busy || !!key.assigned_servers.length} onclick={() => remove(key)}>Remove</Button>{#if key.assigned_servers.length}<small>In use</small>{/if}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
@@ -123,7 +123,7 @@
|
||||
<Dialog bind:open title="Add key" busy={busy}>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<form onsubmit={save}>
|
||||
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
|
||||
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
|
||||
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
|
||||
</Field>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { freshTelemetry } from '$lib/server-observation.js';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
|
||||
@@ -257,7 +257,7 @@
|
||||
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
|
||||
{#if activeSection === 'live'}
|
||||
<section id="scoreboard" aria-label="Scoreboard">
|
||||
{#if !scoreboard || !fresh || !freshTelemetry(scoreboard, $uiNow, server.telemetry_after)}<p class="live-context">Players: {effectivePlayers ?? '—'} · {fleetIdentity(server).map}{#if !fresh} · Current count unavailable{/if}</p>{/if}
|
||||
{#if !scoreboard || !fresh || !freshTelemetry(scoreboard, $uiNow, server.telemetry_after)}<p class="live-context">Players: {effectivePlayers ?? '—'} · {fleetIdentity(server).map}{!fresh ? ' · Current count unavailable' : ''}</p>{/if}
|
||||
<Card padding="lg">
|
||||
{#key name}<PlayerScoreboard matchElapsed={gameElapsed} activity={resetElapsed ? `Resetting · ${resetElapsed}` : activeElapsed ? `Players present for ${activeElapsed}` : inactiveElapsed ? `Empty for ${inactiveElapsed}` : undefined} current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
|
||||
</Card>
|
||||
@@ -285,7 +285,7 @@
|
||||
{:else}
|
||||
<div class="table-scroll"><table class="detail-table"><thead><tr><th>File</th><th>Type</th><th>Recorded version</th><th>Details</th></tr></thead><tbody>
|
||||
{#each inventory.slice(0, inventoryLimit) as f}
|
||||
<tr><td>{#if f.artifactId}<a href={contextHref(`/${f.kind === 'plugin' ? 'plugins' : f.kind === 'config' || f.kind === 'baseline' ? 'configs' : 'scripts'}/${f.artifactId}`, $page.url)}>{f.relPath}</a>{:else}{f.relPath}{/if}</td><td>{f.kind ?? 'file'}</td><td>{f.releaseVersion ?? (f.version ? `v${f.version}` : '—')}</td><td><details><summary><span class="sr-only">{f.relPath} </span>Details</summary><dl class="file-details"><dt>Size</dt><dd>{new Intl.NumberFormat().format(f.size)} B</dd><dt>SHA-256</dt><dd><code>{f.sha256}</code></dd><dt>Recorded</dt><dd>{$timeFormat.dateTime(f.updatedAt)}</dd><dt>Source</dt><dd>{#if f.source?.startsWith('promote:') || f.source?.startsWith('rollback:')}<a href={contextHref(`/campaigns/${f.source.slice(f.source.indexOf(':') + 1)}`, $page.url)}>View campaign</a>{:else}{f.source === 'native-create' ? 'Instance creation' : f.source ?? 'Unknown'}{/if}</dd></dl></details></td></tr>
|
||||
<tr><td>{#if f.artifactId}<a href={contextHref(`/${f.kind === 'plugin' ? 'plugins' : f.kind === 'config' || f.kind === 'baseline' ? 'configs' : 'scripts'}/${f.artifactId}`, $page.url)}>{f.relPath}</a>{:else}{f.relPath}{/if}</td><td>{f.kind ?? 'file'}</td><td>{f.releaseVersion ?? (f.version ? `v${f.version}` : '—')}</td><td><details><summary><span class="sr-only">{f.relPath} </span>Details</summary><dl class="file-details"><dt>Size</dt><dd>{new Intl.NumberFormat().format(f.size)} B</dd><dt>SHA-256</dt><dd><code>{f.sha256}</code></dd><dt>Recorded</dt><dd><RelativeTime value={f.updatedAt} /></dd><dt>Source</dt><dd>{#if f.source?.startsWith('promote:') || f.source?.startsWith('rollback:')}<a href={contextHref(`/campaigns/${f.source.slice(f.source.indexOf(':') + 1)}`, $page.url)}>View campaign</a>{:else}{f.source === 'native-create' ? 'Instance creation' : f.source ?? 'Unknown'}{/if}</dd></dl></details></td></tr>
|
||||
{/each}
|
||||
</tbody></table></div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
@@ -100,7 +101,7 @@
|
||||
<div class="user-list" aria-label="User accounts">{#each users as user (user.id)}<article class="user-row">
|
||||
<div class="user-name"><h2>{user.displayName} {#if user.id === $page.data.user?.id}<span class="you">You</span>{/if}</h2><p>@{user.username}</p></div>
|
||||
<div class="access"><span class="role">{user.role === 'admin' ? 'Administrator' : user.role}</span><span class:disabled={user.disabled}>{user.disabled ? 'Disabled' : 'Enabled'}</span>{#if user.mustChangePassword && !user.disabled}<small>Password change required</small>{/if}</div>
|
||||
<div class="last-login"><span>Last sign-in</span><p>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}</p></div>
|
||||
<div class="last-login"><span>Last sign-in</span><p><RelativeTime value={user.lastLoginAt} missing={user.id === $page.data.user?.id ? 'Signed in · time not recorded' : 'Not recorded'} /></p></div>
|
||||
<div class="actions"><Button size="sm" disabled={busy} onclick={() => openEdit(user)}>Edit access</Button><Button size="sm" variant="ghost" disabled={busy} onclick={() => revoke(user)}>Sign out sessions</Button></div>
|
||||
</article>{/each}</div>
|
||||
{/if}
|
||||
@@ -110,7 +111,7 @@
|
||||
{#if auditLoading && !events.length}<p class="muted" role="status">Loading account activity…</p>
|
||||
{:else if !events.length}<p class="muted">No account events recorded.</p>
|
||||
{:else}<div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>By</th><th>Account</th><th>Details</th></tr></thead><tbody>
|
||||
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time">{new Date(event.createdAt).toLocaleString()}</td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
|
||||
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time"><RelativeTime value={event.createdAt} /></td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
|
||||
</tbody></table></div>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -32,13 +32,12 @@
|
||||
<label for="setup-confirm">Confirm password<input id="setup-confirm" name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password" bind:value={confirmPassword} /></label>
|
||||
<Button type="submit" variant="primary" disabled={busy} loading={busy}>Create administrator</Button>
|
||||
</fieldset></form>
|
||||
<a class="login-link" href="/login">Already have an account? Sign in</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
|
||||
form { margin-top:24px; } fieldset { display:grid; gap:16px; border:0; margin:0; padding:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
|
||||
.help,.hint,.login-link { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); } .login-link { display:block; margin-top:24px; color:var(--accent-primary-hover); }
|
||||
.help,.hint { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); }
|
||||
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
|
||||
@media(max-width:540px) { .auth-card { padding:24px; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user