feat(stats): resource metrics dashboard + sites logs/stats
Build / build (push) Successful in 10m50s

Background collector samples CPU/memory/network/block I/O for every
instance and site on a configurable interval (default 15s, range
5-300s), persists samples to SQLite with a configurable retention
window (default 2h, range 0-24h), and skips ticks gracefully when
the Docker daemon is unreachable. Settings are reloadable without
a restart — each tick re-reads them.

New API endpoints:
- GET /api/system/stats (host snapshot: info + df)
- GET /api/system/stats/history
- GET /api/system/stats/top?by=cpu|memory
- GET /api/projects/{id}/stages/{s}/instances/{iid}/stats/history
- GET /api/sites/{id}/stats[/history]
- GET /api/sites/{id}/logs (SSE + JSON, reuses instance log streamer)

Frontend:
- ECharts added with tree-shaken imports (~180KB gzip) for
  future-proof time-series/gantt/graph visualizations
- CollapsibleSection wraps all dashboard sections (system health,
  daemons, system resources, static sites, projects) with
  localStorage-persisted open state
- SystemResourcesCard shows capacity tiles, workload utilization
  chart with 30m/2h/6h/24h window picker, disk breakdown with
  reclaimable callouts, and top 5 consumers
- ContainerStats and ContainerLogs take a source discriminated union
  so sites reuse the same components as instances; sites detail page
  embeds both for Deno backend debugging
- Settings › Maintenance exposes collection interval + retention
- Docker-unavailable state returns 503 and renders an amber banner
  instead of a generic 500

