498 lines
18 KiB
Svelte
498 lines
18 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* Deploys tab: the recorded history of deploy runs for this stack (GET
|
|
* /api/stacks/{name}/deploys), rendered as a dense DataGrid. Each run is one
|
|
* grid row -- status, when, duration, trigger, and a merged summary column
|
|
* (container/build recap + option pills + a shortened error). Expanding a row
|
|
* reveals the LOG beneath it plus the run's details (containers, build/cache,
|
|
* images, options, a truncated notice, the last error line). All text
|
|
* composition lives in deploy-run-view.ts -- this component only fetches, binds,
|
|
* and lays the grid out.
|
|
*
|
|
* The log is fetched (GET .../deploys/{id}/log) only when a row expands, never
|
|
* while the list loads: a run's log can be large, and the list is meant to stay
|
|
* quick. buildDeployLogPanelState decides what that fetch should attempt -- a run
|
|
* already known to be missing its log file never triggers the request.
|
|
*
|
|
* `envId === null` is the LOCAL environment, not "nothing to ask for" (see the
|
|
* list route's doc comment): appendEnvParam() omits `env` when envId is falsy,
|
|
* and every LOCAL run is recorded with environmentId === null, so the grid loads
|
|
* unconditionally.
|
|
*/
|
|
import { toast } from 'svelte-sonner';
|
|
import {
|
|
Loader2,
|
|
Check,
|
|
X,
|
|
Clock,
|
|
Hand,
|
|
Timer,
|
|
Webhook,
|
|
Power,
|
|
AlertTriangle,
|
|
Trash2
|
|
} from 'lucide-svelte';
|
|
import { formatRelativeTime, formatDateTime } from '$lib/stores/settings';
|
|
import { appendEnvParam } from '$lib/stores/environment';
|
|
import { buildDeployRunView, buildDeployLogPanelState, deployTallyFromRuns, type DeployRun } from '$lib/utils/deploy-run-view';
|
|
import { DataGrid } from '$lib/components/data-grid';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { onDestroy } from 'svelte';
|
|
import { GripHorizontal } from 'lucide-svelte';
|
|
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
|
import LogViewer from '$lib/components/LogViewer.svelte';
|
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
|
|
|
interface Props {
|
|
stackName: string;
|
|
envId: number | null;
|
|
// Forwarded to LogViewer so an expanded run's log follows the caller's editor
|
|
// theme toggle instead of LogViewer's own 'dark' default.
|
|
theme?: 'light' | 'dark';
|
|
// Bumped by the parent after a deploy finishes so the history re-fetches.
|
|
reloadKey?: number;
|
|
// Reports the run tally to the parent for the tab badge (total + ok/failed split).
|
|
onTally?: (t: { total: number; ok: number; failed: number }) => void;
|
|
}
|
|
let { stackName, envId, theme = 'dark', reloadKey = 0, onTally }: Props = $props();
|
|
|
|
let runs = $state<DeployRun[]>([]);
|
|
let loading = $state(true);
|
|
let loadError = $state<string | null>(null);
|
|
let expandedKeys = $state<Set<unknown>>(new Set());
|
|
|
|
// Column sort. Default: newest first (the API's own order). startedAt/duration
|
|
// compare numerically; status/triggeredBy compare as strings.
|
|
let sortState = $state<{ field: string; direction: 'asc' | 'desc' }>({
|
|
field: 'startedAt',
|
|
direction: 'desc'
|
|
});
|
|
const sortedRuns = $derived.by(() => {
|
|
const dir = sortState.direction === 'desc' ? -1 : 1;
|
|
const numeric = sortState.field === 'startedAt' || sortState.field === 'duration';
|
|
return [...runs].sort((a, b) => {
|
|
let cmp: number;
|
|
if (numeric) {
|
|
const va = sortState.field === 'startedAt' ? (a.startedAt ? Date.parse(a.startedAt) : 0) : (a.duration ?? 0);
|
|
const vb = sortState.field === 'startedAt' ? (b.startedAt ? Date.parse(b.startedAt) : 0) : (b.duration ?? 0);
|
|
cmp = va - vb;
|
|
} else {
|
|
cmp = String((a as any)[sortState.field] ?? '').localeCompare(String((b as any)[sortState.field] ?? ''));
|
|
}
|
|
return cmp * dir;
|
|
});
|
|
});
|
|
|
|
/** One entry per run whose log has been requested. 'loading'/'loaded'/'missing'/'error'
|
|
* -- deliberately not reused for "not yet requested" (absent from the map), so a
|
|
* fresh row doesn't render as if a fetch had already failed. */
|
|
interface LogFetch {
|
|
status: 'loading' | 'loaded' | 'missing' | 'error';
|
|
text?: string;
|
|
error?: string;
|
|
}
|
|
let logs = $state<Map<number, LogFetch>>(new Map());
|
|
let confirmDeleteId = $state<number | null>(null);
|
|
let deletingIds = $state<Set<number>>(new Set());
|
|
|
|
// Fired by the grid when a row's expand chevron is clicked. Loads the log on
|
|
// open; nothing to do on collapse (the fetched text is kept for a re-open).
|
|
function onExpandChange(key: unknown, expanded: boolean) {
|
|
if (!expanded) return;
|
|
const run = runs.find((r) => r.id === key);
|
|
if (run) void loadLog(run);
|
|
}
|
|
|
|
// Whole-row click toggles expansion (the grid only wires the chevron itself),
|
|
// loading the log on open -- same effect as the chevron path.
|
|
function toggleRun(run: DeployRun) {
|
|
const s = new Set(expandedKeys);
|
|
if (s.has(run.id)) s.delete(run.id);
|
|
else {
|
|
s.add(run.id);
|
|
void loadLog(run);
|
|
}
|
|
expandedKeys = s;
|
|
}
|
|
|
|
/**
|
|
* Fetches a run's protocol text the first time its row opens. A prior 'error'
|
|
* result is retried on the next open (transient network failures shouldn't
|
|
* require a page reload to recover from); 'loading'/'loaded'/'missing' are all
|
|
* left alone -- there is nothing a second fetch would tell us that the first
|
|
* one (or the run's own details.logMissing) didn't already establish.
|
|
*/
|
|
async function loadLog(run: DeployRun) {
|
|
const existing = logs.get(run.id);
|
|
if (existing && existing.status !== 'error') return;
|
|
|
|
if (buildDeployLogPanelState(run).logMissing) {
|
|
logs = new Map(logs).set(run.id, { status: 'missing' });
|
|
return;
|
|
}
|
|
|
|
logs = new Map(logs).set(run.id, { status: 'loading' });
|
|
try {
|
|
const res = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/deploys/${run.id}/log`);
|
|
if (res.status === 404) {
|
|
logs = new Map(logs).set(run.id, { status: 'missing' });
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
logs = new Map(logs).set(run.id, { status: 'error', error: `Failed to load log (${res.status})` });
|
|
return;
|
|
}
|
|
const text = await res.text();
|
|
logs = new Map(logs).set(run.id, { status: 'loaded', text });
|
|
} catch {
|
|
logs = new Map(logs).set(run.id, { status: 'error', error: 'Failed to load log.' });
|
|
}
|
|
}
|
|
|
|
/** Removes both the database record and the on-disk log file (see the DELETE
|
|
* route's own doc comment) -- there is no "delete just the log" option, so a
|
|
* removed run disappears from the list entirely rather than turning into a
|
|
* metadata-only row nobody can act on. */
|
|
async function deleteRun(run: DeployRun) {
|
|
deletingIds = new Set(deletingIds).add(run.id);
|
|
try {
|
|
const res = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/deploys/${run.id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
toast.error(data?.error || 'Failed to delete deploy run');
|
|
return;
|
|
}
|
|
runs = runs.filter((r) => r.id !== run.id);
|
|
const s = new Set(expandedKeys);
|
|
s.delete(run.id);
|
|
expandedKeys = s;
|
|
const l = new Map(logs);
|
|
l.delete(run.id);
|
|
logs = l;
|
|
toast.success('Deploy run deleted');
|
|
} catch {
|
|
toast.error('Failed to delete deploy run');
|
|
} finally {
|
|
const d = new Set(deletingIds);
|
|
d.delete(run.id);
|
|
deletingIds = d;
|
|
confirmDeleteId = null;
|
|
}
|
|
}
|
|
|
|
async function loadRuns() {
|
|
loading = true;
|
|
loadError = null;
|
|
try {
|
|
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
loadError = data?.error || `Failed to load deploy history (${res.status})`;
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
runs = Array.isArray(data?.runs) ? data.runs : [];
|
|
} catch {
|
|
loadError = 'Failed to load deploy history.';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Load on mount and whenever the parent bumps reloadKey (a deploy finished).
|
|
$effect(() => {
|
|
void reloadKey;
|
|
loadRuns();
|
|
});
|
|
|
|
// Keep the parent's tab badge in sync: total + a success/failure split. Only
|
|
// report once a load has settled -- reporting the initial empty `runs` while
|
|
// still loading would momentarily clobber the parent's own pre-fetched tally
|
|
// (making the badge flicker to nothing when the tab is first opened).
|
|
$effect(() => {
|
|
if (loading) return;
|
|
onTally?.(deployTallyFromRuns(runs));
|
|
});
|
|
|
|
// Drag-resizable log height (px), shared by every expanded row's LogViewer and
|
|
// persisted per browser. A handle below the log drags this between the bounds.
|
|
const LOG_HEIGHT_KEY = 'dockhand-deploy-log-height';
|
|
const LOG_MIN = 120, LOG_MAX = 720;
|
|
let logHeight = $state(
|
|
typeof localStorage !== 'undefined'
|
|
? clampLog(Number(localStorage.getItem(LOG_HEIGHT_KEY)) || 256)
|
|
: 256
|
|
);
|
|
let dragging = $state(false);
|
|
let dragStartY = 0;
|
|
let dragStartH = 0;
|
|
|
|
function clampLog(h: number): number {
|
|
return Math.min(LOG_MAX, Math.max(LOG_MIN, h));
|
|
}
|
|
|
|
function startLogResize(e: MouseEvent) {
|
|
e.preventDefault();
|
|
dragging = true;
|
|
dragStartY = e.clientY;
|
|
dragStartH = logHeight;
|
|
window.addEventListener('mousemove', onLogResize);
|
|
window.addEventListener('mouseup', endLogResize);
|
|
}
|
|
function onLogResize(e: MouseEvent) {
|
|
if (!dragging) return;
|
|
logHeight = clampLog(dragStartH + (e.clientY - dragStartY));
|
|
}
|
|
function endLogResize() {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
window.removeEventListener('mousemove', onLogResize);
|
|
window.removeEventListener('mouseup', endLogResize);
|
|
try {
|
|
localStorage.setItem(LOG_HEIGHT_KEY, String(logHeight));
|
|
} catch { /* private mode - keep the in-memory value */ }
|
|
}
|
|
onDestroy(() => {
|
|
window.removeEventListener('mousemove', onLogResize);
|
|
window.removeEventListener('mouseup', endLogResize);
|
|
});
|
|
|
|
// Badge styles mirror the Schedules execution-history (getStatusBadge/getTriggerBadge)
|
|
// so the Deploys grid reads the same as the rest of the app.
|
|
function statusMeta(status: string) {
|
|
switch (status) {
|
|
case 'success':
|
|
return { cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: Check, spin: false };
|
|
case 'failed':
|
|
return { cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', icon: X, spin: false };
|
|
case 'running':
|
|
return { cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400', icon: Loader2, spin: true };
|
|
default:
|
|
return { cls: 'bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400', icon: AlertTriangle, spin: false };
|
|
}
|
|
}
|
|
|
|
function triggerMeta(triggeredBy: string) {
|
|
switch (triggeredBy) {
|
|
case 'cron':
|
|
return { icon: Timer, cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' };
|
|
case 'webhook':
|
|
return { icon: Webhook, cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' };
|
|
case 'startup':
|
|
return { icon: Power, cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' };
|
|
default:
|
|
return { icon: Hand, cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if loadError}
|
|
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
|
<AlertTriangle class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
|
<p class="text-sm text-muted-foreground">{loadError}</p>
|
|
</div>
|
|
{:else}
|
|
<DataGrid
|
|
data={sortedRuns}
|
|
keyField="id"
|
|
gridId="deploys"
|
|
{loading}
|
|
expandable
|
|
bind:expandedKeys
|
|
{onExpandChange}
|
|
{sortState}
|
|
onSortChange={(state) => (sortState = state)}
|
|
onRowClick={(run, e) => {
|
|
// Ignore clicks that originated on an interactive control (delete popover).
|
|
if ((e.target as HTMLElement)?.closest('button')) return;
|
|
toggleRun(run);
|
|
}}
|
|
class="border-none"
|
|
wrapperClass="border rounded-lg"
|
|
>
|
|
{#snippet emptyState()}
|
|
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
|
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
|
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
|
|
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet cell(column, run, _rowState)}
|
|
{@const view = buildDeployRunView(run)}
|
|
{#if column.id === 'status'}
|
|
{@const meta = statusMeta(run.status)}
|
|
{@const StatusIcon = meta.icon}
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<Badge variant="default" class={meta.cls}>
|
|
<StatusIcon class="h-3.5 w-3.5 {meta.spin ? 'animate-spin' : ''}" />
|
|
</Badge>
|
|
<span class="truncate text-xs">{view.statusLabel}</span>
|
|
</span>
|
|
{:else if column.id === 'when'}
|
|
{#if run.startedAt}
|
|
<span class="flex w-full items-center justify-between gap-2">
|
|
<span>{formatRelativeTime(run.startedAt)}</span>
|
|
<span class="text-muted-foreground tabular-nums">{formatDateTime(run.startedAt)}</span>
|
|
</span>
|
|
{:else}
|
|
<span>—</span>
|
|
{/if}
|
|
{:else if column.id === 'duration'}
|
|
<span class="block text-right text-muted-foreground tabular-nums">{view.duration}</span>
|
|
{:else if column.id === 'trigger'}
|
|
{@const tmeta = triggerMeta(run.triggeredBy)}
|
|
{@const TrigIcon = tmeta.icon}
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<Badge variant="default" class={tmeta.cls}>
|
|
<TrigIcon class="h-3.5 w-3.5" />
|
|
</Badge>
|
|
<span class="truncate">{view.trigger}</span>
|
|
</span>
|
|
{:else if column.id === 'summary'}
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<span class="shrink-0 text-muted-foreground">{view.containerSummary} · {view.buildStatus}</span>
|
|
{#if view.optionChips.length > 0}
|
|
<span class="flex shrink-0 flex-wrap gap-1">
|
|
{#each view.optionChips as chip}
|
|
<span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
|
|
{/each}
|
|
</span>
|
|
{/if}
|
|
{#if view.errorSummary}
|
|
<span class="min-w-0 truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
|
|
{/if}
|
|
</div>
|
|
{:else if column.id === 'actions'}
|
|
{@const panelState = buildDeployLogPanelState(run)}
|
|
{#if panelState.deletable}
|
|
<div class="flex justify-end">
|
|
<ConfirmPopover
|
|
open={confirmDeleteId === run.id}
|
|
action="Delete"
|
|
itemType="deploy run"
|
|
title="Delete this run"
|
|
position="left"
|
|
disabled={deletingIds.has(run.id)}
|
|
onConfirm={() => deleteRun(run)}
|
|
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
|
|
>
|
|
{#snippet children({ open })}
|
|
{#if deletingIds.has(run.id)}
|
|
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
{:else}
|
|
<Trash2 class="h-3.5 w-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
|
{/if}
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet expandedRow(run, _rowState)}
|
|
{@const view = buildDeployRunView(run)}
|
|
{@const panelState = buildDeployLogPanelState(run)}
|
|
{@const logEntry = logs.get(run.id)}
|
|
<div class="w-full p-4 pl-12 shadow-inner bg-muted text-xs">
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
|
<div>
|
|
<div class="text-muted-foreground">Containers</div>
|
|
<div>{view.containerSummary}</div>
|
|
{#if view.containerNames.length > 0}
|
|
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
{#each view.containerNames as cname}
|
|
<span class="inline-flex items-center gap-1">
|
|
<ContainerIcon image="" name={cname} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
|
{cname}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<div class="text-muted-foreground">Build</div>
|
|
<div>{view.buildStatus}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-muted-foreground">Triggered by</div>
|
|
<div>{view.trigger}</div>
|
|
</div>
|
|
</div>
|
|
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
|
|
<div class="mt-2 space-y-1">
|
|
{#if view.imagesBuilt.length > 0}
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span class="text-muted-foreground">Built:</span>
|
|
{#each view.imagesBuilt as img}
|
|
<span class="inline-flex items-center gap-1">
|
|
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
|
{img}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if view.imagesPulled.length > 0}
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span class="text-muted-foreground">Pulled:</span>
|
|
{#each view.imagesPulled as img}
|
|
<span class="inline-flex items-center gap-1">
|
|
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
|
{img}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div class="mt-3">
|
|
{#if panelState.truncated}
|
|
<div class="mb-1">
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
|
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
{#if panelState.logMissing || logEntry?.status === 'missing'}
|
|
<!-- A confirmed-gone log file reads the same as an empty one if handed to
|
|
LogViewer with logs="" -- the two mean opposite things, so this case
|
|
gets its own message. -->
|
|
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
|
|
Protocol no longer available.
|
|
</div>
|
|
{:else if logEntry?.status === 'error'}
|
|
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
|
|
{logEntry.error}
|
|
</div>
|
|
{:else}
|
|
<div style="height: {logHeight}px">
|
|
<LogViewer
|
|
logs={logEntry?.text ?? ''}
|
|
loading={logEntry?.status !== 'loaded'}
|
|
title={`${stackName}-deploy-${run.id}`}
|
|
autoRefresh={false}
|
|
autoScroll={false}
|
|
class="h-full"
|
|
{theme}
|
|
/>
|
|
</div>
|
|
<!-- Drag up/down to resize the log height (persisted). -->
|
|
<div
|
|
class="group flex h-2 cursor-row-resize items-center justify-center rounded-b bg-zinc-200 transition-colors hover:bg-blue-400 dark:bg-zinc-700 dark:hover:bg-blue-500 {dragging ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
|
onmousedown={startLogResize}
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
tabindex="0"
|
|
title="Drag to resize"
|
|
>
|
|
<GripHorizontal class="h-3 w-3 text-white opacity-0 transition-opacity group-hover:opacity-100 {dragging ? 'opacity-100' : ''}" />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
</DataGrid>
|
|
{/if}
|