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
+46 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
"@fontsource/jetbrains-mono": "^5.2.8",
"echarts": "^6.0.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
@@ -1335,6 +1336,15 @@
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -2013,6 +2023,11 @@
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2119,6 +2134,14 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dependencies": {
"tslib": "2.3.0"
}
}
},
"dependencies": {
@@ -2833,6 +2856,15 @@
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true
},
"echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"requires": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -3231,6 +3263,11 @@
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3264,6 +3301,14 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true
},
"zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"requires": {
"tslib": "2.3.0"
}
}
}
}
+2 -1
View File
@@ -23,6 +23,7 @@
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
"@fontsource/jetbrains-mono": "^5.2.8",
"echarts": "^6.0.0"
}
}
+55
View File
@@ -1,6 +1,9 @@
import type {
ApiEnvelope,
ContainerStats,
ContainerStatsSample,
SystemStats,
SystemStatsSample,
Deploy,
DeployLog,
DockerHealth,
@@ -677,6 +680,58 @@ export function fetchContainerStats(
);
}
export function fetchInstanceStatsHistory(
projectId: string,
stageId: string,
instanceId: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
return get<SystemStats>('/api/system/stats', signal);
}
export function fetchSystemStatsHistory(
window = '2h',
signal?: AbortSignal
): Promise<SystemStatsSample[]> {
return get<SystemStatsSample[]>(`/api/system/stats/history?window=${encodeURIComponent(window)}`, signal);
}
export function fetchTopContainers(
by: 'cpu' | 'memory' = 'cpu',
limit = 5,
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
}
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
return get<ContainerStats>(`/api/sites/${id}/stats`, signal);
}
export function fetchStaticSiteStatsHistory(
id: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export async function fetchStaticSiteLogs(id: string, tail = 200): Promise<string[]> {
const result = await get<string[] | null>(`/api/sites/${id}/logs?tail=${tail}`);
return result ?? [];
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
@@ -0,0 +1,86 @@
<!--
Collapsible section wrapper. Persists open/closed state in localStorage
per-id so dashboard layout preferences survive reloads.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { IconChevronDown } from '$lib/components/icons';
interface Props {
id: string;
title: string;
subtitle?: string;
defaultOpen?: boolean;
badge?: string;
children: Snippet;
actions?: Snippet;
}
const {
id,
title,
subtitle = '',
defaultOpen = true,
badge = '',
children,
actions
}: Props = $props();
const storageKey = $derived(`tinyforge.section.${id}.open`);
function readInitial(): boolean {
if (typeof window === 'undefined') return defaultOpen;
const raw = window.localStorage.getItem(`tinyforge.section.${id}.open`);
if (raw === null) return defaultOpen;
return raw === '1';
}
let open = $state(readInitial());
function toggle() {
open = !open;
if (typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, open ? '1' : '0');
}
}
</script>
<section class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)]">
<header class="flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
onclick={toggle}
class="flex flex-1 items-center gap-2 text-left"
aria-expanded={open}
>
<span
class="inline-flex h-5 w-5 items-center justify-center text-[var(--text-tertiary)] transition-transform {open
? 'rotate-0'
: '-rotate-90'}"
>
<IconChevronDown size={16} />
</span>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-semibold text-[var(--text-primary)]">
{title}
{#if badge}
<span class="ml-2 rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-[10px] font-medium text-[var(--text-tertiary)]">
{badge}
</span>
{/if}
</h2>
{#if subtitle}
<p class="truncate text-xs text-[var(--text-tertiary)]">{subtitle}</p>
{/if}
</div>
</button>
{#if actions}
<div class="flex items-center gap-2">{@render actions()}</div>
{/if}
</header>
{#if open}
<div class="border-t border-[var(--border-primary)] p-4">
{@render children()}
</div>
{/if}
</section>
+28 -11
View File
@@ -1,21 +1,26 @@
<!--
Container log viewer with tail line limit and auto-scroll.
Works for both project instances and static sites — pass a `source`
discriminated union to point at the right endpoint.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import { fetchContainerLogs } from '$lib/api';
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api';
import { getAuthToken } from '$lib/auth';
import { t } from '$lib/i18n';
import { IconLoader, IconX } from '$lib/components/icons';
export type LogSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string };
interface Props {
projectId: string;
stageId: string;
instanceId: string;
source: LogSource;
onclose: () => void;
}
const { projectId, stageId, instanceId, onclose }: Props = $props();
const { source, onclose }: Props = $props();
let lines = $state<string[]>([]);
let loading = $state(true);
@@ -48,11 +53,26 @@
}
}
function buildFollowUrl(token: string | null): string {
const tokenParam = token ? `&token=${token}` : '';
if (source.kind === 'instance') {
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
}
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
}
async function fetchLogs(tail: number): Promise<string[]> {
if (source.kind === 'instance') {
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
}
return fetchStaticSiteLogs(source.siteId, tail);
}
async function loadLogs() {
loading = true;
error = '';
try {
lines = await fetchContainerLogs(projectId, stageId, instanceId, tailCount);
lines = await fetchLogs(tailCount);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load logs';
} finally {
@@ -65,8 +85,7 @@
if (eventSource) return;
following = true;
const token = getAuthToken();
const url = `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?follow=true&tail=0&token=${token}`;
eventSource = new EventSource(url);
eventSource = new EventSource(buildFollowUrl(token));
eventSource.onmessage = (e) => {
try {
@@ -87,7 +106,6 @@
eventSource.close();
eventSource = null;
}
// Flush any buffered lines before stopping.
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushPendingLines();
following = false;
@@ -108,7 +126,6 @@
loadLogs();
}
// Load on mount.
$effect(() => { loadLogs(); });
onDestroy(() => {
@@ -166,7 +183,7 @@
{:else if lines.length === 0}
<p class="text-gray-500">{$t('logs.noLogs')}</p>
{:else}
{#each lines as line, i}
{#each lines as line}
<div class="hover:bg-gray-900/50 px-1 -mx-1 rounded whitespace-pre-wrap break-all">{line}</div>
{/each}
{/if}
+104 -10
View File
@@ -1,33 +1,64 @@
<!--
Compact CPU/memory stats bars for embedding in instance cards.
Compact CPU/memory stats bars with an optional expandable history
chart. Works for both project instances and static sites via the
`source` discriminated union.
-->
<script lang="ts">
import type { ContainerStats } from '$lib/types';
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
export type StatsSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string };
interface Props {
projectId: string;
stageId: string;
instanceId: string;
source: StatsSource;
historyWindow?: '30m' | '2h' | '6h' | '24h';
}
const { projectId, stageId, instanceId }: Props = $props();
const { source, historyWindow = '2h' }: Props = $props();
let stats = $state<ContainerStats | null>(null);
let history = $state<ContainerStatsSample[]>([]);
let error = $state(false);
let expanded = $state(false);
async function fetchStats(signal: AbortSignal): Promise<ContainerStats> {
if (source.kind === 'instance') {
return api.fetchContainerStats(source.projectId, source.stageId, source.instanceId, signal);
}
return api.fetchStaticSiteStats(source.siteId, signal);
}
async function fetchHistory(signal: AbortSignal): Promise<ContainerStatsSample[]> {
if (source.kind === 'instance') {
return api.fetchInstanceStatsHistory(
source.projectId,
source.stageId,
source.instanceId,
historyWindow,
signal
);
}
return api.fetchStaticSiteStatsHistory(source.siteId, historyWindow, signal);
}
$effect(() => {
let controller = new AbortController();
async function load() {
// Abort any previous in-flight request before starting a new one.
controller.abort();
controller = new AbortController();
try {
const result = await api.fetchContainerStats(projectId, stageId, instanceId, controller.signal);
const result = await fetchStats(controller.signal);
stats = result;
error = false;
if (expanded) {
history = await fetchHistory(controller.signal);
}
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = true;
@@ -35,8 +66,6 @@
}
load();
// Poll every 30 seconds (reduced from 10s to limit concurrent connections).
const interval = setInterval(load, 30_000);
return () => {
@@ -68,6 +97,51 @@
if (stats.memory_percent > 50) return 'bg-amber-500';
return 'bg-blue-500';
});
const historyOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 10, bottom: 24, left: 40 },
tooltip: {
trigger: 'axis',
valueFormatter: (v) => (typeof v === 'number' ? v.toFixed(1) + '%' : String(v))
},
legend: {
data: [$t('resources.cpuSeries'), $t('resources.memorySeries')],
bottom: 0,
textStyle: { fontSize: 10 }
},
xAxis: { type: 'time', axisLabel: { fontSize: 10, color: '#94a3b8' } },
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
series: [
{
name: $t('resources.cpuSeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => [s.ts * 1000, Number(s.cpu_percent.toFixed(2))]),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('resources.memorySeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const pct = s.memory_limit > 0 ? (s.memory_usage / s.memory_limit) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
</script>
{#if stats}
@@ -98,6 +172,26 @@
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
</span>
</div>
<button
type="button"
class="mt-1 text-[10px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
onclick={() => (expanded = !expanded)}
>
{expanded ? '▾ ' + $t('resources.hideHistory') : '▸ ' + $t('resources.showHistory')}
</button>
{#if expanded}
{#if history.length === 0}
<p class="mt-1 text-[10px] text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
</p>
{:else}
<div class="mt-1 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-2">
<ResourceChart option={historyOption} height="140px" ariaLabel={$t('resources.workloadUtilization')} />
</div>
{/if}
{/if}
</div>
{:else if error}
<p class="mt-2 text-[10px] text-[var(--text-tertiary)]">{$t('stats.unavailable')}</p>
+2 -4
View File
@@ -147,15 +147,13 @@
</div>
{#if instance.status === 'running'}
<ContainerStats projectId={projectId} stageId={instance.stage_id} instanceId={instance.id} />
<ContainerStats source={{ kind: 'instance', projectId, stageId: instance.stage_id, instanceId: instance.id }} />
{/if}
{#if showLogs}
<div class="mt-2">
<ContainerLogs
{projectId}
stageId={instance.stage_id}
instanceId={instance.id}
source={{ kind: 'instance', projectId, stageId: instance.stage_id, instanceId: instance.id }}
onclose={() => { showLogs = false; }}
/>
</div>
@@ -0,0 +1,72 @@
<!--
Reusable ECharts wrapper for resource time-series.
Uses ECharts' tree-shakeable core import so only the modules we need
ship to the browser. The component creates one chart per mount, reuses
it via setOption, and disposes on unmount. ResizeObserver keeps the
canvas size in sync with its container.
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
LegendComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsOption } from 'echarts';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
LegendComponent,
CanvasRenderer
]);
interface Props {
option: EChartsOption;
height?: string;
ariaLabel?: string;
}
const { option, height = '160px', ariaLabel = 'Resource chart' }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let chart: echarts.ECharts | null = null;
let resizeObs: ResizeObserver | null = null;
onMount(() => {
if (!container) return;
chart = echarts.init(container, null, { renderer: 'canvas' });
chart.setOption(option);
resizeObs = new ResizeObserver(() => chart?.resize());
resizeObs.observe(container);
});
$effect(() => {
if (chart) {
chart.setOption(option, { notMerge: false, lazyUpdate: true });
}
});
onDestroy(() => {
resizeObs?.disconnect();
chart?.dispose();
chart = null;
});
</script>
<div
bind:this={container}
role="img"
aria-label={ariaLabel}
style="height: {height}; width: 100%;"
></div>
@@ -0,0 +1,274 @@
<!--
System resources panel: host capacity + workload usage chart + disk
breakdown + top consumers. Drops into the dashboard as its own section.
-->
<script lang="ts">
import type { SystemStats, SystemStatsSample, ContainerStatsSample } from '$lib/types';
import * as api from '$lib/api';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
import { t } from '$lib/i18n';
let current = $state<SystemStats | null>(null);
let history = $state<SystemStatsSample[]>([]);
let top = $state<ContainerStatsSample[]>([]);
let topBy = $state<'cpu' | 'memory'>('cpu');
let window = $state<'30m' | '2h' | '6h' | '24h'>('2h');
let dockerDown = $state(false);
let otherError = $state('');
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;
if (gb < 1024) return `${gb.toFixed(2)} GB`;
const tb = gb / 1024;
return `${tb.toFixed(2)} TB`;
}
async function load(signal?: AbortSignal) {
// Each request is handled independently so a 503 on `current` does
// not prevent history/top from populating (they read from SQLite
// which is available even when Docker is down).
const [currRes, histRes, topRes] = await Promise.allSettled([
api.fetchSystemStats(signal),
api.fetchSystemStatsHistory(window, signal),
api.fetchTopContainers(topBy, 5, signal)
]);
if (currRes.status === 'fulfilled') {
current = currRes.value;
dockerDown = false;
otherError = '';
} else if (!isAbort(currRes.reason)) {
if (isDockerDown(currRes.reason)) {
dockerDown = true;
otherError = '';
} else {
otherError = errorMessage(currRes.reason);
}
}
if (histRes.status === 'fulfilled') {
history = histRes.value;
}
if (topRes.status === 'fulfilled') {
top = topRes.value;
}
}
function isAbort(e: unknown): boolean {
return e instanceof DOMException && e.name === 'AbortError';
}
function isDockerDown(e: unknown): boolean {
const msg = errorMessage(e).toLowerCase();
return msg.includes('docker') && (msg.includes('not available') || msg.includes('503'));
}
function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
$effect(() => {
// Read window/topBy so this effect re-runs when they change.
void window;
void topBy;
const controller = new AbortController();
load(controller.signal);
const t = setInterval(() => load(controller.signal), 15_000);
return () => {
controller.abort();
clearInterval(t);
};
});
const chartOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 12, bottom: 24, left: 40 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
valueFormatter: (v) => {
if (typeof v !== 'number') return String(v);
return v.toFixed(1) + '%';
}
},
legend: {
data: [$t('resources.cpuSeries'), $t('resources.memorySeries')],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'time',
axisLabel: { fontSize: 10, color: '#94a3b8' }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
series: [
{
name: $t('resources.cpuSeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const cap = (s.ncpu || 1) * 100;
const pct = cap > 0 ? (s.workload_cpu_percent / cap) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('resources.memorySeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const pct = s.memory_total > 0 ? (s.workload_mem_usage / s.memory_total) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
</script>
{#if dockerDown}
<p class="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
{$t('resources.dockerUnavailable')}
</p>
{:else if otherError}
<p class="text-xs text-red-500">{otherError}</p>
{/if}
{#if current}
<!-- Capacity summary -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.cpuCores')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{current.ncpu}</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.memory')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{formatBytes(current.memory_total)}</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.running')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">
{current.running}<span class="text-xs text-[var(--text-tertiary)]"> / {current.containers}</span>
</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.dockerDisk')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{formatBytes(current.disk_total_bytes)}</div>
</div>
</div>
<!-- History chart + window picker -->
<div class="mt-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.workloadUtilization')}</span>
<select
bind:value={window}
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
>
<option value="30m">{$t('resources.windowMinutes', { n: '30' })}</option>
<option value="2h">{$t('resources.windowHours', { n: '2' })}</option>
<option value="6h">{$t('resources.windowHours', { n: '6' })}</option>
<option value="24h">{$t('resources.windowHours', { n: '24' })}</option>
</select>
</div>
{#if history.length === 0}
<p class="py-6 text-center text-xs text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
</p>
{:else}
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('resources.workloadUtilization')} />
{/if}
</div>
<!-- Disk breakdown -->
<div class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
{#each [
{ label: $t('resources.diskImages'), size: current.disk_images_bytes, reclaim: current.disk_images_reclaimable },
{ label: $t('resources.diskContainers'), size: current.disk_containers_bytes, reclaim: current.disk_containers_reclaimable },
{ label: $t('resources.diskVolumes'), size: current.disk_volumes_bytes, reclaim: current.disk_volumes_reclaimable },
{ label: $t('resources.diskBuildCache'), size: current.disk_build_cache_bytes, reclaim: current.disk_build_cache_reclaimable }
] as d}
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{d.label}</div>
<div class="mt-1 text-sm font-semibold text-[var(--text-primary)]">{formatBytes(d.size)}</div>
{#if d.reclaim > 0}
<div class="mt-0.5 text-[10px] text-amber-500">
{$t('resources.reclaimable', { size: formatBytes(d.reclaim) })}
</div>
{/if}
</div>
{/each}
</div>
<!-- Top consumers -->
<div class="mt-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.topConsumers')}</span>
<div class="flex gap-1">
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] font-medium transition-colors {topBy === 'cpu'
? 'bg-[var(--surface-card-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => (topBy = 'cpu')}
>
{$t('resources.byCpu')}
</button>
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] font-medium transition-colors {topBy === 'memory'
? 'bg-[var(--surface-card-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => (topBy = 'memory')}
>
{$t('resources.byMemory')}
</button>
</div>
</div>
{#if top.length === 0}
<p class="py-2 text-center text-xs text-[var(--text-tertiary)]">{$t('resources.noRunning')}</p>
{:else}
<div class="space-y-1">
{#each top as s}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate text-[var(--text-secondary)]">
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
{s.owner_type === 'site' ? $t('resources.site') : $t('resources.instance')}
</span>
<span class="ml-2 font-mono text-[10px] text-[var(--text-tertiary)]">
{s.container_id.slice(0, 12)}
</span>
</span>
<span class="tabular-nums text-[var(--text-primary)]">
{#if topBy === 'cpu'}
{s.cpu_percent.toFixed(1)}%
{:else}
{formatBytes(s.memory_usage)}
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if !dockerDown && !otherError}
<p class="text-xs text-[var(--text-tertiary)]">{$t('resources.loading')}</p>
{/if}
+41 -1
View File
@@ -41,7 +41,47 @@
"failedSites": "failed",
"noSites": "No static sites yet.",
"addFirstSite": "Deploy your first site",
"viewAllSites": "View all sites"
"viewAllSites": "View all sites",
"systemHealth": "System health",
"daemons": "Daemons",
"systemResources": "System resources",
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers"
},
"resources": {
"cpuCores": "CPU Cores",
"memory": "Memory",
"running": "Running",
"dockerDisk": "Docker Disk",
"workloadUtilization": "Workload utilization",
"windowMinutes": "{n} minutes",
"windowHours": "{n} hours",
"noSamples": "No samples yet — the collector samples every {interval}s.",
"diskImages": "Images",
"diskContainers": "Containers",
"diskVolumes": "Volumes",
"diskBuildCache": "Build cache",
"reclaimable": "{size} reclaimable",
"topConsumers": "Top consumers",
"byCpu": "by cpu",
"byMemory": "by memory",
"noRunning": "No running containers.",
"instance": "instance",
"site": "site",
"showHistory": "Show history",
"hideHistory": "Hide history",
"cpuSeries": "CPU %",
"memorySeries": "Memory %",
"loading": "Loading…",
"sectionTitle": "Resources",
"showLogs": "Show logs",
"hideLogs": "Hide logs",
"dockerUnavailable": "Docker is unavailable. Check that the daemon is running."
},
"statsSettings": {
"intervalLabel": "Stats collection interval (s)",
"intervalHelp": "How often resource samples are collected. 0 disables collection. Range: 5300s.",
"retentionLabel": "Stats retention (hours)",
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 024h."
},
"projects": {
"title": "Projects",
+41 -1
View File
@@ -41,7 +41,47 @@
"failedSites": "с ошибкой",
"noSites": "Статических сайтов пока нет.",
"addFirstSite": "Разверните первый сайт",
"viewAllSites": "Все сайты"
"viewAllSites": "Все сайты",
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
},
"resources": {
"cpuCores": "Ядра CPU",
"memory": "Память",
"running": "Запущено",
"dockerDisk": "Диск Docker",
"workloadUtilization": "Использование нагрузкой",
"windowMinutes": "{n} минут",
"windowHours": "{n} часов",
"noSamples": "Пока нет данных — сбор идёт каждые {interval}с.",
"diskImages": "Образы",
"diskContainers": "Контейнеры",
"diskVolumes": "Тома",
"diskBuildCache": "Кэш сборки",
"reclaimable": "{size} можно освободить",
"topConsumers": "Топ потребителей",
"byCpu": "по CPU",
"byMemory": "по памяти",
"noRunning": "Нет запущенных контейнеров.",
"instance": "экземпляр",
"site": "сайт",
"showHistory": "Показать историю",
"hideHistory": "Скрыть историю",
"cpuSeries": "CPU %",
"memorySeries": "Память %",
"loading": "Загрузка…",
"sectionTitle": "Ресурсы",
"showLogs": "Показать логи",
"hideLogs": "Скрыть логи",
"dockerUnavailable": "Docker недоступен. Проверьте, что демон запущен."
},
"statsSettings": {
"intervalLabel": "Интервал сбора статистики (с)",
"intervalHelp": "Как часто собираются замеры ресурсов. 0 отключает сбор. Диапазон: 5–300с.",
"retentionLabel": "Хранение статистики (часы)",
"retentionHelp": "Как долго хранятся замеры ресурсов. 0 отключает сбор. Диапазон: 0–24ч."
},
"projects": {
"title": "Проекты",
+55 -1
View File
@@ -130,6 +130,8 @@ export interface Settings {
backup_enabled: boolean;
backup_interval_hours: number;
backup_retention_count: number;
stats_interval_seconds: number;
stats_retention_hours: number;
updated_at: string;
}
@@ -462,11 +464,63 @@ export interface FolderEntry {
is_dir: boolean;
}
/** Container CPU and memory stats from the Docker stats API. */
/** Container CPU, memory, network, and block I/O stats from the Docker stats API. */
export interface ContainerStats {
timestamp?: string;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
memory_percent: number;
network_rx_bytes?: number;
network_tx_bytes?: number;
block_read_bytes?: number;
block_write_bytes?: number;
}
/** One persisted container sample returned by the history endpoints. */
export interface ContainerStatsSample {
container_id: string;
owner_type: 'instance' | 'site';
owner_id: string;
ts: number;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
network_rx_bytes: number;
network_tx_bytes: number;
block_read_bytes: number;
block_write_bytes: number;
}
/** Host-level snapshot returned by /api/system/stats. */
export interface SystemStats {
timestamp: string;
ncpu: number;
memory_total: number;
containers: number;
running: number;
paused: number;
stopped: number;
images: number;
disk_images_bytes: number;
disk_containers_bytes: number;
disk_volumes_bytes: number;
disk_build_cache_bytes: number;
disk_images_reclaimable: number;
disk_containers_reclaimable: number;
disk_volumes_reclaimable: number;
disk_build_cache_reclaimable: number;
disk_total_bytes: number;
}
/** One persisted system sample returned by /api/system/stats/history. */
export interface SystemStatsSample {
ts: number;
ncpu: number;
memory_total: number;
workload_cpu_percent: number;
workload_mem_usage: number;
containers_running: number;
disk_total_bytes: number;
}
+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')}