Files
electric-cherry/src/routes/campaigns/[id]/+page.svelte
T
2026-10-17 09:54:01 -04:00

961 lines
36 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>