feat(apps): per-workload deploy history, rollback, and resource metrics

Two additions to the app detail page, each backed by a per-workload
endpoint.

Deploy history + rollback:
- New deploy_history table — a structured, version-pinned ledger of every
  dispatch (success AND failure), distinct from the free-text event_log.
  Recorded at the single DispatchPlugin choke point so every source kind
  is covered. The raw deploy error is never persisted (it can carry
  registry-auth / compose-stdout secrets) — only a generic marker, with
  detail going to slog. Pruned to the newest N per workload; cascade-
  deleted with the workload.
- GET /api/workloads/{id}/deploys lists the ledger; POST .../rollback
  (admin) replays a prior successful deploy's pinned reference as a
  rollback-reason dispatch. Phase 1 is image-source only (RollbackCapable);
  git-built sources need checkout-by-commit, a later phase.
- DeployHistoryPanel.svelte renders the ledger with confirm-gated rollback.

Per-workload metrics:
- ListContainerStatsSamplesByWorkload joins the existing container stats
  samples through the containers index; GET /api/workloads/{id}/stats/history
  aggregates CPU/memory per timestamp across the workload's containers.
- WorkloadMetricsPanel.svelte reuses ResourceChart (CPU% + memory MiB,
  windowed, 15s poll).

en/ru i18n added with parity. Tests: store CRUD + cascade + workload-scoped
join, deployer recording (incl. secret-non-leak on failure), API rollback
guards, and per-timestamp aggregation. Plans under docs/plans/.
This commit is contained in:
2026-06-19 16:22:12 +03:00
parent c8e71a0c34
commit 0c4c338bfe
23 changed files with 1828 additions and 0 deletions
+60
View File
@@ -938,6 +938,66 @@ export function deployPluginWorkload(
return post(`/api/workloads/${id}/deploy`, body ?? {});
}
// ── Deploy history + rollback ───────────────────────────────────────
// Structured, version-pinned ledger of every deploy dispatch (success and
// failure). `rollbackable` is computed server-side: a successful deploy of a
// source kind that supports reference-pinned redeploy (image today).
export interface DeployHistoryEntry {
id: number;
workload_id: string;
source_kind: string;
reference: string;
reason: string;
triggered_by: string;
note: string;
outcome: 'success' | 'failure';
error: string;
started_at: string;
finished_at: string;
rollbackable: boolean;
}
export function fetchWorkloadDeploys(
id: string,
params?: { limit?: number; offset?: number },
signal?: AbortSignal
): Promise<DeployHistoryEntry[]> {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', String(params.limit));
if (params?.offset) query.set('offset', String(params.offset));
const qs = query.toString();
return get<DeployHistoryEntry[]>(`/api/workloads/${id}/deploys${qs ? `?${qs}` : ''}`, signal);
}
export function rollbackWorkload(
id: string,
deployId: number
): Promise<{ workload_id: string; reference: string; rollback_of: number; triggered_by: string }> {
return post(`/api/workloads/${id}/rollback`, { deploy_id: deployId });
}
// ── Per-workload metrics history ────────────────────────────────────
// CPU% and memory (bytes) summed across the workload's containers, one
// point per sampled timestamp. Empty when stats collection is off / Docker
// was down / the workload is new.
export interface WorkloadStatsPoint {
ts: number;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
}
export function fetchWorkloadStatsHistory(
id: string,
window = '2h',
signal?: AbortSignal
): Promise<WorkloadStatsPoint[]> {
return get<WorkloadStatsPoint[]>(
`/api/workloads/${id}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export function listHookKinds(signal?: AbortSignal): Promise<import('./types').HookKinds> {
return get<import('./types').HookKinds>('/api/hooks/kinds', signal);
}
@@ -0,0 +1,190 @@
<script lang="ts">
/**
* DeployHistoryPanel
*
* Per-workload structured deploy ledger (success + failure) with one-click
* rollback. This is the actionable sibling of the free-text activity
* timeline: each row carries a version-pinned `reference` the rollback
* action replays. The "Roll back" affordance only appears on rows the
* server marked `rollbackable` (a successful deploy of a reference-pinned
* source kind — image today); the server re-checks on POST, so the button
* is a convenience, not the authority.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { toasts } from '$lib/stores/toast';
import { fmt } from '$lib/format/datetime';
import StatusBadge from './StatusBadge.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconRestart, IconRefresh } from './icons';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
let deploys = $state<api.DeployHistoryEntry[]>([]);
let loading = $state(true);
let error = $state('');
let rollingBack = $state(false);
let confirmEntry = $state<api.DeployHistoryEntry | null>(null);
async function load(): Promise<void> {
loading = true;
error = '';
try {
deploys = await api.fetchWorkloadDeploys(workloadId, { limit: 50 });
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever workloadId changes — the parent reuses this instance
// across /apps/A → /apps/B navigation.
$effect(() => {
const _ = workloadId;
load();
});
async function doRollback(entry: api.DeployHistoryEntry): Promise<void> {
if (rollingBack) return;
rollingBack = true;
try {
await api.rollbackWorkload(workloadId, entry.id);
toasts.success($t('apps.detail.deployHistory.rolledBack', { ref: shortRef(entry.reference) }));
await load();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.deployHistory.rollbackFailed'));
} finally {
rollingBack = false;
confirmEntry = null;
}
}
// Tags render in full; long commit SHAs are clipped to the first 10 chars.
function shortRef(ref: string): string {
if (!ref) return '—';
return /^[0-9a-f]{16,}$/i.test(ref) ? ref.slice(0, 10) : ref;
}
</script>
<section class="panel dh-panel" aria-labelledby="dh-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="dh-heading">
{$t('apps.detail.deployHistory.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.deployHistory.sub')}</span>
<button
class="forge-btn-ghost dh-refresh"
onclick={load}
disabled={loading}
aria-label={$t('common.refresh')}
>
<IconRefresh size={13} />
</button>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading}
<p class="hint">{$t('apps.detail.deployHistory.loading')}</p>
{:else if deploys.length === 0}
<p class="hint">{$t('apps.detail.deployHistory.empty')}</p>
{:else}
<table class="forge-table dh-table">
<thead>
<tr>
<th>{$t('apps.detail.deployHistory.colWhen')}</th>
<th>{$t('apps.detail.deployHistory.colReason')}</th>
<th>{$t('apps.detail.deployHistory.colReference')}</th>
<th>{$t('apps.detail.deployHistory.colOutcome')}</th>
<th>{$t('apps.detail.deployHistory.colBy')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each deploys as d (d.id)}
<tr>
<td class="mono-time" title={$fmt.dateTime(d.finished_at)}>{$fmt.relative(d.finished_at)}</td>
<td><span class="reason-tag">{d.reason || '—'}</span></td>
<td class="mono" title={d.reference}>{shortRef(d.reference)}</td>
<td><StatusBadge status={d.outcome} size="sm" /></td>
<td>{d.triggered_by || '—'}</td>
<td class="dh-actions">
{#if d.rollbackable}
<button
class="forge-btn-ghost"
onclick={() => (confirmEntry = d)}
disabled={rollingBack}
>
<IconRestart size={13} />
<span>{$t('apps.detail.deployHistory.rollback')}</span>
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{#if confirmEntry}
<ConfirmDialog
open={true}
title={$t('apps.detail.deployHistory.confirmTitle')}
message={$t('apps.detail.deployHistory.confirmMessage', { ref: shortRef(confirmEntry.reference) })}
confirmLabel={$t('apps.detail.deployHistory.rollback')}
confirmVariant="danger"
onconfirm={() => confirmEntry && doRollback(confirmEntry)}
oncancel={() => (confirmEntry = null)}
/>
{/if}
<style>
.dh-panel {
margin-top: 1rem;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.dh-refresh {
margin-left: auto;
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
margin: 0.3rem 0;
}
.dh-table {
width: 100%;
margin-top: 0.5rem;
}
.reason-tag {
font-size: 0.68rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.74rem;
}
.dh-actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,223 @@
<script lang="ts">
/**
* WorkloadMetricsPanel
*
* Per-workload CPU + memory time-series for /apps/[id]. Reuses the global
* ResourceChart (ECharts) but scoped to one workload's containers, summed
* per timestamp server-side. CPU is plotted as a percentage on the left
* axis; memory as absolute MiB on the right axis (the container memory
* limit is often 0/unlimited, so a percentage would be meaningless).
*
* Stats live in SQLite, so the chart works even when the Docker daemon is
* down; an empty series simply means collection is off or the app is new.
*/
import * as api from '$lib/api';
import type { EChartsOption } from 'echarts';
import { t } from '$lib/i18n';
import { formatBytes } from '$lib/format/bytes';
import ResourceChart from './ResourceChart.svelte';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
type Window = '30m' | '2h' | '6h';
const WINDOWS: Window[] = ['30m', '2h', '6h'];
let points = $state<api.WorkloadStatsPoint[]>([]);
let window = $state<Window>('2h');
let loading = $state(true);
let error = $state('');
async function load(signal?: AbortSignal): Promise<void> {
try {
points = await api.fetchWorkloadStatsHistory(workloadId, window, signal);
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload on workloadId/window change and poll while mounted (matches the
// dashboard resources card cadence).
$effect(() => {
void workloadId;
void window;
loading = true;
const controller = new AbortController();
load(controller.signal);
const id = setInterval(() => load(controller.signal), 15_000);
return () => {
controller.abort();
clearInterval(id);
};
});
const MIB = 1024 * 1024;
const chartOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 48, bottom: 24, left: 44 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' }
},
legend: {
data: [$t('apps.detail.metrics.cpuSeries'), $t('apps.detail.metrics.memorySeries')],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'time',
axisLabel: { fontSize: 10, color: '#94a3b8' }
},
yAxis: [
{
type: 'value',
min: 0,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
{
type: 'value',
min: 0,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value} MiB' },
splitLine: { show: false }
}
],
series: [
{
name: $t('apps.detail.metrics.cpuSeries'),
type: 'line',
yAxisIndex: 0,
smooth: true,
showSymbol: false,
data: points.map((p) => [p.ts * 1000, Number(p.cpu_percent.toFixed(2))]),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('apps.detail.metrics.memorySeries'),
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: false,
data: points.map((p) => [p.ts * 1000, Number((p.memory_usage / MIB).toFixed(1))]),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
// Latest reading for the at-a-glance summary line above the chart.
const latest = $derived(points.length > 0 ? points[points.length - 1] : null);
</script>
<section class="panel wm-panel" aria-labelledby="wm-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="wm-heading">
{$t('apps.detail.metrics.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.metrics.sub')}</span>
<div class="wm-windows" role="group" aria-label={$t('apps.detail.metrics.windowLabel')}>
{#each WINDOWS as w (w)}
<button class="wm-win" class:active={window === w} onclick={() => (window = w)}>{w}</button>
{/each}
</div>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading && points.length === 0}
<p class="hint">{$t('apps.detail.metrics.loading')}</p>
{:else if points.length === 0}
<p class="hint">{$t('apps.detail.metrics.empty')}</p>
{:else}
{#if latest}
<p class="wm-summary">
<span class="wm-stat"
><span class="wm-dot cpu"></span>{$t('apps.detail.metrics.cpuSeries')}:
<strong>{latest.cpu_percent.toFixed(1)}%</strong></span
>
<span class="wm-stat"
><span class="wm-dot mem"></span>{$t('apps.detail.metrics.memorySeries')}:
<strong>{formatBytes(latest.memory_usage)}</strong></span
>
</p>
{/if}
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('apps.detail.metrics.title')} />
{/if}
</section>
<style>
.wm-panel {
margin-top: 1rem;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.wm-windows {
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.wm-win {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border: 1px solid var(--border-primary);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.wm-win.active {
background: var(--accent-soft, rgba(16, 185, 129, 0.15));
color: var(--text-primary);
border-color: var(--accent, #10b981);
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
margin: 0.3rem 0;
}
.wm-summary {
display: flex;
gap: 1rem;
font-size: 0.74rem;
color: var(--text-secondary);
margin: 0.3rem 0 0.5rem;
}
.wm-stat {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.wm-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.wm-dot.cpu {
background: #10b981;
}
.wm-dot.mem {
background: #3b82f6;
}
</style>
+25
View File
@@ -1697,6 +1697,31 @@
"confirmDeleteTitle": "Delete snapshot?",
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone."
},
"deployHistory": {
"title": "Deploy history",
"sub": "Every deploy of this app, newest first. Roll back to redeploy a previous version.",
"loading": "Loading deploy history…",
"empty": "No deploys recorded yet.",
"colWhen": "When",
"colReason": "Reason",
"colReference": "Version",
"colOutcome": "Outcome",
"colBy": "By",
"rollback": "Roll back",
"rolledBack": "Rolling back to {ref}…",
"rollbackFailed": "Rollback failed",
"confirmTitle": "Roll back to this version?",
"confirmMessage": "This redeploys {ref} as a new deploy. The current version is replaced once the rollback is healthy."
},
"metrics": {
"title": "Resource usage",
"sub": "CPU and memory for this app's containers over time.",
"loading": "Loading metrics…",
"empty": "No metrics yet. Collection may be disabled, or this app hasn't run long enough to sample.",
"windowLabel": "Time window",
"cpuSeries": "CPU",
"memorySeries": "Memory"
},
"toolbar": {
"stop": "Stop",
"start": "Start",
+25
View File
@@ -1697,6 +1697,31 @@
"confirmDeleteTitle": "Удалить снимок?",
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить."
},
"deployHistory": {
"title": "История деплоев",
"sub": "Все деплои этого приложения, новые сверху. Откатитесь, чтобы повторно развернуть предыдущую версию.",
"loading": "Загрузка истории деплоев…",
"empty": "Деплоев пока нет.",
"colWhen": "Когда",
"colReason": "Причина",
"colReference": "Версия",
"colOutcome": "Результат",
"colBy": "Кем",
"rollback": "Откатить",
"rolledBack": "Откат до {ref}…",
"rollbackFailed": "Не удалось откатить",
"confirmTitle": "Откатиться к этой версии?",
"confirmMessage": "Версия {ref} будет развёрнута заново. Текущая версия заменяется после успешной проверки отката."
},
"metrics": {
"title": "Использование ресурсов",
"sub": "CPU и память контейнеров этого приложения во времени.",
"loading": "Загрузка метрик…",
"empty": "Метрик пока нет. Сбор может быть отключён, или приложение работало слишком недолго для замера.",
"windowLabel": "Период",
"cpuSeries": "CPU",
"memorySeries": "Память"
},
"toolbar": {
"stop": "Стоп",
"start": "Старт",
+12
View File
@@ -33,6 +33,8 @@
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WorkloadNotificationsPanel from '$lib/components/WorkloadNotificationsPanel.svelte';
import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
import DeployHistoryPanel from '$lib/components/DeployHistoryPanel.svelte';
import WorkloadMetricsPanel from '$lib/components/WorkloadMetricsPanel.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
@@ -2808,6 +2810,16 @@
</section>
{/if}
<!-- ── Per-workload metrics (CPU/memory) ──────────── -->
{#if !editing}
<WorkloadMetricsPanel workloadId={id} />
{/if}
<!-- ── Deploy history + rollback ──────────────────── -->
{#if !editing}
<DeployHistoryPanel workloadId={id} />
{/if}
<!-- ── Per-workload notification routes ───────────── -->
{#if !editing}
<WorkloadNotificationsPanel workloadId={id} />