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
+58 -10
View File
@@ -4,21 +4,30 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
)
// ContainerStats holds computed CPU and memory usage for a container.
// ContainerStats holds computed CPU, memory, network, and block I/O
// usage for a container. Network and block I/O values are cumulative
// byte counters — compute rates by differencing two samples.
type ContainerStats struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
MemoryPercent float64 `json:"memory_percent"`
Timestamp time.Time `json:"timestamp"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
MemoryPercent float64 `json:"memory_percent"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"`
}
// GetContainerStats retrieves a one-shot stats snapshot for the given container
// and computes CPU and memory percentages.
// and computes CPU, memory, network, and block I/O metrics.
func (c *Client) GetContainerStats(ctx context.Context, containerID string) (ContainerStats, error) {
result, err := c.api.ContainerStats(ctx, containerID, client.ContainerStatsOptions{
Stream: false,
@@ -42,14 +51,53 @@ func (c *Client) GetContainerStats(ctx context.Context, containerID string) (Con
memPercent = float64(memUsage) / float64(memLimit) * 100.0
}
rxBytes, txBytes := sumNetworkBytes(stats.Networks)
readBytes, writeBytes := sumBlockIOBytes(stats.BlkioStats.IoServiceBytesRecursive)
ts := stats.Read
if ts.IsZero() {
ts = time.Now().UTC()
}
return ContainerStats{
CPUPercent: cpuPercent,
MemoryUsage: memUsage,
MemoryLimit: memLimit,
MemoryPercent: memPercent,
Timestamp: ts,
CPUPercent: cpuPercent,
MemoryUsage: memUsage,
MemoryLimit: memLimit,
MemoryPercent: memPercent,
NetworkRxBytes: rxBytes,
NetworkTxBytes: txBytes,
BlockReadBytes: readBytes,
BlockWriteBytes: writeBytes,
}, nil
}
// sumNetworkBytes aggregates rx/tx byte counters across all network interfaces
// for a single container. Missing Networks map (disabled networking) yields zeros.
func sumNetworkBytes(nets map[string]container.NetworkStats) (rx, tx int64) {
for _, n := range nets {
rx += int64(n.RxBytes)
tx += int64(n.TxBytes)
}
return rx, tx
}
// sumBlockIOBytes totals read/write bytes across all block devices from the
// cgroup io_service_bytes_recursive entries. The "Op" field is "read"/"write"
// on cgroup v2 and "Read"/"Write" on cgroup v1 — match case-insensitively so
// either runtime works.
func sumBlockIOBytes(entries []container.BlkioStatEntry) (read, write int64) {
for _, e := range entries {
switch {
case strings.EqualFold(e.Op, "read"):
read += int64(e.Value)
case strings.EqualFold(e.Op, "write"):
write += int64(e.Value)
}
}
return read, write
}
// calculateCPUPercent computes CPU usage percentage from a stats response
// using the delta between current and previous CPU readings.
func calculateCPUPercent(stats container.StatsResponse) float64 {
+87
View File
@@ -0,0 +1,87 @@
package docker
import (
"context"
"fmt"
"time"
"github.com/moby/moby/client"
)
// SystemStats is a host-level snapshot combining daemon capacity
// (NCPU, memory total) with container counts and disk usage broken down
// by category. Workload CPU/memory utilization is aggregated from
// per-container samples by the stats collector, not here.
type SystemStats struct {
Timestamp time.Time `json:"timestamp"`
// Capacity from Docker daemon.
NCPU int `json:"ncpu"`
MemoryTotal int64 `json:"memory_total"`
// Container/image counts.
Containers int `json:"containers"`
Running int `json:"running"`
Paused int `json:"paused"`
Stopped int `json:"stopped"`
Images int `json:"images"`
// Disk usage by category (bytes).
DiskImagesBytes int64 `json:"disk_images_bytes"`
DiskContainersBytes int64 `json:"disk_containers_bytes"`
DiskVolumesBytes int64 `json:"disk_volumes_bytes"`
DiskBuildCacheBytes int64 `json:"disk_build_cache_bytes"`
// Reclaimable disk space by category (bytes).
DiskImagesReclaimable int64 `json:"disk_images_reclaimable"`
DiskContainersReclaimable int64 `json:"disk_containers_reclaimable"`
DiskVolumesReclaimable int64 `json:"disk_volumes_reclaimable"`
DiskBuildCacheReclaimable int64 `json:"disk_build_cache_reclaimable"`
// DiskTotalBytes is the sum of the category totals.
DiskTotalBytes int64 `json:"disk_total_bytes"`
}
// GetSystemStats returns a one-shot host-level snapshot. The Info() call
// and disk usage call are made in sequence. Disk usage failures do not
// fail the whole call — the result degrades gracefully with zero disk fields.
func (c *Client) GetSystemStats(ctx context.Context) (SystemStats, error) {
info, err := c.Info(ctx)
if err != nil {
return SystemStats{}, fmt.Errorf("system stats: %w", err)
}
stats := SystemStats{
Timestamp: time.Now().UTC(),
NCPU: info.NCPU,
MemoryTotal: info.MemoryTotal,
Containers: info.Containers,
Running: info.Running,
Paused: info.Paused,
Stopped: info.Stopped,
Images: info.Images,
}
du, derr := c.api.DiskUsage(ctx, client.DiskUsageOptions{
Containers: true,
Images: true,
Volumes: true,
BuildCache: true,
})
if derr == nil {
stats.DiskImagesBytes = du.Images.TotalSize
stats.DiskContainersBytes = du.Containers.TotalSize
stats.DiskVolumesBytes = du.Volumes.TotalSize
stats.DiskBuildCacheBytes = du.BuildCache.TotalSize
stats.DiskImagesReclaimable = du.Images.Reclaimable
stats.DiskContainersReclaimable = du.Containers.Reclaimable
stats.DiskVolumesReclaimable = du.Volumes.Reclaimable
stats.DiskBuildCacheReclaimable = du.BuildCache.Reclaimable
stats.DiskTotalBytes = stats.DiskImagesBytes +
stats.DiskContainersBytes +
stats.DiskVolumesBytes +
stats.DiskBuildCacheBytes
}
return stats, nil
}