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:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Старт",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user