Files
tiny-forge/web/src/routes/+page.svelte
T
alexei.dolgolyov 05440a5f92
Build / build (push) Successful in 10m50s
feat(stats): resource metrics dashboard + sites logs/stats
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.
2026-04-24 15:02:43 +03:00

317 lines
12 KiB
Svelte

<script lang="ts">
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
import * as api from '$lib/api';
import ProjectCard from '$lib/components/ProjectCard.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
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';
import { fmt } from '$lib/format/datetime';
let projects = $state<Project[]>([]);
let instancesByProject = $state<Record<string, Instance[]>>({});
let staleContainers = $state<StaleContainer[]>([]);
let unusedImagesMB = $state(0);
let unusedImagesCount = $state(0);
let unusedImagesExceeded = $state(false);
let sites = $state<StaticSite[]>([]);
let loading = $state(true);
let error = $state('');
let loadController: AbortController | null = null;
async function loadDashboard() {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
const signal = controller.signal;
loading = true;
error = '';
try {
projects = await api.listProjects(signal);
// Fetch project details sequentially to avoid exhausting
// browser connection pool (HTTP/1.1 allows only 6 per host).
const results: { projectId: string; instances: Instance[] }[] = [];
for (const p of projects) {
try {
const detail = await api.getProject(p.id, signal);
const stages = detail.stages ?? [];
const stageInstances: Instance[][] = [];
for (const s of stages) {
stageInstances.push(await api.listInstances(p.id, s.id, signal));
}
results.push({ projectId: p.id, instances: stageInstances.flat() });
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e;
results.push({ projectId: p.id, instances: [] });
}
}
const mapped: Record<string, Instance[]> = {};
for (const r of results) {
mapped[r.projectId] = r.instances;
}
instancesByProject = mapped;
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
try {
const imgStats = await api.getUnusedImageStats(signal);
unusedImagesMB = imgStats.total_size_mb;
unusedImagesCount = imgStats.count;
unusedImagesExceeded = imgStats.exceeded;
} catch { /* non-critical */ }
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
} finally {
loading = false;
}
}
$effect(() => {
loadDashboard();
return () => { loadController?.abort(); };
});
const totalProjects = $derived(projects.length);
const totalRunning = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'running').length
);
const totalFailed = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'failed').length
);
const totalStale = $derived(staleContainers.length);
const totalSites = $derived(sites.length);
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
function siteStatusBadge(status: string): { text: string; cls: string } {
switch (status) {
case 'deployed':
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
case 'syncing':
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
case 'failed':
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
case 'stopped':
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
default:
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
}
</script>
<svelte:head>
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6 dashboard">
<!-- Hero -->
{#snippet heroToolbar()}
<a href="/deploy" class="forge-btn">
<IconDeploy size={14} />
<span>{$t('dashboard.quickDeploy')}</span>
</a>
{/snippet}
<ForgeHero
eyebrow="THE FORGE"
eyebrowSuffix="DASHBOARD"
title={$t('dashboard.title')}
accent="."
size="lg"
toolbar={heroToolbar}
/>
<!-- Stats grid -->
<div class="forge-stat-grid">
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
<span class="forge-stat-sub">active</span>
</div>
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
<span class="forge-stat-sub">instances</span>
</div>
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
<span class="forge-stat-sub">need attention</span>
</div>
<a href="/containers/stale" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
<span class="forge-stat-sub">stale →</span>
</a>
<a href="/sites" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
<span class="forge-stat-sub">
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
</span>
</a>
</div>
<!-- Unused images warning -->
{#if unusedImagesExceeded}
<a href="/settings" class="flex items-center gap-3 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-4 transition-colors hover:bg-amber-100 dark:hover:bg-amber-950/50">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/50 text-amber-600">
<IconAlert size={20} />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">{$t('dashboard.unusedImagesWarning')}</p>
<p class="text-xs text-amber-700 dark:text-amber-400">{unusedImagesCount} {$t('dashboard.unusedImages')} &middot; {unusedImagesMB >= 1024 ? (unusedImagesMB / 1024).toFixed(1) + ' GB' : unusedImagesMB + ' MB'}</p>
</div>
<span class="text-xs font-medium text-amber-600 dark:text-amber-400">{$t('settings.pruneImages')} &rarr;</span>
</a>
{/if}
<!-- System health summary -->
<CollapsibleSection id="system-health" title={$t('dashboard.systemHealth')}>
<SystemHealthCard />
</CollapsibleSection>
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
<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}
{#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}
<EmptyState
title={$t('dashboard.noSites')}
description={$t('dashboard.addFirstSite')}
actionLabel={$t('sites.title')}
actionHref="/sites"
icon="projects"
/>
{:else}
<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)]">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
{#if site.domain}
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
{/if}
</div>
{#if site.last_sync_at}
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
{/if}
</a>
{/each}
</div>
{/if}
</CollapsibleSection>
{/if}
<!-- Project cards -->
<CollapsibleSection
id="dashboard-projects"
title={$t('dashboard.projects')}
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
>
{#if loading}
<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="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"
class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline"
onclick={loadDashboard}
>
{$t('dashboard.retry')}
</button>
</div>
{:else if projects.length === 0}
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('empty.createProject')}
actionHref="/projects"
icon="projects"
/>
{:else}
<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}
</CollapsibleSection>
</div>
<style>
.dashboard { position: relative; }
.stat-link {
text-decoration: none;
transition: background 150ms ease;
}
.stat-link:hover { background: var(--surface-card-hover); }
.stat-link .forge-stat-sub .tag {
display: inline-block;
padding: 0.05rem 0.4rem;
margin-right: 0.25rem;
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
}
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
: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; }
</style>