Make UI language, timestamps and live feedback consistent
This commit is contained in:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user