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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user