Simplify campaign detail UI and progress states
This commit is contained in:
@@ -1,3 +1,22 @@
|
|||||||
|
import { statusPresentation, type StatusTone } from './status.js';
|
||||||
|
|
||||||
|
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
|
||||||
|
|
||||||
|
/** Progress comes from recorded state, not the error field, which also contains routine stage messages. */
|
||||||
|
export function campaignServerStatus(server: {
|
||||||
|
restart_state: string; server_deleted_at?: string | null; restarted_at: string | null;
|
||||||
|
}, files: { apply_state: string }[] = []): { group: CampaignServerGroup; label: string; tone: StatusTone } {
|
||||||
|
if (server.restart_state === 'done') return { group: 'complete', label: 'Complete', tone: 'online' };
|
||||||
|
if (server.server_deleted_at) return { group: 'inactive', label: 'Deleted before activation', tone: 'neutral' };
|
||||||
|
if (['skipped', 'cancelled'].includes(server.restart_state)) return { group: 'inactive', ...statusPresentation(server.restart_state) };
|
||||||
|
if (['failed', 'manual', 'held'].includes(server.restart_state)) {
|
||||||
|
return { group: 'attention', ...statusPresentation(server.restart_state), tone: server.restart_state === 'failed' ? 'danger' : 'warning' };
|
||||||
|
}
|
||||||
|
if (server.restarted_at) return { group: 'processing', label: 'Verifying health', tone: 'info' };
|
||||||
|
if (files.some(file => file.apply_state === 'applying')) return { group: 'processing', label: 'Applying files', tone: 'info' };
|
||||||
|
return { group: 'queued', label: 'Queued', tone: 'neutral' };
|
||||||
|
}
|
||||||
|
|
||||||
/** Load only the requested history, respecting the API's per-request page bound. */
|
/** Load only the requested history, respecting the API's per-request page bound. */
|
||||||
export async function fetchCampaigns<T>(filters: Record<string, string>, count: number, signal?: AbortSignal): Promise<{ deployments: T[]; total: number }> {
|
export async function fetchCampaigns<T>(filters: Record<string, string>, count: number, signal?: AbortSignal): Promise<{ deployments: T[]; total: number }> {
|
||||||
const deployments: T[] = [];
|
const deployments: T[] = [];
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import BackLink from '$ui/components/BackLink.svelte';
|
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||||
import { campaignFlow, type ActivityEvent } from '$lib/operation-flow.js';
|
import type { ActivityEvent } from '$lib/operation-flow.js';
|
||||||
|
import { campaignServerStatus, type CampaignServerGroup } from '$ui/campaigns.js';
|
||||||
|
import { serverIdentity } from '$ui/server-labels.js';
|
||||||
|
|
||||||
interface FileRow {
|
interface FileRow {
|
||||||
id: number; server: string; verb: string; rel_path: string;
|
id: number; server: string; verb: string; rel_path: string;
|
||||||
@@ -54,14 +53,34 @@
|
|||||||
let diffRequest = 0;
|
let diffRequest = 0;
|
||||||
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||||
let retryFile = $state<FileRow | null>(null);
|
let retryFile = $state<FileRow | null>(null);
|
||||||
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
|
let filter = $state<'all' | CampaignServerGroup>('all');
|
||||||
const withdrawn = $derived(detail?.servers.filter(server => ['skipped', 'cancelled'].includes(server.restart_state)).length ?? 0);
|
let expandedServers = $state<string[]>([]);
|
||||||
const unresolved = $derived(detail?.servers.filter(server => !server.server_deleted_at && !['done', 'skipped', 'cancelled'].includes(server.restart_state)).length ?? 0);
|
let copyStatus = $state('');
|
||||||
const deletedIncomplete = $derived(detail?.servers.filter(server => server.server_deleted_at && server.restart_state !== 'done').length ?? 0);
|
const serverRows = $derived((detail?.servers ?? []).map(server => ({
|
||||||
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
|
server, identity: serverIdentity(server.server),
|
||||||
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
|
status: campaignServerStatus(server, byServer.get(server.server))
|
||||||
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
|
})));
|
||||||
|
const counts = $derived.by(() => {
|
||||||
|
const result: Record<CampaignServerGroup, number> = { attention: 0, processing: 0, queued: 0, complete: 0, inactive: 0 };
|
||||||
|
for (const row of serverRows) result[row.status.group]++;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const filters: { value: 'all' | CampaignServerGroup; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All' }, { value: 'attention', label: 'Needs attention' },
|
||||||
|
{ value: 'processing', label: 'Processing' }, { value: 'queued', label: 'Queued' },
|
||||||
|
{ value: 'complete', label: 'Complete' }, { value: 'inactive', label: 'Inactive' }
|
||||||
|
];
|
||||||
|
const visibleServers = $derived(serverRows.filter(row => filter === 'all' || row.status.group === filter).sort((a, b) => {
|
||||||
|
const rank: Record<CampaignServerGroup, number> = { attention: 0, processing: 1, queued: 2, complete: 3, inactive: 4 };
|
||||||
|
return rank[a.status.group] - rank[b.status.group];
|
||||||
}));
|
}));
|
||||||
|
const uniqueFileCount = $derived(new Set(detail?.files.map(file => file.rel_path)).size);
|
||||||
|
|
||||||
|
async function copyId() {
|
||||||
|
if (!detail) return;
|
||||||
|
try { await navigator.clipboard.writeText(detail.deployment.id); copyStatus = 'Copied'; }
|
||||||
|
catch { copyStatus = 'Could not copy. Select the ID to copy it manually.'; }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDetail(): Promise<void> {
|
async function loadDetail(): Promise<void> {
|
||||||
const id = $page.params.id;
|
const id = $page.params.id;
|
||||||
@@ -77,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; void loadDetail(); });
|
$effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; filter = 'all'; expandedServers = []; copyStatus = ''; void loadDetail(); });
|
||||||
|
|
||||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||||
|
|
||||||
@@ -132,7 +151,7 @@
|
|||||||
{#if detail}
|
{#if detail}
|
||||||
{@const action = detail.deployment.action}
|
{@const action = detail.deployment.action}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`} subtitle={detail.deployment.id}>
|
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`}>
|
||||||
{#snippet crumbs()}
|
{#snippet crumbs()}
|
||||||
<Breadcrumbs items={[
|
<Breadcrumbs items={[
|
||||||
{ label: 'Campaigns', href: '/campaigns' },
|
{ label: 'Campaigns', href: '/campaigns' },
|
||||||
@@ -140,70 +159,103 @@
|
|||||||
]} />
|
]} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<BackLink fallback="/campaigns" />
|
<div class="metadata">
|
||||||
{#if detail.fileContentsExpired}<p role="status">File contents have expired under the retention policy. File comparisons and rollback are unavailable.</p>{/if}
|
<span>Started {fmt(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.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>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{#if detail.fileContentsExpired}<p class="muted" role="status">File contents have expired. File comparisons and rollback are unavailable.</p>{/if}
|
||||||
|
|
||||||
<section class="outcome" aria-label="Campaign outcome">
|
<section class="outcome" aria-label="Campaign progress">
|
||||||
<h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{withdrawn ? ` · ${withdrawn} withdrawn` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2>
|
<div class="progress-heading">
|
||||||
<p>{detail.watchdogIncidentId ? 'Maintenance repairs drift by deleting the existing instance files and rebuilding with the latest promoted setup after verifying a safe server state.' : 'File changes wait for an empty server and restart capacity. The campaign captures rollback snapshots immediately before applying files, then restarts the server and verifies health. Tested servers keep their original pre-beta snapshots.'}</p>
|
<h2>{counts.complete} <span>of {detail.servers.length} servers complete</span></h2>
|
||||||
{#if unresolved || deletedIncomplete}<p>Review the recorded results below.{#if deletedIncomplete} Deleted servers retain their last recorded progress.{/if}{#if unresolved} Diagnose connectivity or inspect startup logs from the affected row.{/if}</p>
|
<span class="live-status">{loadError ? 'Updates interrupted' : 'Updates every 5s'}</span>
|
||||||
{:else if withdrawn}<p>Withdrawn changes will not be activated by this campaign.</p>
|
</div>
|
||||||
{:else}<p>Every recorded server completed activation.</p>{/if}
|
<progress max={detail.servers.length || 1} value={counts.complete} aria-label="Servers completed"></progress>
|
||||||
|
<div class="progress-footer">
|
||||||
|
<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.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}
|
||||||
|
</div>
|
||||||
|
{#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
|
||||||
|
<Button size="sm" onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>View incident and recovery history</a></p>{/if}
|
|
||||||
{#if detail.deployment.beta_id}<p class="watchdog-source">Started from <a href={contextHref(`/betas/${detail.deployment.beta_id}`, $page.url)}>Beta #{detail.deployment.beta_id}</a> · Review its files, checks, and test servers.</p>{/if}
|
|
||||||
|
|
||||||
{#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
|
|
||||||
<div><Button onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button><p>Apply held changes and restart after the safety checks pass.</p></div>
|
|
||||||
{/if}
|
|
||||||
{#if resumeError}<p class="err" role="alert">{resumeError}</p>{/if}
|
{#if resumeError}<p class="err" role="alert">{resumeError}</p>{/if}
|
||||||
|
|
||||||
<Card padding="lg">
|
<section class="section" aria-labelledby="servers-heading">
|
||||||
<div class="meta-grid">
|
<h2 id="servers-heading">Servers</h2>
|
||||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
<div class="filters" role="group" aria-label="Filter servers by status">
|
||||||
<div class="kv"><span class="k">Finished</span><span class="v mono">{fmt(detail.deployment.finished_at)}</span></div>
|
{#each filters as option}
|
||||||
<div class="kv"><span class="k">Triggered by</span><span class="v mono">{detail.deployment.triggered_by ?? '—'}</span></div>
|
{@const count = option.value === 'all' ? detail.servers.length : counts[option.value]}
|
||||||
<div class="kv"><span class="k">Servers</span><span class="v mono">{detail.servers.length}</span></div>
|
{#if option.value !== 'inactive' || count || filter === option.value}
|
||||||
</div>
|
<button class:chosen={filter === option.value} aria-pressed={filter === option.value} onclick={() => filter = option.value}>{option.label} <span>{count}</span></button>
|
||||||
</Card>
|
{/if}
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Servers</h2>
|
|
||||||
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="server-list">
|
|
||||||
{#each rankedServers as s (s.server)}
|
|
||||||
<div class="srv-row">
|
|
||||||
<div class="srv-l">
|
|
||||||
<span class="srv-name"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(s.server)}`, $page.url)} /></span>
|
|
||||||
{#if s.error}
|
|
||||||
<span class="err">{s.error}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="srv-r">
|
|
||||||
<StatusBadge state={s.restart_state} />
|
|
||||||
{#if s.provisioning_id}<a href="/provisioning/{encodeURIComponent(s.provisioning_id)}">View provisioning</a>{/if}
|
|
||||||
{#if s.players !== null && s.players > 0}
|
|
||||||
<span class="players">{s.players} players at last check</span>
|
|
||||||
{/if}
|
|
||||||
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
|
||||||
{#if s.restarted_at}
|
|
||||||
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if s.restart_state !== 'done' && !s.server_deleted_at}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col">Server</th><th scope="col">Lobby</th><th scope="col">Status</th><th scope="col" class="actions-heading">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each visibleServers as { server: s, identity, status } (s.server)}
|
||||||
|
{@const expanded = expandedServers.includes(s.server)}
|
||||||
|
<tr class:expanded>
|
||||||
|
<td class="server-cell">
|
||||||
|
{#if s.server_deleted_at}<span title={s.server}>{identity.map}</span>
|
||||||
|
{:else}<a title={s.server} href={contextHref(`/servers/${encodeURIComponent(s.server)}`, $page.url)}>{identity.map}</a>{/if}
|
||||||
|
{#if identity.game}<small>{identity.game}</small>{/if}
|
||||||
|
{#if s.server_deleted_at}<Badge size="sm">Deleted</Badge>{/if}
|
||||||
|
</td>
|
||||||
|
<td>{identity.lobby ?? '—'}</td>
|
||||||
|
<td><Badge variant={status.tone} size="md">{status.label}</Badge></td>
|
||||||
|
<td><div class="row-actions">
|
||||||
|
{#if s.restart_state === 'failed' && !s.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(s.server)}?tab=diagnostics`, $page.url)}>Diagnose</a>{/if}
|
||||||
|
<button class="text-button" aria-expanded={expanded} aria-controls={`server-details-${s.server}`} aria-label={`${expanded ? 'Hide' : 'Show'} details for ${identity.map}${identity.lobby !== null ? ` lobby ${identity.lobby}` : ''}`} onclick={() => expandedServers = expanded ? expandedServers.filter(name => name !== s.server) : [...expandedServers, s.server]}>{expanded ? 'Hide details' : 'Details'} <span aria-hidden="true">{expanded ? '−' : '+'}</span></button>
|
||||||
|
</div></td>
|
||||||
|
</tr>
|
||||||
|
<tr hidden={!expanded} class="detail-row" id={`server-details-${s.server}`}>
|
||||||
|
<td colspan="4">
|
||||||
|
<div class="server-details">
|
||||||
|
{#if s.error}<p class:err={s.restart_state === 'failed'}>{s.error}</p>{/if}
|
||||||
|
<dl>
|
||||||
|
<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}
|
||||||
|
</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}
|
||||||
|
{#if s.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(s.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr><td colspan="4" class="empty-servers">{filter === 'all' ? 'No servers recorded.' : 'No servers in this status.'}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<details class="files-section">
|
||||||
<div class="section-head">
|
<summary>Files <span>{uniqueFileCount} file{uniqueFileCount === 1 ? '' : 's'} across {byServer.size} server{byServer.size === 1 ? '' : 's'}</span></summary>
|
||||||
<h2>Files</h2>
|
|
||||||
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
|
|
||||||
</div>
|
|
||||||
{#if detail.files.length === 0}
|
{#if detail.files.length === 0}
|
||||||
<EmptyState title="No file changes recorded" description="This campaign didn't change any tracked files." />
|
<EmptyState title="No file changes recorded" description="This campaign didn't change any tracked files." />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -228,9 +280,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</details>
|
||||||
|
|
||||||
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at), detail.files)} events={detail.events} active={detail.servers.some(s => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'))} />
|
<OperationHistory events={detail.events} showOverview={false} />
|
||||||
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
||||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
|
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
|
||||||
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
||||||
@@ -242,136 +294,71 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.watchdog-source { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); }.watchdog-source a { color: var(--accent-primary-hover); }
|
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||||
.outcome { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }.outcome h2 { font-size: 20px; margin: 0; }.outcome p { color: var(--text-secondary); line-height: 1.6; margin: 10px 0 0; }.srv-r a { color: var(--accent-primary-hover); font-size: 13px; }
|
a { color: var(--accent-primary-hover); }
|
||||||
.page {
|
.metadata { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
display: flex;
|
.campaign-details { flex-basis: 100%; }
|
||||||
flex-direction: column;
|
summary { cursor: pointer; }
|
||||||
gap: var(--space-8);
|
.campaign-details-body { padding: 16px 0 0; max-width: 800px; line-height: 1.6; }
|
||||||
}
|
.campaign-id { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
.loading {
|
.mono { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||||
text-align: center;
|
.outcome { padding: 20px 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: grid; gap: 16px; }
|
||||||
padding: var(--space-16);
|
.progress-heading, .progress-footer { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
color: var(--text-tertiary);
|
.progress-heading h2 { font-size: var(--text-2xl); margin: 0; }
|
||||||
}
|
.progress-heading h2 span { font-size: var(--text-md); font-weight: var(--weight-medium); }
|
||||||
|
.live-status { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
.meta-grid {
|
progress { display: block; width: 100%; height: 6px; appearance: none; border: 0; border-radius: var(--radius-pill); overflow: hidden; background: var(--surface-raised); }
|
||||||
display: grid;
|
progress::-webkit-progress-bar { background: var(--surface-raised); }
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
progress::-webkit-progress-value { background: var(--accent-primary); border-radius: var(--radius-pill); }
|
||||||
gap: var(--space-2);
|
progress::-moz-progress-bar { background: var(--accent-primary); border-radius: var(--radius-pill); }
|
||||||
}
|
.progress-counts { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
.kv {
|
.attention { color: var(--state-warning); }
|
||||||
display: flex;
|
.section { display: grid; gap: 16px; min-width: 0; }
|
||||||
flex-direction: column;
|
.section h2 { font-size: var(--text-md); margin: 0; }
|
||||||
gap: 2px;
|
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
padding: var(--space-3) var(--space-4);
|
.filters button { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); }
|
||||||
background: var(--surface-sunken);
|
.filters button:hover, .filters .chosen { color: var(--text-primary); background: var(--surface-raised); }
|
||||||
border: 1px solid var(--border-subtle);
|
.filters .chosen { border-color: var(--border-strong); }
|
||||||
border-radius: var(--radius-md);
|
.filters button span { font-size: var(--text-xs); font-variant-numeric: tabular-nums; color: var(--text-tertiary); }
|
||||||
}
|
.table-scroll { overflow-x: auto; }
|
||||||
.kv .k {
|
table { border-collapse: collapse; width: 100%; font-size: var(--text-sm); text-align: left; }
|
||||||
font-size: var(--text-xs);
|
th { padding: 10px 16px; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-medium); border-bottom: 1px solid var(--border-strong); }
|
||||||
text-transform: uppercase;
|
td { padding: 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
|
||||||
letter-spacing: var(--tracking-label);
|
.server-cell { min-width: 160px; font-weight: var(--weight-semibold); }
|
||||||
color: var(--text-tertiary);
|
.server-cell small { display: block; margin-top: 4px; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
||||||
font-weight: var(--weight-semibold);
|
.actions-heading { text-align: right; }
|
||||||
}
|
.row-actions { display: flex; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
.kv .v { font-size: var(--text-sm); color: var(--text-primary); }
|
.text-button { background: transparent; border: 0; padding: 8px 0; color: var(--text-secondary); cursor: pointer; font: inherit; white-space: nowrap; }
|
||||||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
.text-button:hover { color: var(--text-primary); }
|
||||||
|
button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
.expanded, .detail-row { background: var(--surface); }
|
||||||
.section-head {
|
.expanded td { border-bottom-color: transparent; }
|
||||||
display: flex;
|
.detail-row td { padding-top: 0; }
|
||||||
align-items: center;
|
.server-details { display: grid; gap: 16px; color: var(--text-secondary); }
|
||||||
gap: var(--space-3);
|
.server-details p { margin: 0; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
}
|
dl { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 0; }
|
||||||
.section-head h2 {
|
dt { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
||||||
margin: 0;
|
dd { margin: 0; }
|
||||||
font-family: var(--font-body);
|
.detail-actions { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
font-size: var(--text-md);
|
.empty-servers { text-align: center; padding: 32px; color: var(--text-secondary); }
|
||||||
font-weight: var(--weight-semibold);
|
.err { font-size: var(--text-sm); color: var(--state-danger); }
|
||||||
color: var(--text-primary);
|
.muted { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
}
|
.files-section { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||||
|
.files-section summary { font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||||
.server-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
.files-section summary span { margin-left: 12px; color: var(--text-secondary); font-weight: var(--weight-regular); }
|
||||||
.srv-row {
|
.file-group { margin-top: 24px; display: grid; gap: 8px; }
|
||||||
display: flex;
|
.file-group h3 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||||
justify-content: space-between;
|
.file-rows { display: grid; gap: 4px; }
|
||||||
align-items: center;
|
.file-row { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; padding: 12px; background: transparent; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); cursor: pointer; font: inherit; }
|
||||||
gap: var(--space-4);
|
.file-row:hover:not(:disabled) { background: var(--surface-raised); }
|
||||||
padding: var(--space-3) var(--space-4);
|
.file-row:disabled { cursor: default; }
|
||||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
.path { font-family: var(--font-mono); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||||
border: 1px solid var(--border-subtle);
|
.sizes { margin-left: auto; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
border-radius: var(--radius-md);
|
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
|
||||||
}
|
|
||||||
.srv-l { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; }
|
|
||||||
.srv-r { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
|
||||||
.srv-name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.srv-name:hover { color: var(--accent-primary-hover); }
|
|
||||||
.err { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-danger); }
|
|
||||||
.players { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-online); }
|
|
||||||
.attempts, .when {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-group {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.file-group h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
}
|
|
||||||
.file-rows { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.file-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.file-row:hover { background: var(--surface-raised); }
|
|
||||||
.file-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
|
||||||
.path {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.sizes {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Diff modal */
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.srv-row { align-items: flex-start; flex-direction: column; }
|
.outcome { padding: 16px; }
|
||||||
.srv-l, .file-row { flex-wrap: wrap; }
|
th, td { padding: 12px 8px; }
|
||||||
|
.file-row { flex-wrap: wrap; }
|
||||||
|
.files-section summary span { margin-left: 4px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,32 @@
|
|||||||
import { afterEach, expect, it, vi } from 'vitest';
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
import { fetchCampaigns } from '../../src/lib/ui/campaigns.js';
|
import { campaignServerStatus, fetchCampaigns } from '../../src/lib/ui/campaigns.js';
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('distinguishes queued work, file application, and health verification without treating stage messages as failures', () => {
|
||||||
|
const server = { restart_state: 'pending', restarted_at: null, error: 'Queued to restart; waiting for another server to recover' };
|
||||||
|
expect(campaignServerStatus(server, [{ apply_state: 'pending' }])).toEqual({ group: 'queued', label: 'Queued', tone: 'neutral' });
|
||||||
|
expect(campaignServerStatus(server, [{ apply_state: 'applying' }])).toMatchObject({ group: 'processing', label: 'Applying files' });
|
||||||
|
expect(campaignServerStatus({ ...server, restarted_at: '2026-10-06T20:08:29Z', error: 'Checking container state with host agent' }, [{ apply_state: 'applied' }]))
|
||||||
|
.toEqual({ group: 'processing', label: 'Verifying health', tone: 'info' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['failed', 'held', 'manual'])('keeps %s servers needing intervention ahead of ordinary progress', restart_state => {
|
||||||
|
expect(campaignServerStatus({ restart_state, restarted_at: '2026-10-06T20:08:29Z' }, [{ apply_state: 'applying' }]))
|
||||||
|
.toMatchObject({ group: 'attention', tone: restart_state === 'failed' ? 'danger' : 'warning' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves completed and inactive outcomes without asking users to intervene on deleted servers', () => {
|
||||||
|
const server = { restart_state: 'done', restarted_at: '2026-10-06T20:08:29Z' };
|
||||||
|
expect(campaignServerStatus(server)).toMatchObject({ group: 'complete', label: 'Complete' });
|
||||||
|
for (const restart_state of ['skipped', 'cancelled']) {
|
||||||
|
expect(campaignServerStatus({ ...server, restart_state })).toMatchObject({ group: 'inactive', tone: 'neutral' });
|
||||||
|
}
|
||||||
|
const deleted = { ...server, server_deleted_at: '2026-10-06T21:00:00Z' };
|
||||||
|
expect(campaignServerStatus(deleted)).toMatchObject({ group: 'complete' });
|
||||||
|
expect(campaignServerStatus({ ...deleted, restart_state: 'failed' })).toEqual({ group: 'inactive', label: 'Deleted before activation', tone: 'neutral' });
|
||||||
|
});
|
||||||
|
|
||||||
it('loads history beyond the API page limit without truncating or fetching unwanted older rows', async () => {
|
it('loads history beyond the API page limit without truncating or fetching unwanted older rows', async () => {
|
||||||
const requests: URL[] = [];
|
const requests: URL[] = [];
|
||||||
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
|
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user