feat(observability): phase 8 - container stats, notifications & dashboard

Add container monitoring and notification system:
- Docker Stats API: real-time CPU/memory for running containers
- Webhook notifications for errors (deploy failures, stale, proxy unhealthy)
- Event log auto-pruning (daily, 30-day retention)
- ContainerStats component with auto-polling progress bars
- SystemHealthCard dashboard widget with running/proxy/error counts
- Full EN/RU i18n for stats and system health
This commit is contained in:
2026-03-30 11:37:25 +03:00
parent 79a40f3d9c
commit 7c57c740b4
13 changed files with 436 additions and 0 deletions
@@ -0,0 +1,104 @@
<!--
Compact CPU/memory stats bars for embedding in instance cards.
-->
<script lang="ts">
import type { ContainerStats } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
interface Props {
projectId: string;
stageId: string;
instanceId: string;
}
const { projectId, stageId, instanceId }: Props = $props();
let stats = $state<ContainerStats | null>(null);
let error = $state(false);
$effect(() => {
let cancelled = false;
async function load() {
try {
const result = await api.fetchContainerStats(projectId, stageId, instanceId);
if (!cancelled) {
stats = result;
error = false;
}
} catch {
if (!cancelled) {
error = true;
}
}
}
load();
// Poll every 10 seconds.
const interval = setInterval(load, 10_000);
return () => {
cancelled = true;
clearInterval(interval);
};
});
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}
const cpuColor = $derived(() => {
if (!stats) return 'bg-gray-300';
if (stats.cpu_percent > 80) return 'bg-red-500';
if (stats.cpu_percent > 50) return 'bg-amber-500';
return 'bg-emerald-500';
});
const memColor = $derived(() => {
if (!stats) return 'bg-gray-300';
if (stats.memory_percent > 80) return 'bg-red-500';
if (stats.memory_percent > 50) return 'bg-amber-500';
return 'bg-blue-500';
});
</script>
{#if stats}
<div class="mt-2 space-y-1">
<!-- CPU bar -->
<div class="flex items-center gap-2">
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.cpu')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {cpuColor()}"
style="width: {Math.min(stats.cpu_percent, 100)}%"
></div>
</div>
<span class="w-10 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
{stats.cpu_percent.toFixed(1)}%
</span>
</div>
<!-- Memory bar -->
<div class="flex items-center gap-2">
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.mem')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {memColor()}"
style="width: {Math.min(stats.memory_percent, 100)}%"
></div>
</div>
<span class="w-24 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
</span>
</div>
</div>
{:else if error}
<p class="mt-2 text-[10px] text-[var(--text-tertiary)]">{$t('stats.unavailable')}</p>
{/if}