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
+40
View File
@@ -133,10 +133,46 @@ func (s *Store) runMigrations() error {
// avoid a destructive migration on SQLite.
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
// Resource metrics collection (2026-04-24). Interval in seconds,
// retention in hours. 0 in either disables collection.
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
}
// Additive stack tables (2026-04-16). Created here rather than in the
// schema constant so older databases pick them up on restart.
statsTables := []string{
`CREATE TABLE IF NOT EXISTS container_stats_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
ts INTEGER NOT NULL,
cpu_percent REAL NOT NULL DEFAULT 0,
memory_usage INTEGER NOT NULL DEFAULT 0,
memory_limit INTEGER NOT NULL DEFAULT 0,
network_rx INTEGER NOT NULL DEFAULT 0,
network_tx INTEGER NOT NULL DEFAULT 0,
block_read INTEGER NOT NULL DEFAULT 0,
block_write INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS system_stats_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ncpu INTEGER NOT NULL DEFAULT 0,
memory_total INTEGER NOT NULL DEFAULT 0,
workload_cpu_percent REAL NOT NULL DEFAULT 0,
workload_mem_usage INTEGER NOT NULL DEFAULT 0,
containers_running INTEGER NOT NULL DEFAULT 0,
disk_total_bytes INTEGER NOT NULL DEFAULT 0
)`,
}
for _, t := range statsTables {
if _, err := s.db.Exec(t); err != nil {
return fmt.Errorf("create stats table: %w", err)
}
}
stackTables := []string{
`CREATE TABLE IF NOT EXISTS stacks (
id TEXT PRIMARY KEY,
@@ -201,6 +237,10 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {