Simplify campaign detail UI and progress states
This commit is contained in:
@@ -1,22 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||||
import Card from '$ui/components/primitives/Card.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 { 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 {
|
||||
id: number; server: string; verb: string; rel_path: string;
|
||||
@@ -54,14 +53,34 @@
|
||||
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);
|
||||
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
|
||||
const withdrawn = $derived(detail?.servers.filter(server => ['skipped', 'cancelled'].includes(server.restart_state)).length ?? 0);
|
||||
const unresolved = $derived(detail?.servers.filter(server => !server.server_deleted_at && !['done', 'skipped', 'cancelled'].includes(server.restart_state)).length ?? 0);
|
||||
const deletedIncomplete = $derived(detail?.servers.filter(server => server.server_deleted_at && server.restart_state !== 'done').length ?? 0);
|
||||
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
|
||||
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
|
||||
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
|
||||
let filter = $state<'all' | CampaignServerGroup>('all');
|
||||
let expandedServers = $state<string[]>([]);
|
||||
let copyStatus = $state('');
|
||||
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;
|
||||
@@ -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));
|
||||
|
||||
@@ -132,7 +151,7 @@
|
||||
{#if detail}
|
||||
{@const action = detail.deployment.action}
|
||||
<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()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Campaigns', href: '/campaigns' },
|
||||
@@ -140,70 +159,103 @@
|
||||
]} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback="/campaigns" />
|
||||
{#if detail.fileContentsExpired}<p role="status">File contents have expired under the retention policy. File comparisons and rollback are unavailable.</p>{/if}
|
||||
<div class="metadata">
|
||||
<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">
|
||||
<h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{withdrawn ? ` · ${withdrawn} withdrawn` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2>
|
||||
<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>
|
||||
{#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>
|
||||
{:else if withdrawn}<p>Withdrawn changes will not be activated by this campaign.</p>
|
||||
{:else}<p>Every recorded server completed activation.</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} 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>
|
||||
|
||||
{#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}
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="meta-grid">
|
||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
||||
<div class="kv"><span class="k">Finished</span><span class="v mono">{fmt(detail.deployment.finished_at)}</span></div>
|
||||
<div class="kv"><span class="k">Triggered by</span><span class="v mono">{detail.deployment.triggered_by ?? '—'}</span></div>
|
||||
<div class="kv"><span class="k">Servers</span><span class="v mono">{detail.servers.length}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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>{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 class="section">
|
||||
<div class="section-head">
|
||||
<h2>Files</h2>
|
||||
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
|
||||
</div>
|
||||
<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}
|
||||
@@ -228,9 +280,9 @@
|
||||
</div>
|
||||
{/each}
|
||||
{/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>
|
||||
{#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…" />
|
||||
@@ -242,136 +294,71 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.watchdog-source { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); }.watchdog-source a { color: var(--accent-primary-hover); }
|
||||
.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; }
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-16);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.kv .k {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.kv .v { font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.server-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.srv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.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 */
|
||||
|
||||
.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-mono); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
|
||||
@media (max-width: 720px) {
|
||||
.srv-row { align-items: flex-start; flex-direction: column; }
|
||||
.srv-l, .file-row { flex-wrap: wrap; }
|
||||
|
||||
.outcome { padding: 16px; }
|
||||
th, td { padding: 12px 8px; }
|
||||
.file-row { flex-wrap: wrap; }
|
||||
.files-section summary span { margin-left: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user