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.
118 lines
3.7 KiB
Go
118 lines
3.7 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/client"
|
|
)
|
|
|
|
// 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 {
|
|
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, 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,
|
|
IncludePreviousSample: true,
|
|
})
|
|
if err != nil {
|
|
return ContainerStats{}, fmt.Errorf("get container stats %s: %w", containerID, err)
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
var stats container.StatsResponse
|
|
if err := json.NewDecoder(result.Body).Decode(&stats); err != nil {
|
|
return ContainerStats{}, fmt.Errorf("decode container stats %s: %w", containerID, err)
|
|
}
|
|
|
|
cpuPercent := calculateCPUPercent(stats)
|
|
memUsage := int64(stats.MemoryStats.Usage)
|
|
memLimit := int64(stats.MemoryStats.Limit)
|
|
var memPercent float64
|
|
if memLimit > 0 {
|
|
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{
|
|
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 {
|
|
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
|
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
|
|
|
if systemDelta <= 0 || cpuDelta < 0 {
|
|
return 0.0
|
|
}
|
|
|
|
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
|
if onlineCPUs == 0 {
|
|
onlineCPUs = 1
|
|
}
|
|
|
|
return (cpuDelta / systemDelta) * onlineCPUs * 100.0
|
|
}
|