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:
Generated
+46
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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: 5–300s.",
|
||||
"retentionLabel": "Stats retention (hours)",
|
||||
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 0–24h."
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
|
||||
@@ -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
@@ -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
@@ -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