Files
electric-cherry/src/routes/campaigns/+page.svelte
T

718 lines
31 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 { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import type { DriftPlan, DriftResult } from '$lib/drift-plan.js';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface ServerRow {
server: string;
restart_state: string;
files_changed: number;
players: number | null;
error: string | null;
}
interface DepScript {
deployment_id: string;
script_id: number;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface Deployment {
id: string;
action: string;
started_at: string;
finished_at: string | null;
triggered_by: string | null;
servers: ServerRow[];
script_id: number | null;
script_version_id: number | null;
prior_version_id: number | null;
beta_id: number | null;
rolled_back_by: string | null;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
scripts: DepScript[];
}
interface DriftFile {
relPath: string;
size: number;
sha256: string;
kind: 'script' | 'config' | 'plugin' | 'baseline';
driftKind: 'untracked' | 'missing' | 'replace';
}
interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[]; scannedAt: string }
let loadError = $state('');
let limit = $state(25);
let driftLimit = $state(25);
$effect(() => { driftFilter; driftLimit = 25; });
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let drift = $state<DriftPreview | null>(null);
let scanning = $state(false);
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
let flashError = $state(false);
let flashId = $state('');
let selected = $state<string[]>([]);
let reviewOpen = $state(false);
let plan = $state<DriftPlan | null>(null);
let planError = $state('');
let reviewing = $state(false);
let reviewAll = $state(false);
let result = $state<DriftResult | null>(null);
const skipped = $derived(result?.perServer.filter(server => server.skipped.length) ?? []);
let driftError = $state('');
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
$effect(() => { driftFilter = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
function saveContext() {
const url = new URL($page.url);
if (driftFilter) url.searchParams.set('server', driftFilter); else url.searchParams.delete('server');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function loadCampaigns(): Promise<void> {
try {
const res = await fetch('/api/campaigns');
if (!res.ok) throw new Error('Could not refresh campaigns.');
deployments = (await res.json()).deployments; loadError = '';
} catch (error) { loadError = (error as Error).message; }
finally { loading = false; }
}
async function scanDrift(): Promise<void> {
if (scanning) return;
scanning = true;
try {
const res = await fetch('/api/drift');
if (!res.ok) throw new Error('Could not scan drift.');
drift = (await res.json()) as DriftPreview;
selected = selected.filter(name => drift!.servers.some(server => server.server === name));
driftError = '';
} catch (error) { driftError = (error as Error).message;
} finally {
scanning = false;
}
}
let driftCounts = $derived.by(() => {
if (!drift) return { untracked: 0, missing: 0, replace: 0 };
let u = 0, m = 0, r = 0;
for (const s of drift.servers) for (const f of s.files) {
if (f.driftKind === 'untracked') u++; else if (f.driftKind === 'replace') r++; else m++;
}
return { untracked: u, missing: m, replace: r };
});
// Auto-open on first scan if there's drift; leave user's toggle alone after.
let firstScan = $state(true);
$effect(() => {
if (drift && firstScan) {
firstScan = false;
if (drift.totalDrift > 0) driftOpen = true;
}
});
async function reviewDrift(all: boolean, targets = selected): Promise<void> {
reviewOpen = true; reviewing = true; plan = null; planError = ''; reviewAll = all;
try {
const response = await fetch('/api/drift', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dryRun: true, servers: all ? undefined : targets }) });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not prepare the reconciliation plan.');
plan = body.plan;
} catch (error) { planError = (error as Error).message; }
finally { reviewing = false; }
}
async function runDrift(): Promise<void> {
if (!plan || running) return;
running = true; planError = ''; result = null;
try {
const res = await fetch('/api/drift', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: reviewAll ? undefined : plan.servers, expectedFingerprint: plan.fingerprint })
});
const body = await res.json();
if (!res.ok || !body.ok) { if (body.plan) plan = body.plan; throw new Error(body.error ?? 'Reconciliation failed.'); }
result = body.result; reviewOpen = false;
await Promise.all([loadCampaigns(), scanDrift()]);
} catch (error) { planError = (error as Error).message; } finally {
running = false;
}
}
onMount(() => { void scanDrift(); return pollVisible(loadCampaigns, 5000); });
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
function scriptSummary(d: Deployment): string {
if (d.scripts && d.scripts.length > 0) {
if (d.scripts.length === 1) {
const s = d.scripts[0];
return `${s.script_name ?? '(deleted)'} v${s.script_version ?? '?'}`;
}
return `${d.scripts.length} scripts`;
}
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
rollingBack = d.id;
flash = null; flashId = d.id; flashError = false;
try {
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !body.ok) { flashError = true; flash = body.error ?? 'Rollback failed'; return; }
flash = 'Rollback started';
await loadCampaigns();
} catch (error) { flashError = true; flash = (error as Error).message; } finally {
rollingBack = null;
}
}
// History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>();
for (const d of history.slice(0,limit)) {
const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
}
return Array.from(groups.entries());
});
// Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived(
drift?.servers.filter((s) =>
!driftFilter || (s.server + ' ' + serverTitle(s.server)).toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader
title="Campaigns"
subtitle="Track file campaigns, reconcile drift, and review rollbacks."
/>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
{#if driftError}<div class="ui-error" role="alert">{driftError}<Button size="sm" onclick={scanDrift} disabled={scanning}>Retry scan</Button></div>{/if}
<div class="drift-summary">
<button class="drift-summary-l" aria-expanded={driftOpen} aria-controls="drift-body" onclick={() => driftOpen = !driftOpen}>
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span>
{#if drift === null}
<Badge variant="neutral" size="sm">Not scanned</Badge>
{:else if drift.totalDrift === 0}
<Badge variant="online" size="sm">In sync</Badge>
{:else}
{#if driftCounts.untracked > 0}
<Badge variant="danger" size="sm">{driftCounts.untracked} untracked</Badge>
{/if}
{#if driftCounts.missing > 0}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if}
{#if driftCounts.replace > 0}
<Badge variant="warning" size="sm">{driftCounts.replace} to replace</Badge>
{/if}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
</button>
<div class="drift-summary-r">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
<Button
variant="danger"
size="sm"
onclick={() => reviewDrift(false)}
disabled={running || scanning || reviewing || !selected.length}
loading={running}
>
Review {selected.length} selected
</Button>
<Button size="sm" variant="ghost" onclick={() => reviewDrift(true)} disabled={running || scanning || reviewing || !drift?.totalDrift}>Review entire fleet</Button>
</div>
</div>
{#if driftOpen}
<div class="drift-body" id="drift-body">
{#if drift}<p class="scan-context">Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.</p>{/if}
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter" aria-label="Filter drift by server"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
oninput={event => { driftFilter = event.currentTarget.value; saveContext(); }}
/>
<div class="selection-actions"><Button size="sm" onclick={() => selected = [...new Set([...selected, ...filteredDriftServers.map(server => server.server)])]}>Select visible</Button><Button size="sm" variant="ghost" disabled={!selected.length} onclick={() => selected = []}>Clear selection</Button><span>{selected.length} selected</span></div>
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<label class="server-name"><input type="checkbox" checked={selected.includes(s.server)} onchange={event => selected = event.currentTarget.checked ? [...selected, s.server] : selected.filter(name => name !== s.server)} /> <ServerIdentity name={s.server} /></label>
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<Badge
variant={f.driftKind === 'untracked' ? 'danger' : 'warning'}
size="sm"
mono
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>
[{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath}
</Badge>
{/each}
</div>
</div>
{/each}
<ListFooter total={filteredDriftServers.length} bind:limit={driftLimit} label="servers" />
{:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else}
<EmptyState title="Scan hasn't run yet" description="Click Rescan to look for drift." />
{/if}
</div>
{/if}
{#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}>
<strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong>
<p>{result.totalRemoved} files removed; {result.totalRestored} installed. {result.totalServers ? 'Affected servers restart when empty, then health is verified.' : skipped.length ? 'No changes could be applied. Review the reasons below.' : 'The reviewed servers already match the intended files.'}</p>
{#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View campaign →</a>{/if}
{#each skipped as server}<div><a href="/servers/{encodeURIComponent(server.server)}?tab=diagnostics"><ServerIdentity name={server.server} /></a><ul>{#each server.skipped as reason}<li>{reason}</li>{/each}</ul></div>{/each}
{#if skipped.length}<Button onclick={() => reviewDrift(false, skipped.map(server => server.server))}>Review unresolved servers</Button>{/if}
</div>{/if}
</Card>
<!-- Active pipelines -->
<section class="section">
<div class="section-head">
<h2>Active</h2>
<Badge variant="neutral" size="sm">{active.length}</Badge>
</div>
{#if active.length === 0}
<EmptyState
title="Nothing in flight"
description="Promote a beta to roll out tested files to the fleet."
/>
{:else}
<div class="deploy-list">
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
{/if}
</section>
<!-- History timeline -->
<section class="section">
<div class="section-head">
<h2>History</h2>
<Badge variant="neutral" size="sm">{history.length}</Badge>
</div>
{#if history.length === 0}
<EmptyState
title="No completed campaigns in the retention window"
description="Retention is 7 days by default."
/>
{:else}
{#each historyByDay as [day, rows] (day)}
<div class="day-group">
<div class="day-label">{day}</div>
<div class="deploy-list">
{#each rows as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
</div>
{/each}
{/if}
</section>
<ListFooter total={history.length} bind:limit onchange={saveContext} label="campaigns" />
{/if}
</div>
<Dialog bind:open={reviewOpen} title="Review reconciliation" busy={running || reviewing} wide>
{#if planError}<p class="ui-error" role="alert">{planError}</p>{/if}
{#if reviewing}<p role="status">Comparing current files with their intended versions…</p>
{:else if plan}
<p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p>
<p>Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.</p>
<p class="scan-context">Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.</p>
<div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div>
{/if}
{#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet}
</Dialog>
{#snippet deployRow(d: Deployment)}
<article class="deploy-row">
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
<div class="row-head">
<div class="row-head-l">
<a class="action" href="/campaigns/{d.id}">{d.action} campaign <span class="sr-only">{d.id}</span></a>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<Badge variant="neutral" size="sm">Rolled back</Badge>
{/if}
</div>
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<Button
variant="danger"
size="sm"
onclick={(e) => rollbackPromote(d, e)}
disabled={rollingBack === d.id}
loading={rollingBack === d.id}
>
Rollback
</Button>
{/if}
<span class="when">{ago(d.started_at)}</span>
</div>
</div>
<!-- Mini pipeline visual: one dot per server, colored by state. -->
<div class="pipeline" aria-label="Server progress">
{#each d.servers as s (s.server)}
<span
class="node n-{s.restart_state}"
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<a class="node-label" href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</a><StatusBadge state={s.restart_state} />
</span>
{/each}
</div>
<div class="row-foot">
<span class="id">{d.id}</span>
<div class="counts">
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
<span class="state-count">{n} <StatusBadge {state} size="sm" /></span>
{/if}
{/each}
</div>
</div>
</article>
{/snippet}
<style>
.scan-context { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.selection-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 12px; }
.result { padding: 16px; margin-top: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); line-height: 1.6; }
.result-error { border-color: var(--state-warning); }
.result p { margin-block: 8px; }
.result a { color: var(--accent-primary-hover); }
.plan-files { display: grid; gap: 20px; }
.plan-file { display: grid; gap: 8px; padding-block: 12px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
.plan-file span, .plan-file details { font-size: 13px; color: var(--text-secondary); }
.server-name { display: flex; align-items: center; gap: 10px; }
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
/* Drift summary bar */
.drift-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
font: inherit;
}
.drift-summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-md); }
.drift-summary-l {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
flex-wrap: wrap;
}
.drift-summary-r {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.drift-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.drift-servers {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.chev {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
transition: transform var(--dur-base) var(--ease-standard);
display: inline-block;
}
.chev.open { transform: rotate(90deg); }
.drift-body {
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.drift-filter {
width: 100%;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
transition: border-color var(--dur-fast) var(--ease-standard);
}
.drift-filter:focus {
outline: none;
border-color: var(--accent-primary);
}
.drift-server {
padding: var(--space-3) var(--space-4);
margin: var(--space-2) 0;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.drift-server-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.server-name { color: var(--text-primary); }
.server-count { color: var(--text-tertiary); font-size: var(--text-xs); }
.drift-files {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
/* Sections */
.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);
letter-spacing: var(--tracking-tight);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.day-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.deploy-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Deployment rows */
.deploy-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.deploy-row:hover {
background: var(--surface-raised);
border-color: var(--border-strong);
}
.deploy-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.row-head-l {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.row-head-r {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.action {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.target {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* Pipeline visual */
.pipeline {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.node {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--surface-sunken);
}
.node .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-quaternary);
}
.n-done .dot { background: var(--state-online); }
.n-pending .dot { background: var(--state-warning); }
.n-failed .dot { background: var(--state-danger); }
.n-held .dot { background: var(--blue-300); }
.n-manual .dot { background: var(--text-tertiary); }
.node-label { color: var(--text-secondary); }
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.id {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-quaternary);
}
.counts {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.row-head, .row-foot { flex-direction: column; align-items: flex-start; }
.drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; }
}
.drift-summary-l { padding:0; background:transparent; border:0; color:inherit; text-align:left; cursor:pointer; }
.state-count { display:inline-flex; align-items:center; gap:5px; }
.action { color:var(--text-primary); text-decoration:underline; text-underline-offset:4px; }
</style>