Redesign UI with new token system, primitives, and motion

Restraint-first design language: neutral gray surfaces + one blue accent
(cyan/amber/red reserved for state), 8pt spacing, softer radii, one
ambient + one raised shadow, standardized motion tokens. Alfa Slab One
demoted to BrandMark only; Inter carries all page copy.

New primitives (Button, Card, Badge, Stat, SectionHeader, Skeleton,
EmptyState, Tabs, Breadcrumbs, StatusPip, Field, Input, Select, Textarea)
plus motion utilities (tweenNumber, viewTransition) and a global toast
system. All eight route surfaces rewritten on top: Fleet gains an
overview hero, Server detail restructures into tabs, Deployments gets a
collapsible drift summary + per-server pipeline visual, and the
scripts/configs/plugins/betas flows adopt the new form primitives.

Sticky translucent nav with backdrop blur, view-transition cross-fades
between routes, staggered card entrance on Fleet, breathing status pip
halo, tabular-numeric player counts. Gmail-style g-key nav shortcuts.
Mobile stacks below 720px; prefers-reduced-motion neutralizes durations.
Legacy tokens preserved as aliases so any un-migrated code still renders.
This commit is contained in:
2026-09-14 19:48:48 -04:00
parent efd4a4fa66
commit a2d4a38925
34 changed files with 5093 additions and 3144 deletions
+467 -223
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from '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;
@@ -32,7 +37,6 @@
prior_version: number | null;
scripts: DepScript[];
}
interface DriftFile {
relPath: string;
size: number;
@@ -50,13 +54,15 @@
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
async function loadDeployments(): Promise<void> {
const res = await fetch('/api/deployments');
const body = (await res.json()) as { deployments: Deployment[] };
deployments = body.deployments;
}
async function scanDrift(): Promise<void> {
scanning = true;
try {
@@ -76,14 +82,22 @@
return { untracked: u, missing: m };
});
// 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 runDrift(): Promise<void> {
if (!drift || drift.totalDrift === 0) return;
const parts: string[] = [];
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
running = true;
flash = null;
running = true; flash = null;
try {
const res = await fetch('/api/drift', {
method: 'POST',
@@ -91,14 +105,8 @@
body: JSON.stringify({})
});
const body = (await res.json()) as {
ok: boolean;
error?: string;
result?: {
deploymentId: string | null;
totalRemoved: number;
totalRestored?: number;
totalServers: number;
};
ok: boolean; error?: string;
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
};
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
const removed = body.result?.totalRemoved ?? 0;
@@ -126,11 +134,6 @@
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
function chipClass(state: string): string {
return `chip chip-${state}`;
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
@@ -138,6 +141,13 @@
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
@@ -150,7 +160,6 @@
}
return `${d.scripts.length} scripts`;
}
// Legacy fallback: pre-multi-script rows still use the top-level cols.
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
@@ -159,261 +168,496 @@
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
const msg = `Rollback ${summary} on ${d.servers.length} server(s)?`;
if (!confirm(msg)) return;
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/deployments/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = `rollback deployment opened`;
flash = 'Rollback deployment opened';
await loadDeployments();
} 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) {
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.toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<h1>Deployments</h1>
<div class="page">
<SectionHeader
title="Deployments"
subtitle="Fleet-wide script deploys, drift reconciliation, and rollbacks."
/>
{#if loading}
<p class="empty">loading…</p>
{:else}
<section class="drift-panel">
<div class="drift-head">
<h2>Drift{drift ? ` · ${driftCounts.untracked} untracked, ${driftCounts.missing} missing` : ''}</h2>
<div class="drift-actions">
<button onclick={scanDrift} disabled={scanning || running}>{scanning ? 'scanning…' : 'rescan'}</button>
<button class="primary" onclick={runDrift} disabled={running || scanning || !drift || drift.totalDrift === 0}>
{running ? 'reconciling…' : 'reconcile drift'}
</button>
</div>
</div>
{#if drift === null}
<p class="empty">scan hasn't run yet</p>
{:else if drift.totalDrift === 0}
<p class="empty">inventory matches disk — no drift</p>
{:else}
{#each drift.servers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head mono">{s.server} · {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<span
class="drift-file mono {f.driftKind}"
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>[{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath}</span>
{/each}
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
<button
type="button"
class="drift-summary"
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
<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}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
</div>
{/each}
{/if}
{#if flash}<p class="flash">{flash}</p>{/if}
</section>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
<Button
variant="danger"
size="sm"
onclick={runDrift}
disabled={running || scanning || !drift || drift.totalDrift === 0}
loading={running}
>
Reconcile
</Button>
</div>
</button>
<section>
<h2>Active</h2>
{#if active.length === 0}
<p class="empty">nothing active</p>
{:else}
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
{/if}
</section>
{#if driftOpen}
<div class="drift-body">
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
/>
{#each filteredDriftServers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<span class="server-name">{s.server}</span>
<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.kind}] {f.relPath}
</Badge>
{/each}
</div>
</div>
{/each}
{: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}
<section>
<h2>History (7 days)</h2>
{#if history.length === 0}
<p class="empty">no completed deployments in the retention window</p>
{:else}
{#each history as d (d.id)}
{@render deployRow(d)}
{/each}
{/if}
</section>
{/if}
{#if flash}
<div class="flash">{flash}</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="Push scripts or open a beta to see deployments here."
/>
{: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 deployments 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>
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<a class="row" href="/deployments/{d.id}">
<div class="head">
<div class="head-l">
<a class="deploy-row" href="/deployments/{d.id}">
<div class="row-head">
<div class="row-head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target mono">{scriptSummary(d)}</span>
<span class="target">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<span class="tag rb">rolled back</span>
<Badge variant="neutral" size="sm">Rolled back</Badge>
{/if}
</div>
<div class="head-r">
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<button class="rb-btn" onclick={(e) => rollbackPromote(d, e)} disabled={rollingBack === d.id}>
{rollingBack === d.id ? 'rolling back…' : 'rollback'}
</button>
<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>
<div class="chips">
<!-- 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={chipClass(s.restart_state)} title={s.error ?? ''}>
{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
<span
class="node n-{s.restart_state}"
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
</span>
{/each}
</div>
<div class="id">{d.id}</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}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
{/if}
{/each}
</div>
</div>
</a>
{/snippet}
<style>
h1 {
color: var(--cream);
font-family: var(--font-display);
font-size: var(--text-3xl);
letter-spacing: var(--tracking-display);
line-height: var(--leading-tight);
margin: 0 0 28px;
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
h2 {
margin: var(--gap-section) 0 16px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
}
.row {
display: block;
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 18px 20px;
margin-bottom: 12px;
color: var(--ink-100);
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: center; gap: 12px; }
.head-l { display: flex; gap: 10px; align-items: baseline; }
.head-r { display: flex; gap: 10px; align-items: center; }
.action { font-family: var(--font-mono); font-size: var(--text-md); color: var(--cream); }
.target { color: var(--spark-500); font-size: var(--text-mono); }
.when { color: var(--ink-500); font-size: var(--text-mono); font-family: var(--font-mono); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
.tag.rb { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
.rb-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
}
.rb-btn:hover:not(:disabled) { border-color: var(--danger-500); }
.rb-btn:disabled { opacity: 0.5; cursor: default; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
padding: 3px 9px;
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
border: 1px solid transparent;
}
.chip-done {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
}
.chip-failed {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
}
.chip-pending {
background: rgba(255, 176, 32, 0.1);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
}
.chip-held {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
}
.chip-manual {
background: var(--bg-nav);
border-color: var(--border);
color: var(--ink-500);
}
.id {
color: var(--ink-700);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-base);
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
.drift-panel {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-panel);
margin-bottom: var(--gap-section);
/* 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-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.drift-head h2 { margin: 0; }
.drift-actions { display: flex; gap: 8px; }
.drift-actions button {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 8px 16px;
.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);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease-standard);
}
.drift-actions button:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
.drift-actions button.primary {
background: linear-gradient(180deg, var(--danger-500), #a01e39);
border-color: rgba(255, 48, 80, 0.6);
color: var(--cream);
font-weight: 600;
.drift-filter:focus {
outline: none;
border-color: var(--accent-primary);
}
.drift-actions button:disabled { opacity: 0.4; cursor: default; }
.drift-server {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 14px;
margin: 8px 0;
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 { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; }
.drift-files { display: flex; flex-wrap: wrap; gap: 6px; }
.drift-file {
.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);
}
.flash {
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
color: var(--state-online);
font-size: var(--text-sm);
}
/* 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(--r-sm);
font-size: var(--text-mono-sm);
border: 1px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--surface-sunken);
}
.drift-file.untracked {
background: rgba(255, 48, 80, 0.08);
border-color: rgba(255, 48, 80, 0.25);
color: var(--danger-500);
.node .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-quaternary);
}
.drift-file.missing {
background: rgba(255, 176, 32, 0.08);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
.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; }
}
.flash { color: var(--spark-400); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
.mono { font-family: var(--font-mono); }
</style>
+266 -182
View File
@@ -2,35 +2,28 @@
import { onMount, onDestroy } from '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';
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;
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;
}
interface ServerRow {
deployment_id: string;
server: string;
files_changed: number;
restart_state: string;
players: number | null;
checked_at: string | null;
restarted_at: string | null;
attempts: number;
error: string | null;
deployment_id: string; server: string; files_changed: number;
restart_state: string; players: number | null;
checked_at: string | null; restarted_at: string | null;
attempts: number; error: string | null;
}
interface Detail {
deployment: {
id: string;
action: string;
started_at: string;
finished_at: string | null;
triggered_by: string | null;
id: string; action: string; started_at: string;
finished_at: string | null; triggered_by: string | null;
};
servers: ServerRow[];
files: FileRow[];
@@ -60,8 +53,7 @@
openFile = { id: f.id, ...body };
}
// Group files by server for the layout
let byServer = $derived(() => {
let byServer = $derived.by(() => {
if (!detail) return new Map<string, FileRow[]>();
const m = new Map<string, FileRow[]>();
for (const f of detail.files) {
@@ -70,203 +62,295 @@
}
return m;
});
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
return v === 'added' ? 'online' :
v === 'updated' ? 'info' :
v === 'removed' ? 'danger' :
v === 'held' ? 'warning' :
'neutral';
}
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
}
</script>
{#if detail}
<h1>{detail.deployment.action}</h1>
<p class="meta">
started {detail.deployment.started_at}
{#if detail.deployment.finished_at} · finished {detail.deployment.finished_at}{/if}
· <code>{detail.deployment.id}</code>
</p>
{@const action = detail.deployment.action}
<div class="page">
<SectionHeader title={action} subtitle={detail.deployment.id}>
{#snippet crumbs()}
<Breadcrumbs items={[
{ label: 'Deployments', href: '/deployments' },
{ label: action }
]} />
{/snippet}
</SectionHeader>
<h2>Servers</h2>
<div class="server-grid">
{#each detail.servers as s (s.server)}
<div class="srv srv-{s.restart_state}">
<div class="name">{s.server}</div>
<div class="line">state: {s.restart_state}</div>
{#if s.players !== null}<div class="line">players: {s.players}</div>{/if}
{#if s.error}<div class="line err">{s.error}</div>{/if}
{#if s.restarted_at}<div class="line dim">restarted {s.restarted_at}</div>{/if}
<div class="line dim">attempts: {s.attempts}</div>
<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>
{/each}
</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 detail.servers as s (s.server)}
<div class="srv-row">
<div class="srv-l">
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
{#if s.error}
<span class="err">{s.error}</span>
{/if}
</div>
<div class="srv-r">
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
{#if s.players !== null && s.players > 0}
<span class="players">{s.players}p</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}
</div>
</div>
{/each}
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Files</h2>
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div>
{#if detail.files.length === 0}
<EmptyState title="No file changes recorded" description="This deployment didn't touch any tracked artifacts." />
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group">
<h3>{srv}</h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>
<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}
</section>
{#if openFile}
<div
class="diff-scrim"
onclick={() => (openFile = null)}
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
role="button"
tabindex="-1"
aria-label="Close diff"
>
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
<div class="diff-head">
<span class="diff-title">{openFile.relPath}</span>
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
</div>
<DiffViewer
before={openFile.before}
after={openFile.after}
verb={openFile.verb}
relPath={openFile.relPath}
/>
</div>
</div>
{/if}
</div>
<h2>Files</h2>
{#each [...byServer().entries()] as [srv, files] (srv)}
<div class="file-group">
<h3>{srv}</h3>
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
<span class="verb verb-{f.verb}">{f.verb}</span>
<code>{f.rel_path}</code>
<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>
{/each}
{#if openFile}
<div class="diff-scrim" onclick={() => (openFile = null)} onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} role="button" tabindex="-1">
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
<button class="close" onclick={() => (openFile = null)}>close</button>
<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />
</div>
</div>
{/if}
{:else}
<p class="empty">loading…</p>
<div class="loading">Loading…</div>
{/if}
<style>
h1 {
color: var(--cream);
font-family: var(--font-display);
font-size: var(--text-2xl);
letter-spacing: 1.5px;
line-height: var(--leading-tight);
margin: 0 0 8px;
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
h2 {
margin: var(--gap-section) 0 14px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
h3 {
margin: 18px 0 8px;
font-size: var(--text-md);
color: var(--cream);
font-family: var(--font-mono);
}
.meta {
color: var(--ink-500);
font-size: var(--text-mono);
margin: 0 0 28px;
font-family: var(--font-mono);
}
.server-grid {
.meta-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
}
.srv {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 16px;
.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);
}
.srv-done { border-color: rgba(124, 212, 255, 0.35); }
.srv-failed { border-color: rgba(255, 48, 80, 0.35); }
.srv-pending { border-color: rgba(255, 176, 32, 0.35); }
.srv-held { border-color: rgba(124, 192, 245, 0.3); }
.srv-manual { border-color: var(--ink-500); }
.name {
font-weight: 600;
margin-bottom: 8px;
color: var(--cream);
.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);
}
.line { font-size: var(--text-mono); color: var(--ink-300); font-family: var(--font-mono); line-height: var(--leading-mono); }
.line.dim { color: var(--ink-500); }
.line.err { color: var(--danger-500); }
.file-group { margin-bottom: 20px; }
.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: 12px;
gap: var(--space-3);
width: 100%;
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 8px 12px;
margin: 4px 0;
color: var(--ink-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;
text-align: left;
transition: border-color 0.15s;
transition: background-color var(--dur-fast) var(--ease-standard);
}
.file-row:hover { border-color: var(--accent); }
.verb {
padding: 3px 9px;
border-radius: var(--r-sm);
font-size: var(--text-xs);
.file-row:hover { background: var(--surface-raised); }
.file-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.path {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.8px;
border: 1px solid transparent;
}
.verb-added {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
}
.verb-updated {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
}
.verb-removed {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
}
.verb-held {
background: rgba(255, 176, 32, 0.1);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
.sizes {
margin-left: auto;
color: var(--ink-500);
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
code { font-family: var(--font-mono); color: var(--ink-100); font-size: var(--text-mono); }
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-base);
}
/* Diff modal */
.diff-scrim {
position: fixed; inset: 0;
background: rgba(6, 11, 22, 0.85);
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: grid;
place-items: center;
backdrop-filter: blur(4px);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.diff-modal {
max-width: 1000px;
width: 92%;
width: min(1000px, 92vw);
max-height: 92vh;
overflow: auto;
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-panel);
box-shadow: var(--shadow-panel);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-raised);
}
.close {
float: right;
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 14px;
font-family: var(--font-body);
.diff-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.diff-title {
font-family: var(--font-mono);
font-size: var(--text-sm);
cursor: pointer;
color: var(--text-primary);
word-break: break-all;
}
.close:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
</style>