05440a5f92
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.
317 lines
12 KiB
Svelte
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')} · {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')} →</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>
|