Full i18n coverage (en + ru) for all new strings.
This commit is contained in:
2026-04-24 15:02:43 +03:00
parent 0632f512e6
commit 05440a5f92
27 changed files with 1897 additions and 112 deletions
+55 -69
View File
@@ -6,6 +6,8 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
import SystemDaemonsCard from '$lib/components/SystemDaemonsCard.svelte';
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconDeploy, IconAlert } from '$lib/components/icons';
import { t } from '$lib/i18n';
@@ -181,35 +183,49 @@
{/if}
<!-- System health summary -->
<SystemHealthCard />
<CollapsibleSection id="system-health" title={$t('dashboard.systemHealth')}>
<SystemHealthCard />
</CollapsibleSection>
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
<SystemDaemonsCard />
<CollapsibleSection id="system-daemons" title={$t('dashboard.daemons')} defaultOpen={false}>
<SystemDaemonsCard />
</CollapsibleSection>
<!-- Host CPU/memory/disk + top consumers -->
<CollapsibleSection
id="system-resources"
title={$t('dashboard.systemResources')}
subtitle={$t('dashboard.systemResourcesSubtitle')}
>
<SystemResourcesCard />
</CollapsibleSection>
<!-- Static sites summary -->
{#if !loading}
<section class="section">
<div class="section-head">
<h2 class="section-title">{$t('dashboard.staticSites')}<span class="accent">.</span></h2>
{#if sites.length > 0}
<a href="/sites" class="section-more">
{$t('dashboard.viewAllSites')} <span class="arrow"></span>
</a>
{/if}
</div>
{#snippet sitesActions()}
{#if sites.length > 0}
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
{$t('dashboard.viewAllSites')}
</a>
{/if}
{/snippet}
<CollapsibleSection
id="dashboard-sites"
title={$t('dashboard.staticSites')}
badge={sites.length > 0 ? String(sites.length) : ''}
actions={sitesActions}
>
{#if sites.length === 0}
<div class="mt-4">
<EmptyState
title={$t('dashboard.noSites')}
description={$t('dashboard.addFirstSite')}
actionLabel={$t('sites.title')}
actionHref="/sites"
icon="projects"
/>
</div>
<EmptyState
title={$t('dashboard.noSites')}
description={$t('dashboard.addFirstSite')}
actionLabel={$t('sites.title')}
actionHref="/sites"
icon="projects"
/>
{:else}
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each sites as site (site.id)}
{@const badge = siteStatusBadge(site.status)}
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
@@ -230,21 +246,23 @@
{/each}
</div>
{/if}
</section>
</CollapsibleSection>
{/if}
<!-- Project cards -->
<section class="section">
<h2 class="section-title">{$t('dashboard.projects')}<span class="accent">.</span></h2>
<CollapsibleSection
id="dashboard-projects"
title={$t('dashboard.projects')}
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
>
{#if loading}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(3) as _}
<SkeletonCard />
{/each}
</div>
{:else if error}
<div class="mt-4 rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button
type="button"
@@ -255,23 +273,21 @@
</button>
</div>
{:else if projects.length === 0}
<div class="mt-4">
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('empty.createProject')}
actionHref="/projects"
icon="projects"
/>
</div>
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('empty.createProject')}
actionHref="/projects"
icon="projects"
/>
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each projects as project (project.id)}
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
{/each}
</div>
{/if}
</section>
</CollapsibleSection>
</div>
<style>
@@ -297,34 +313,4 @@
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
.section { margin-top: 0.5rem; }
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1rem;
}
.section-title {
font-family: var(--font-family-sans);
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0 0 1rem;
}
.section-title .accent {
color: var(--color-brand-600);
font-weight: 700;
}
.section-more {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-brand-600);
text-decoration: none;
}
.section-more .arrow { display: inline-block; transition: transform 150ms ease; }
.section-more:hover .arrow { transform: translateX(3px); }
</style>
@@ -21,6 +21,8 @@
let staleThresholdDays = $state('7');
let imagePruneThresholdMb = $state('1024');
let statsIntervalSeconds = $state('15');
let statsRetentionHours = $state('2');
async function load() {
loading = true;
@@ -28,6 +30,8 @@
const s = await getSettings();
staleThresholdDays = String(s.stale_threshold_days ?? 7);
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
statsRetentionHours = String(s.stats_retention_hours ?? 2);
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
@@ -38,9 +42,20 @@
async function handleSave() {
saving = true;
try {
const intervalParsed = parseInt(statsIntervalSeconds, 10);
const retentionParsed = parseInt(statsRetentionHours, 10);
// Interval 0 disables collection; otherwise clamp to [5, 300].
const interval = isNaN(intervalParsed)
? 15
: intervalParsed === 0
? 0
: Math.max(5, Math.min(300, intervalParsed));
const retention = isNaN(retentionParsed) ? 2 : Math.max(0, Math.min(24, retentionParsed));
await updateSettings({
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0)
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0),
stats_interval_seconds: interval,
stats_retention_hours: retention
} as any);
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
@@ -99,6 +114,22 @@
placeholder="1024"
helpText={$t('settings.pruneThresholdHelp')}
/>
<FormField
label={$t('statsSettings.intervalLabel')}
name="statsIntervalSeconds"
type="number"
bind:value={statsIntervalSeconds}
placeholder="15"
helpText={$t('statsSettings.intervalHelp')}
/>
<FormField
label={$t('statsSettings.retentionLabel')}
name="statsRetentionHours"
type="number"
bind:value={statsRetentionHours}
placeholder="2"
helpText={$t('statsSettings.retentionHelp')}
/>
</div>
<div class="mt-6">
+27
View File
@@ -9,6 +9,8 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
let site = $state<StaticSite | null>(null);
@@ -26,6 +28,7 @@
let secretEncrypted = $state(true);
let secretSubmitting = $state(false);
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
let showLogs = $state(false);
const siteId = $derived($page.params.id);
@@ -251,6 +254,30 @@
{/if}
</div>
<!-- Resource usage + logs for deployed sites. -->
{#if site.container_id}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('resources.sectionTitle')}</h2>
<button
type="button"
onclick={() => { showLogs = !showLogs; }}
class="rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
{showLogs ? $t('resources.hideLogs') : $t('resources.showLogs')}
</button>
</div>
<ContainerStats source={{ kind: 'site', siteId: site.id }} />
</div>
{#if showLogs}
<ContainerLogs
source={{ kind: 'site', siteId: site.id }}
onclose={() => { showLogs = false; }}
/>
{/if}
{/if}
<!-- Webhook -->
<WebhookPanel
title={$t('sites.webhookTitle')}