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
+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}