feat(stats): resource metrics dashboard + sites logs/stats
Build / build (push) Successful in 10m50s
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:
+55
-69
@@ -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">
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user