961 lines
36 KiB
Svelte
961 lines
36 KiB
Svelte
<script lang="ts">
|
||
import { canOperate } from '$lib/auth.js';
|
||
const writable = $derived(canOperate($page.data?.user?.role));
|
||
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 { pollVisible } from '$ui/poll.js';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||
import { page } from '$app/stores';
|
||
import { goto } from '$app/navigation';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||
import type { ActivityEvent } from '$lib/operation-flow.js';
|
||
import {
|
||
campaignActionLabel,
|
||
campaignServerStatus,
|
||
type CampaignServerGroup
|
||
} from '$ui/campaigns.js';
|
||
import { serverIdentity } from '$ui/server-labels.js';
|
||
|
||
interface FileRow {
|
||
id: number;
|
||
server: string;
|
||
verb: string;
|
||
rel_path: string;
|
||
sha256_before: string | null;
|
||
sha256_after: string | null;
|
||
size_before: number | null;
|
||
size_after: number | null;
|
||
apply_state: 'pending' | 'applying' | 'applied' | 'cancelled';
|
||
snapshot_pending: number;
|
||
}
|
||
interface ServerRow {
|
||
deployment_id: string;
|
||
server: string;
|
||
server_deleted_at?: string | null;
|
||
files_changed: number;
|
||
restart_state: string;
|
||
players: number | null;
|
||
checked_at: string | null;
|
||
restarted_at: string | null;
|
||
provisioning_id: string | null;
|
||
attempts: number;
|
||
error: string | null;
|
||
}
|
||
interface Detail {
|
||
fileContentsExpired?: boolean;
|
||
deployment: {
|
||
id: string;
|
||
action: string;
|
||
started_at: string;
|
||
finished_at: string | null;
|
||
triggered_by: string | null;
|
||
beta_id?: number | null;
|
||
rolled_back_by: string | null;
|
||
};
|
||
servers: ServerRow[];
|
||
files: FileRow[];
|
||
events: ActivityEvent[];
|
||
watchdogIncidentId?: string | null;
|
||
}
|
||
|
||
let detail = $state<Detail | null>(null);
|
||
let loadError = $state('');
|
||
let resuming = $state(false);
|
||
let resumeError = $state('');
|
||
let rollingBack = $state(false);
|
||
let rollbackError = $state('');
|
||
let request = 0;
|
||
let diffOpen = $state(false);
|
||
let diffError = $state('');
|
||
let diffLoading = $state(false);
|
||
let diffRequest = 0;
|
||
let openFile = $state<{
|
||
id: number;
|
||
before: string | null;
|
||
after: string | null;
|
||
verb: string;
|
||
relPath: string;
|
||
} | null>(null);
|
||
let retryFile = $state<FileRow | null>(null);
|
||
let filter = $state<'all' | CampaignServerGroup>('all');
|
||
let expandedServers = $state<string[]>([]);
|
||
let copyStatus = $state('');
|
||
const title = $derived(
|
||
detail ? `${campaignActionLabel(detail.deployment.action)} campaign` : 'Campaign'
|
||
);
|
||
const serverRows = $derived(
|
||
(detail?.servers ?? []).map((server) => ({
|
||
server,
|
||
identity: serverIdentity(server.server),
|
||
status: campaignServerStatus(server, byServer.get(server.server))
|
||
}))
|
||
);
|
||
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> {
|
||
const id = $page.params.id;
|
||
const current = ++request;
|
||
try {
|
||
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
|
||
if (!res.ok)
|
||
throw new Error(
|
||
res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.'
|
||
);
|
||
const body = (await res.json()) as Detail;
|
||
if (current !== request || id !== $page.params.id) return;
|
||
detail = body;
|
||
loadError = '';
|
||
} catch (err) {
|
||
if (current === request && id === $page.params.id) loadError = (err as Error).message;
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
void $page.params.id;
|
||
detail = null;
|
||
openFile = null;
|
||
diffOpen = false;
|
||
loadError = '';
|
||
rollbackError = '';
|
||
filter = 'all';
|
||
expandedServers = [];
|
||
copyStatus = '';
|
||
void loadDetail();
|
||
});
|
||
|
||
onMount(() => pollVisible(loadDetail, 5000, false));
|
||
|
||
async function resume() {
|
||
if (!writable) return;
|
||
resuming = true;
|
||
resumeError = '';
|
||
try {
|
||
const response = await fetch(
|
||
`/api/campaigns/${encodeURIComponent($page.params.id ?? '')}/resume`,
|
||
{ method: 'POST' }
|
||
);
|
||
if (!response.ok)
|
||
throw new Error((await response.json()).error ?? 'Could not resume campaign.');
|
||
await loadDetail();
|
||
} catch (error) {
|
||
resumeError = (error as Error).message;
|
||
} finally {
|
||
resuming = false;
|
||
}
|
||
}
|
||
|
||
async function rollback() {
|
||
if (!writable) return;
|
||
if (!detail || rollingBack) return;
|
||
const id = detail.deployment.id;
|
||
const servers = detail.servers.filter((server) => !server.server_deleted_at).length;
|
||
if (
|
||
!(await confirmAction({
|
||
title: 'Roll back promotion?',
|
||
message: `Restore the previous files on ${servers} server${servers === 1 ? '' : 's'}. Changes will wait for safe activation.`,
|
||
confirmLabel: 'Roll back',
|
||
danger: true
|
||
}))
|
||
)
|
||
return;
|
||
if ($page.params.id !== id) return;
|
||
rollingBack = true;
|
||
rollbackError = '';
|
||
try {
|
||
const response = await fetch(`/api/campaigns/${encodeURIComponent(id)}/rollback`, {
|
||
method: 'POST'
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok || !body.ok)
|
||
throw new Error(body.error ?? 'Could not roll back promotion.');
|
||
if ($page.params.id !== id) return;
|
||
await goto(
|
||
contextHref(`/campaigns/${encodeURIComponent(body.result.deploymentId)}`, $page.url)
|
||
);
|
||
} catch (error) {
|
||
if ($page.params.id === id) rollbackError = (error as Error).message;
|
||
} finally {
|
||
rollingBack = false;
|
||
}
|
||
}
|
||
|
||
async function loadDiff(f: FileRow) {
|
||
retryFile = f;
|
||
const id = $page.params.id;
|
||
const current = ++diffRequest;
|
||
diffOpen = true;
|
||
diffLoading = true;
|
||
diffError = '';
|
||
openFile = null;
|
||
try {
|
||
const response = await fetch('/api/campaigns/' + id + '/file/' + f.id);
|
||
if (!response.ok)
|
||
throw new Error(
|
||
response.status === 410
|
||
? 'File contents expired under the retention policy.'
|
||
: response.status === 409
|
||
? 'The rollback snapshot is not available yet. Refresh after activation to compare files.'
|
||
: 'Could not load this file comparison. Close and select it again to retry.'
|
||
);
|
||
const body = await response.json();
|
||
if (id === $page.params.id && current === diffRequest) openFile = { id: f.id, ...body };
|
||
} catch (error) {
|
||
if (id === $page.params.id && current === diffRequest)
|
||
diffError = (error as Error).message;
|
||
} finally {
|
||
if (current === diffRequest) diffLoading = false;
|
||
}
|
||
}
|
||
|
||
let byServer = $derived.by(() => {
|
||
if (!detail) return new Map<string, FileRow[]>();
|
||
const m = new Map<string, FileRow[]>();
|
||
for (const f of detail.files) {
|
||
if (!m.has(f.server)) m.set(f.server, []);
|
||
m.get(f.server)!.push(f);
|
||
}
|
||
return m;
|
||
});
|
||
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
|
||
return v === 'added'
|
||
? 'online'
|
||
: v === 'updated'
|
||
? 'info'
|
||
: v === 'removed'
|
||
? 'danger'
|
||
: v === 'held'
|
||
? 'warning'
|
||
: 'neutral';
|
||
}
|
||
</script>
|
||
|
||
<svelte:head><title>{title} | Electric Cherry</title></svelte:head>
|
||
{#if loadError}<p role="alert" class="err">
|
||
{loadError} <button onclick={loadDetail}>Retry</button>
|
||
</p>{/if}
|
||
|
||
{#if detail}
|
||
{@const action = detail.deployment.action}
|
||
<div class="page">
|
||
<SectionHeader {title}>
|
||
{#snippet crumbs()}
|
||
<Breadcrumbs
|
||
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: title }]}
|
||
/>
|
||
{/snippet}
|
||
{#snippet actions()}
|
||
{#if detail?.deployment.rolled_back_by}
|
||
<Button
|
||
size="sm"
|
||
href={contextHref(
|
||
`/campaigns/${encodeURIComponent(detail.deployment.rolled_back_by)}`,
|
||
$page.url
|
||
)}>View rollback</Button
|
||
>
|
||
{:else if writable && detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some((server) => !server.server_deleted_at)}
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onclick={rollback}
|
||
loading={rollingBack}
|
||
disabled={rollingBack}>Roll back promotion</Button
|
||
>
|
||
{/if}
|
||
{/snippet}
|
||
</SectionHeader>
|
||
{#if rollbackError}<p class="err" role="alert">{rollbackError}</p>{/if}
|
||
<div class="metadata">
|
||
<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 <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 rebuild capacity. Rollback snapshots are captured before servers rebuild with the requested files 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 progress">
|
||
<div class="progress-heading">
|
||
<h2>{counts.complete} <span>of {detail.servers.length} servers complete</span></h2>
|
||
<span class="live-status"
|
||
>{loadError ? 'Updates interrupted' : 'Updates every 5s'}</span
|
||
>
|
||
</div>
|
||
<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}
|
||
{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}
|
||
</div>
|
||
{#if writable && 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>
|
||
{#if resumeError}<p class="err" role="alert">{resumeError}</p>{/if}
|
||
|
||
<section class="section" aria-labelledby="servers-heading">
|
||
<h2 id="servers-heading">Servers</h2>
|
||
<div class="filters" role="group" aria-label="Filter servers by status">
|
||
{#each filters as option}
|
||
{@const count =
|
||
option.value === 'all' ? detail.servers.length : counts[option.value]}
|
||
{#if option.value !== 'inactive' || count || filter === option.value}
|
||
<button
|
||
class:chosen={filter === option.value}
|
||
aria-pressed={filter === option.value}
|
||
onclick={() => (filter = option.value)}
|
||
>{option.label} <span>{count}</span></button
|
||
>
|
||
{/if}
|
||
{/each}
|
||
</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><RelativeTime value={s.checked_at} /></dd>
|
||
</div>{/if}
|
||
{#if s.restarted_at}<div>
|
||
<dt>Rebuild 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}
|
||
{#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>
|
||
|
||
<details class="files-section">
|
||
<summary
|
||
>Files <span
|
||
>{uniqueFileCount} file{uniqueFileCount === 1 ? '' : 's'} across {byServer.size} server{byServer.size ===
|
||
1
|
||
? ''
|
||
: 's'}</span
|
||
></summary
|
||
>
|
||
{#if detail.files.length === 0}
|
||
<EmptyState
|
||
title="No file changes recorded"
|
||
description="This campaign didn't change any tracked files."
|
||
/>
|
||
{:else}
|
||
{#each [...byServer.entries()] as [srv, files] (srv)}
|
||
<div class="file-group">
|
||
<h3>
|
||
<HistoricalServerIdentity
|
||
name={srv}
|
||
deletedAt={detail.servers.find((server) => server.server === srv)
|
||
?.server_deleted_at}
|
||
/>
|
||
</h3>
|
||
<div class="file-rows">
|
||
{#each files as f (f.id)}
|
||
<button
|
||
class="file-row"
|
||
disabled={detail.fileContentsExpired || !!f.snapshot_pending}
|
||
onclick={() => loadDiff(f)}
|
||
>
|
||
{#if f.snapshot_pending}<Badge variant="neutral" size="sm"
|
||
>{f.apply_state === 'cancelled'
|
||
? 'Snapshot not captured'
|
||
: 'Snapshot pending'}</Badge
|
||
>
|
||
{:else}<Badge variant={verbVariant(f.verb)} size="sm" uppercase
|
||
>{f.verb}</Badge
|
||
>{/if}
|
||
{#if f.apply_state !== 'applied'}<Badge
|
||
variant="neutral"
|
||
size="sm"
|
||
>{f.apply_state === 'pending'
|
||
? 'Queued'
|
||
: f.apply_state === 'applying'
|
||
? 'Needs inspection'
|
||
: 'Cancelled'}</Badge
|
||
>{/if}
|
||
<span class="path">{f.rel_path}</span>
|
||
<span class="sizes">
|
||
{#if f.size_before !== null}{f.size_before} B{/if}
|
||
{#if f.size_before !== null && f.size_after !== null}
|
||
→
|
||
{/if}
|
||
{#if f.size_after !== null}{f.size_after} B{/if}
|
||
</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
</details>
|
||
|
||
<OperationHistory events={detail.events} showOverview={false} />
|
||
<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
|
||
>
|
||
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
||
{:else if openFile}<DiffViewer
|
||
before={openFile.before}
|
||
after={openFile.after}
|
||
verb={openFile.verb}
|
||
relPath={openFile.relPath}
|
||
/>{/if}
|
||
</Dialog>
|
||
</div>
|
||
{:else if !loadError}
|
||
<div class="loading">Loading…</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-6);
|
||
}
|
||
a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.metadata {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 8px 20px;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.campaign-details {
|
||
flex-basis: 100%;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
}
|
||
.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;
|
||
}
|
||
.mono {
|
||
font-family: var(--font-mono);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.outcome {
|
||
padding: 20px 24px;
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface);
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.progress-heading,
|
||
.progress-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
.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);
|
||
}
|
||
progress {
|
||
display: block;
|
||
width: 100%;
|
||
height: 6px;
|
||
appearance: none;
|
||
border: 0;
|
||
border-radius: var(--radius-pill);
|
||
overflow: hidden;
|
||
background: var(--surface-raised);
|
||
}
|
||
progress::-webkit-progress-bar {
|
||
background: var(--surface-raised);
|
||
}
|
||
progress::-webkit-progress-value {
|
||
background: var(--accent-primary);
|
||
border-radius: var(--radius-pill);
|
||
}
|
||
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);
|
||
}
|
||
.attention {
|
||
color: var(--state-warning);
|
||
}
|
||
.section {
|
||
display: grid;
|
||
gap: 16px;
|
||
min-width: 0;
|
||
}
|
||
.section h2 {
|
||
font-size: var(--text-md);
|
||
margin: 0;
|
||
}
|
||
.filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.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);
|
||
}
|
||
.filters button:hover,
|
||
.filters .chosen {
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
}
|
||
.filters .chosen {
|
||
border-color: var(--border-strong);
|
||
}
|
||
.filters button span {
|
||
font-size: var(--text-xs);
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-tertiary);
|
||
}
|
||
.table-scroll {
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: var(--text-sm);
|
||
text-align: left;
|
||
}
|
||
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);
|
||
}
|
||
td {
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
vertical-align: middle;
|
||
}
|
||
.server-cell {
|
||
min-width: 160px;
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
.server-cell small {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-regular);
|
||
}
|
||
.actions-heading {
|
||
text-align: right;
|
||
}
|
||
.row-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
.text-button {
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 8px 0;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
.text-button:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
button:focus-visible,
|
||
summary:focus-visible {
|
||
outline: 2px solid var(--accent-primary);
|
||
outline-offset: 3px;
|
||
}
|
||
.expanded,
|
||
.detail-row {
|
||
background: var(--surface);
|
||
}
|
||
.expanded td {
|
||
border-bottom-color: transparent;
|
||
}
|
||
.detail-row td {
|
||
padding-top: 0;
|
||
}
|
||
.server-details {
|
||
display: grid;
|
||
gap: 16px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.server-details p {
|
||
margin: 0;
|
||
line-height: 1.6;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
dl {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px 32px;
|
||
margin: 0;
|
||
}
|
||
dt {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-tertiary);
|
||
margin-bottom: 4px;
|
||
}
|
||
dd {
|
||
margin: 0;
|
||
}
|
||
.detail-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
.empty-servers {
|
||
text-align: center;
|
||
padding: 32px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.err {
|
||
font-size: var(--text-sm);
|
||
color: var(--state-danger);
|
||
}
|
||
.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);
|
||
}
|
||
.files-section summary span {
|
||
margin-left: 12px;
|
||
color: var(--text-secondary);
|
||
font-weight: var(--weight-regular);
|
||
}
|
||
.file-group {
|
||
margin-top: 24px;
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.file-group h3 {
|
||
margin: 0;
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
.file-rows {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.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;
|
||
}
|
||
.file-row:hover:not(:disabled) {
|
||
background: var(--surface-raised);
|
||
}
|
||
.file-row:disabled {
|
||
cursor: default;
|
||
}
|
||
.path {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-sm);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.sizes {
|
||
margin-left: auto;
|
||
font-family: var(--font-numeric);
|
||
font-size: var(--text-xs);
|
||
color: var(--text-tertiary);
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.loading {
|
||
text-align: center;
|
||
padding: var(--space-16);
|
||
color: var(--text-tertiary);
|
||
}
|
||
@media (max-width: 720px) {
|
||
.outcome {
|
||
padding: 16px;
|
||
}
|
||
th,
|
||
td {
|
||
padding: 12px 8px;
|
||
}
|
||
.file-row {
|
||
flex-wrap: wrap;
|
||
}
|
||
.files-section summary span {
|
||
margin-left: 4px;
|
||
}
|
||
}
|
||
</style>
|