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
+8
View File
@@ -33,6 +33,7 @@ import (
"github.com/alexei/tinyforge/internal/registry"
"github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/stack"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
@@ -276,6 +277,12 @@ func main() {
}
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
// Initialize resource stats collector. Interval + retention are read from
// settings on each tick, so configuration changes take effect within one
// tick without a restart.
statsCollector := stats.New(db, dockerClient)
statsCollector.Start()
// Initialize static site manager and health checker.
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
@@ -364,6 +371,7 @@ func main() {
staticSiteHealth.Stop()
staleScanner.Stop()
poller.Stop()
statsCollector.Stop()
// Drain in-progress deploys and notifications.
dep.Drain()
+9 -2
View File
@@ -68,6 +68,13 @@ func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
return
}
s.streamLogsForContainer(w, r, inst.ContainerID)
}
// streamLogsForContainer streams logs for an arbitrary container ID using the
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site)
// should validate ownership and then delegate here.
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
@@ -83,9 +90,9 @@ func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
isSSE := strings.Contains(accept, "text/event-stream")
logReader, err := s.docker.ContainerLogs(r.Context(), inst.ContainerID, follow && isSSE, tail)
logReader, err := s.docker.ContainerLogs(r.Context(), containerID, follow && isSSE, tail)
if err != nil {
slog.Error("failed to get container logs", "instance", instanceID, "error", err)
slog.Error("failed to get container logs", "container", containerID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get container logs")
return
}
+9
View File
@@ -219,6 +219,7 @@ func (s *Server) Router() chi.Router {
r.Get("/stages/{stage}/env", s.listStageEnv)
r.Get("/stages/{stage}/instances", s.listInstances)
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory)
r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
r.Get("/images", s.listProjectImages)
r.Get("/volumes", s.listVolumes)
@@ -288,6 +289,9 @@ func (s *Server) Router() chi.Router {
r.Get("/", s.getStaticSite)
r.Get("/secrets", s.listStaticSiteSecrets)
r.Get("/storage", s.getStaticSiteStorage)
r.Get("/logs", s.streamStaticSiteLogs)
r.Get("/stats", s.getStaticSiteStats)
r.Get("/stats/history", s.getStaticSiteStatsHistory)
// Admin-only mutations.
r.Group(func(r chi.Router) {
@@ -333,6 +337,11 @@ func (s *Server) Router() chi.Router {
// Stale container endpoints (read).
r.Get("/containers/stale", s.listStaleContainers)
// System resources (read-only).
r.Get("/system/stats", s.getSystemStats)
r.Get("/system/stats/history", s.getSystemStatsHistory)
r.Get("/system/stats/top", s.listTopContainersByCPU)
// Admin-only routes: require admin role.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
+20
View File
@@ -46,6 +46,8 @@ type settingsRequest struct {
BackupEnabled *bool `json:"backup_enabled,omitempty"`
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
StatsIntervalSeconds *int `json:"stats_interval_seconds,omitempty"`
StatsRetentionHours *int `json:"stats_retention_hours,omitempty"`
}
// getSettings handles GET /api/settings.
@@ -86,6 +88,8 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"backup_enabled": settings.BackupEnabled,
"backup_interval_hours": settings.BackupIntervalHours,
"backup_retention_count": settings.BackupRetentionCount,
"stats_interval_seconds": settings.StatsIntervalSeconds,
"stats_retention_hours": settings.StatsRetentionHours,
"updated_at": settings.UpdatedAt,
})
}
@@ -238,6 +242,22 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
}
updated.BackupRetentionCount = *req.BackupRetentionCount
}
if req.StatsIntervalSeconds != nil {
v := *req.StatsIntervalSeconds
if v != 0 && (v < 5 || v > 300) {
respondError(w, http.StatusBadRequest, "stats_interval_seconds must be 0 (disabled) or between 5 and 300")
return
}
updated.StatsIntervalSeconds = v
}
if req.StatsRetentionHours != nil {
v := *req.StatsRetentionHours
if v < 0 || v > 24 {
respondError(w, http.StatusBadRequest, "stats_retention_hours must be between 0 and 24")
return
}
updated.StatsRetentionHours = v
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
+245
View File
@@ -0,0 +1,245 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/store"
)
const (
// defaultHistoryWindow is used when no ?window= param is provided or the
// value fails to parse. Matches the default retention so the "last 2h"
// view always has data when collection is enabled.
defaultHistoryWindow = 2 * time.Hour
maxHistoryWindow = 24 * time.Hour
)
// parseWindow reads the ?window= query (Go duration string, e.g. "1h", "30m")
// and returns a bounded duration.
func parseWindow(r *http.Request) time.Duration {
raw := r.URL.Query().Get("window")
if raw == "" {
return defaultHistoryWindow
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return defaultHistoryWindow
}
if d > maxHistoryWindow {
return maxHistoryWindow
}
return d
}
// sinceTimestamp converts a duration into a Unix-seconds cutoff.
func sinceTimestamp(window time.Duration) int64 {
return time.Now().UTC().Add(-window).Unix()
}
// getSystemStats handles GET /api/system/stats — current host snapshot.
// When the Docker daemon is unreachable (e.g. Docker Desktop stopped) the
// handler returns 503 so the frontend can show a dedicated unavailable
// state instead of treating it as a generic 5xx failure.
func (s *Server) getSystemStats(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
sys, err := s.docker.GetSystemStats(r.Context())
if err != nil {
slog.Warn("system stats unavailable", "error", err)
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
respondJSON(w, http.StatusOK, sys)
}
// getSystemStatsHistory handles GET /api/system/stats/history?window=1h.
func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) {
samples, err := s.store.ListSystemStatsSamples(sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list system stats samples", "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.SystemStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history.
func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) {
instanceID := chi.URLParam(r, "iid")
if _, err := s.store.GetInstanceByID(instanceID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "instance")
return
}
slog.Error("failed to get instance", "instance_id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get instance")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot.
func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
return
}
cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID)
if err != nil {
slog.Error("failed to get site stats", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site stats")
return
}
respondJSON(w, http.StatusOK, cs)
}
// getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history.
func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list site stats samples", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
if samples == nil {
samples = []store.ContainerStatsSample{}
}
respondJSON(w, http.StatusOK, samples)
}
// streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true.
// Reuses the shared container log streamer so the SSE + multiplex handling
// matches /api/projects/.../instances/.../logs exactly.
func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "site")
return
}
slog.Error("failed to get site", "site_id", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get site")
return
}
if site.ContainerID == "" {
respondError(w, http.StatusConflict, "site has no container")
return
}
s.streamLogsForContainer(w, r, site.ContainerID)
}
// listTopContainersByCPU handles GET /api/system/stats/top?limit=5&by=cpu.
// Returns the top-N most recent samples across containers, sorted by CPU or
// memory. Useful for a system dashboard "top consumers" widget without
// requiring the frontend to aggregate per-container history on its own.
func (s *Server) listTopContainersByCPU(w http.ResponseWriter, r *http.Request) {
limit := 5
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 50 {
limit = n
}
}
by := r.URL.Query().Get("by")
if by != "memory" {
by = "cpu"
}
// Samples from the last 2 minutes window so "top" reflects near-current
// load, not long-dead rows.
samples, err := s.store.ListAllRecentContainerStatsSamples(sinceTimestamp(2 * time.Minute))
if err != nil {
slog.Error("failed to list container samples for top", "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
// Keep only the latest sample per container.
latest := make(map[string]store.ContainerStatsSample, len(samples))
for _, sm := range samples {
if prev, ok := latest[sm.ContainerID]; !ok || sm.TS > prev.TS {
latest[sm.ContainerID] = sm
}
}
top := make([]store.ContainerStatsSample, 0, len(latest))
for _, sm := range latest {
top = append(top, sm)
}
// Partial-sort by the requested metric, descending. For small N a simple
// insertion-like approach is plenty.
sortContainerSamples(top, by)
if len(top) > limit {
top = top[:limit]
}
respondJSON(w, http.StatusOK, top)
}
// sortContainerSamples sorts in place by CPU (or memory) descending.
// Note: ListContainerStatsSamples with empty ownerID returns no rows — the
// caller uses per-owner-type queries and merges; this helper is applied to
// the already-merged slice.
func sortContainerSamples(s []store.ContainerStatsSample, by string) {
// O(n^2) is fine — N is small (bounded by the number of containers).
for i := 1; i < len(s); i++ {
for j := i; j > 0; j-- {
var less bool
if by == "memory" {
less = s[j].MemoryUsage > s[j-1].MemoryUsage
} else {
less = s[j].CPUPercent > s[j-1].CPUPercent
}
if !less {
break
}
s[j-1], s[j] = s[j], s[j-1]
}
}
}
+50 -2
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 {
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{
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
}
+309
View File
@@ -0,0 +1,309 @@
// Package stats implements a background goroutine that periodically samples
// Docker container and host-level resource usage and persists the samples
// into SQLite. It reads its interval and retention from settings on every
// tick so configuration changes take effect without a restart.
package stats
import (
"context"
"log/slog"
"sync"
"time"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
)
// Defaults applied when settings values are outside their valid range.
const (
DefaultIntervalSeconds = 15
DefaultRetentionHours = 2
MinIntervalSeconds = 5
MaxIntervalSeconds = 300
// Hard cap on parallel container stat requests to avoid overwhelming
// the Docker daemon when the user has many containers.
maxParallelSamples = 8
)
// OwnerType values for ContainerStatsSample.OwnerType.
const (
OwnerTypeInstance = "instance"
OwnerTypeSite = "site"
)
// Collector runs the background sampling loop.
type Collector struct {
store *store.Store
docker *docker.Client
stopOnce sync.Once
stop chan struct{}
done chan struct{}
}
// New creates a new stats collector. Call Start to begin sampling.
func New(s *store.Store, d *docker.Client) *Collector {
return &Collector{
store: s,
docker: d,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Start launches the background loop. Returns immediately. The loop exits
// when Stop is called.
func (c *Collector) Start() {
go c.run()
}
// Stop signals the collector to exit and blocks until it has finished the
// in-flight tick.
func (c *Collector) Stop() {
c.stopOnce.Do(func() { close(c.stop) })
<-c.done
}
// run is the main loop. It reads the interval from settings on every tick,
// which lets configuration changes propagate within one tick without a
// dedicated reload mechanism.
func (c *Collector) run() {
defer close(c.done)
// Wait a few seconds before the first sample so the app has settled.
select {
case <-time.After(3 * time.Second):
case <-c.stop:
return
}
for {
interval, retention := c.readConfig()
if interval == 0 || retention == 0 {
// Collection disabled. Poll settings every minute in case the
// user re-enables it.
select {
case <-time.After(time.Minute):
continue
case <-c.stop:
return
}
}
c.tick(retention)
select {
case <-time.After(time.Duration(interval) * time.Second):
case <-c.stop:
return
}
}
}
// readConfig reads the current interval + retention from settings, applying
// defaults and clamping to the valid range.
func (c *Collector) readConfig() (intervalSeconds, retentionHours int) {
settings, err := c.store.GetSettings()
if err != nil {
slog.Warn("stats collector: failed to read settings — using defaults", "error", err)
return DefaultIntervalSeconds, DefaultRetentionHours
}
intervalSeconds = settings.StatsIntervalSeconds
retentionHours = settings.StatsRetentionHours
if intervalSeconds < 0 || retentionHours < 0 {
return 0, 0
}
if intervalSeconds > 0 && intervalSeconds < MinIntervalSeconds {
intervalSeconds = MinIntervalSeconds
}
if intervalSeconds > MaxIntervalSeconds {
intervalSeconds = MaxIntervalSeconds
}
return intervalSeconds, retentionHours
}
// tick samples all known containers, aggregates workload-level totals,
// persists samples, and prunes rows beyond the retention window. When
// the Docker daemon is unreachable the whole tick is skipped with a
// single debug log instead of one warning per container.
func (c *Collector) tick(retentionHours int) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pingCtx, pingCancel := context.WithTimeout(ctx, 2*time.Second)
defer pingCancel()
if err := c.docker.Ping(pingCtx); err != nil {
slog.Debug("stats collector: docker unreachable, skipping tick", "error", err)
return
}
targets := c.buildTargets()
if len(targets) == 0 {
// No containers to sample, but still record a system sample so the
// host history isn't empty.
c.recordSystemSample(ctx, 0, 0, 0)
c.prune(retentionHours)
return
}
samples := c.sampleAll(ctx, targets)
var (
totalCPU float64
totalMem int64
running int
)
for _, s := range samples {
if err := c.store.InsertContainerStatsSample(s); err != nil {
slog.Warn("stats collector: insert container sample",
"container", s.ContainerID, "error", err)
continue
}
totalCPU += s.CPUPercent
totalMem += s.MemoryUsage
running++
}
c.recordSystemSample(ctx, totalCPU, totalMem, running)
c.prune(retentionHours)
}
// target describes a single container to sample.
type target struct {
ContainerID string
OwnerType string
OwnerID string
}
// buildTargets fetches running instances and sites that have a container ID.
func (c *Collector) buildTargets() []target {
var out []target
instances, err := c.store.ListAllInstances()
if err != nil {
slog.Warn("stats collector: list instances", "error", err)
} else {
for _, inst := range instances {
if inst.ContainerID == "" {
continue
}
out = append(out, target{
ContainerID: inst.ContainerID,
OwnerType: OwnerTypeInstance,
OwnerID: inst.ID,
})
}
}
sites, err := c.store.GetAllStaticSites()
if err != nil {
slog.Warn("stats collector: list sites", "error", err)
} else {
for _, site := range sites {
if site.ContainerID == "" {
continue
}
out = append(out, target{
ContainerID: site.ContainerID,
OwnerType: OwnerTypeSite,
OwnerID: site.ID,
})
}
}
return out
}
// sampleAll fetches Docker stats for every target in bounded parallelism.
// Failed samples are logged and skipped — a missing container must not kill
// the whole tick.
func (c *Collector) sampleAll(ctx context.Context, targets []target) []store.ContainerStatsSample {
sem := make(chan struct{}, maxParallelSamples)
results := make([]store.ContainerStatsSample, len(targets))
found := make([]bool, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, t target) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
sampleCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
stats, err := c.docker.GetContainerStats(sampleCtx, t.ContainerID)
if err != nil {
slog.Debug("stats collector: get container stats",
"container", t.ContainerID, "owner_type", t.OwnerType, "error", err)
return
}
ts := stats.Timestamp.Unix()
if ts <= 0 {
ts = time.Now().UTC().Unix()
}
results[i] = store.ContainerStatsSample{
ContainerID: t.ContainerID,
OwnerType: t.OwnerType,
OwnerID: t.OwnerID,
TS: ts,
CPUPercent: stats.CPUPercent,
MemoryUsage: stats.MemoryUsage,
MemoryLimit: stats.MemoryLimit,
NetworkRxBytes: stats.NetworkRxBytes,
NetworkTxBytes: stats.NetworkTxBytes,
BlockReadBytes: stats.BlockReadBytes,
BlockWriteBytes: stats.BlockWriteBytes,
}
found[i] = true
}(i, t)
}
wg.Wait()
out := results[:0]
for i := range results {
if found[i] {
out = append(out, results[i])
}
}
return out
}
// recordSystemSample fetches host info + disk usage and persists a combined
// system-level sample. Failures are warned but do not propagate.
func (c *Collector) recordSystemSample(ctx context.Context, workloadCPU float64, workloadMem int64, running int) {
sysStats, err := c.docker.GetSystemStats(ctx)
if err != nil {
slog.Warn("stats collector: get system stats", "error", err)
return
}
sample := store.SystemStatsSample{
TS: sysStats.Timestamp.Unix(),
NCPU: sysStats.NCPU,
MemoryTotal: sysStats.MemoryTotal,
WorkloadCPUPercent: workloadCPU,
WorkloadMemUsage: workloadMem,
ContainersRunning: running,
DiskTotalBytes: sysStats.DiskTotalBytes,
}
// Prefer the Docker-reported running count when we have no running samples
// (e.g., very first tick may race with container readiness).
if running == 0 && sysStats.Running > 0 {
sample.ContainersRunning = sysStats.Running
}
if err := c.store.InsertSystemStatsSample(sample); err != nil {
slog.Warn("stats collector: insert system sample", "error", err)
}
}
// prune drops rows older than the retention window.
func (c *Collector) prune(retentionHours int) {
if retentionHours <= 0 {
return
}
cutoff := time.Now().UTC().Add(-time.Duration(retentionHours) * time.Hour).Unix()
if _, err := c.store.PruneStatsSamplesBefore(cutoff); err != nil {
slog.Warn("stats collector: prune", "error", err)
}
}
+31
View File
@@ -78,9 +78,40 @@ type Settings struct {
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"`
}
// ContainerStatsSample is one persisted sample of container resource usage.
// Cumulative counters (network, block I/O) require differencing two samples
// to get rates; CPU is already a percent-since-previous-sample value.
type ContainerStatsSample struct {
ContainerID string `json:"container_id"`
OwnerType string `json:"owner_type"` // "instance" or "site"
OwnerID string `json:"owner_id"`
TS int64 `json:"ts"` // Unix seconds UTC
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"`
}
// SystemStatsSample is one persisted host-level snapshot that aggregates
// workload usage across all containers plus daemon capacity + disk totals.
type SystemStatsSample struct {
TS int64 `json:"ts"` // Unix seconds UTC
NCPU int `json:"ncpu"`
MemoryTotal int64 `json:"memory_total"`
WorkloadCPUPercent float64 `json:"workload_cpu_percent"`
WorkloadMemUsage int64 `json:"workload_mem_usage"`
ContainersRunning int `json:"containers_running"`
DiskTotalBytes int64 `json:"disk_total_bytes"`
}
// Backup represents a backup metadata record.
type Backup struct {
ID string `json:"id"`
+4
View File
@@ -18,6 +18,7 @@ func (s *Store) GetSettings() (Settings, error) {
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
image_prune_threshold_mb,
backup_enabled, backup_interval_hours, backup_retention_count,
stats_interval_seconds, stats_retention_hours,
updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
@@ -29,6 +30,7 @@ func (s *Store) GetSettings() (Settings, error) {
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
&st.ImagePruneThresholdMB,
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
&st.StatsIntervalSeconds, &st.StatsRetentionHours,
&st.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err)
@@ -65,6 +67,7 @@ func (s *Store) UpdateSettings(st Settings) error {
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
image_prune_threshold_mb=?,
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
stats_interval_seconds=?, stats_retention_hours=?,
updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL,
@@ -76,6 +79,7 @@ func (s *Store) UpdateSettings(st Settings) error {
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
st.ImagePruneThresholdMB,
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
st.StatsIntervalSeconds, st.StatsRetentionHours,
st.UpdatedAt,
)
if err != nil {
+157
View File
@@ -0,0 +1,157 @@
package store
import (
"fmt"
)
// InsertContainerStatsSample appends a single container sample row.
func (s *Store) InsertContainerStatsSample(sample ContainerStatsSample) error {
_, err := s.db.Exec(
`INSERT INTO container_stats_samples (
container_id, owner_type, owner_id, ts,
cpu_percent, memory_usage, memory_limit,
network_rx, network_tx, block_read, block_write
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sample.ContainerID, sample.OwnerType, sample.OwnerID, sample.TS,
sample.CPUPercent, sample.MemoryUsage, sample.MemoryLimit,
sample.NetworkRxBytes, sample.NetworkTxBytes,
sample.BlockReadBytes, sample.BlockWriteBytes,
)
if err != nil {
return fmt.Errorf("insert container stats sample: %w", err)
}
return nil
}
// InsertSystemStatsSample appends a single host-level sample row.
func (s *Store) InsertSystemStatsSample(sample SystemStatsSample) error {
_, err := s.db.Exec(
`INSERT INTO system_stats_samples (
ts, ncpu, memory_total,
workload_cpu_percent, workload_mem_usage,
containers_running, disk_total_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
sample.TS, sample.NCPU, sample.MemoryTotal,
sample.WorkloadCPUPercent, sample.WorkloadMemUsage,
sample.ContainersRunning, sample.DiskTotalBytes,
)
if err != nil {
return fmt.Errorf("insert system stats sample: %w", err)
}
return nil
}
// ListContainerStatsSamples returns samples for the given owner since the
// given unix timestamp (inclusive), ordered by ts ascending.
func (s *Store) ListContainerStatsSamples(ownerType, ownerID string, sinceTS int64) ([]ContainerStatsSample, error) {
rows, err := s.db.Query(
`SELECT container_id, owner_type, owner_id, ts,
cpu_percent, memory_usage, memory_limit,
network_rx, network_tx, block_read, block_write
FROM container_stats_samples
WHERE owner_type = ? AND owner_id = ? AND ts >= ?
ORDER BY ts ASC`,
ownerType, ownerID, sinceTS,
)
if err != nil {
return nil, fmt.Errorf("list container stats samples: %w", err)
}
defer rows.Close()
var out []ContainerStatsSample
for rows.Next() {
var s ContainerStatsSample
if err := rows.Scan(
&s.ContainerID, &s.OwnerType, &s.OwnerID, &s.TS,
&s.CPUPercent, &s.MemoryUsage, &s.MemoryLimit,
&s.NetworkRxBytes, &s.NetworkTxBytes,
&s.BlockReadBytes, &s.BlockWriteBytes,
); err != nil {
return nil, fmt.Errorf("scan container stats sample: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// ListAllRecentContainerStatsSamples returns samples across every owner since
// the given unix timestamp, ordered by ts ascending. Used by the system
// dashboard "top containers" widget where the UI wants a mixed pool.
func (s *Store) ListAllRecentContainerStatsSamples(sinceTS int64) ([]ContainerStatsSample, error) {
rows, err := s.db.Query(
`SELECT container_id, owner_type, owner_id, ts,
cpu_percent, memory_usage, memory_limit,
network_rx, network_tx, block_read, block_write
FROM container_stats_samples
WHERE ts >= ?
ORDER BY ts ASC`,
sinceTS,
)
if err != nil {
return nil, fmt.Errorf("list all recent container stats samples: %w", err)
}
defer rows.Close()
var out []ContainerStatsSample
for rows.Next() {
var s ContainerStatsSample
if err := rows.Scan(
&s.ContainerID, &s.OwnerType, &s.OwnerID, &s.TS,
&s.CPUPercent, &s.MemoryUsage, &s.MemoryLimit,
&s.NetworkRxBytes, &s.NetworkTxBytes,
&s.BlockReadBytes, &s.BlockWriteBytes,
); err != nil {
return nil, fmt.Errorf("scan container stats sample: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// ListSystemStatsSamples returns host samples since the given unix timestamp.
func (s *Store) ListSystemStatsSamples(sinceTS int64) ([]SystemStatsSample, error) {
rows, err := s.db.Query(
`SELECT ts, ncpu, memory_total,
workload_cpu_percent, workload_mem_usage,
containers_running, disk_total_bytes
FROM system_stats_samples
WHERE ts >= ?
ORDER BY ts ASC`,
sinceTS,
)
if err != nil {
return nil, fmt.Errorf("list system stats samples: %w", err)
}
defer rows.Close()
var out []SystemStatsSample
for rows.Next() {
var s SystemStatsSample
if err := rows.Scan(
&s.TS, &s.NCPU, &s.MemoryTotal,
&s.WorkloadCPUPercent, &s.WorkloadMemUsage,
&s.ContainersRunning, &s.DiskTotalBytes,
); err != nil {
return nil, fmt.Errorf("scan system stats sample: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// PruneStatsSamplesBefore deletes all samples older than the given unix timestamp
// from both the container and system stats tables. Returns rows deleted across
// both tables.
func (s *Store) PruneStatsSamplesBefore(ts int64) (int64, error) {
r1, err := s.db.Exec(`DELETE FROM container_stats_samples WHERE ts < ?`, ts)
if err != nil {
return 0, fmt.Errorf("prune container stats samples: %w", err)
}
r2, err := s.db.Exec(`DELETE FROM system_stats_samples WHERE ts < ?`, ts)
if err != nil {
return 0, fmt.Errorf("prune system stats samples: %w", err)
}
n1, _ := r1.RowsAffected()
n2, _ := r2.RowsAffected()
return n1 + n2, nil
}
+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 {
+46 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
"@fontsource/jetbrains-mono": "^5.2.8",
"echarts": "^6.0.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
@@ -1335,6 +1336,15 @@
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -2013,6 +2023,11 @@
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2119,6 +2134,14 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dependencies": {
"tslib": "2.3.0"
}
}
},
"dependencies": {
@@ -2833,6 +2856,15 @@
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true
},
"echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"requires": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -3231,6 +3263,11 @@
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3264,6 +3301,14 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true
},
"zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"requires": {
"tslib": "2.3.0"
}
}
}
}
+2 -1
View File
@@ -23,6 +23,7 @@
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
"@fontsource/jetbrains-mono": "^5.2.8",
"echarts": "^6.0.0"
}
}
+55
View File
@@ -1,6 +1,9 @@
import type {
ApiEnvelope,
ContainerStats,
ContainerStatsSample,
SystemStats,
SystemStatsSample,
Deploy,
DeployLog,
DockerHealth,
@@ -677,6 +680,58 @@ export function fetchContainerStats(
);
}
export function fetchInstanceStatsHistory(
projectId: string,
stageId: string,
instanceId: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
return get<SystemStats>('/api/system/stats', signal);
}
export function fetchSystemStatsHistory(
window = '2h',
signal?: AbortSignal
): Promise<SystemStatsSample[]> {
return get<SystemStatsSample[]>(`/api/system/stats/history?window=${encodeURIComponent(window)}`, signal);
}
export function fetchTopContainers(
by: 'cpu' | 'memory' = 'cpu',
limit = 5,
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
}
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
return get<ContainerStats>(`/api/sites/${id}/stats`, signal);
}
export function fetchStaticSiteStatsHistory(
id: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export async function fetchStaticSiteLogs(id: string, tail = 200): Promise<string[]> {
const result = await get<string[] | null>(`/api/sites/${id}/logs?tail=${tail}`);
return result ?? [];
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
@@ -0,0 +1,86 @@
<!--
Collapsible section wrapper. Persists open/closed state in localStorage
per-id so dashboard layout preferences survive reloads.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { IconChevronDown } from '$lib/components/icons';
interface Props {
id: string;
title: string;
subtitle?: string;
defaultOpen?: boolean;
badge?: string;
children: Snippet;
actions?: Snippet;
}
const {
id,
title,
subtitle = '',
defaultOpen = true,
badge = '',
children,
actions
}: Props = $props();
const storageKey = $derived(`tinyforge.section.${id}.open`);
function readInitial(): boolean {
if (typeof window === 'undefined') return defaultOpen;
const raw = window.localStorage.getItem(`tinyforge.section.${id}.open`);
if (raw === null) return defaultOpen;
return raw === '1';
}
let open = $state(readInitial());
function toggle() {
open = !open;
if (typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, open ? '1' : '0');
}
}
</script>
<section class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)]">
<header class="flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
onclick={toggle}
class="flex flex-1 items-center gap-2 text-left"
aria-expanded={open}
>
<span
class="inline-flex h-5 w-5 items-center justify-center text-[var(--text-tertiary)] transition-transform {open
? 'rotate-0'
: '-rotate-90'}"
>
<IconChevronDown size={16} />
</span>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-semibold text-[var(--text-primary)]">
{title}
{#if badge}
<span class="ml-2 rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-[10px] font-medium text-[var(--text-tertiary)]">
{badge}
</span>
{/if}
</h2>
{#if subtitle}
<p class="truncate text-xs text-[var(--text-tertiary)]">{subtitle}</p>
{/if}
</div>
</button>
{#if actions}
<div class="flex items-center gap-2">{@render actions()}</div>
{/if}
</header>
{#if open}
<div class="border-t border-[var(--border-primary)] p-4">
{@render children()}
</div>
{/if}
</section>
+28 -11
View File
@@ -1,21 +1,26 @@
<!--
Container log viewer with tail line limit and auto-scroll.
Works for both project instances and static sites — pass a `source`
discriminated union to point at the right endpoint.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import { fetchContainerLogs } from '$lib/api';
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api';
import { getAuthToken } from '$lib/auth';
import { t } from '$lib/i18n';
import { IconLoader, IconX } from '$lib/components/icons';
export type LogSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string };
interface Props {
projectId: string;
stageId: string;
instanceId: string;
source: LogSource;
onclose: () => void;
}
const { projectId, stageId, instanceId, onclose }: Props = $props();
const { source, onclose }: Props = $props();
let lines = $state<string[]>([]);
let loading = $state(true);
@@ -48,11 +53,26 @@
}
}
function buildFollowUrl(token: string | null): string {
const tokenParam = token ? `&token=${token}` : '';
if (source.kind === 'instance') {
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
}
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
}
async function fetchLogs(tail: number): Promise<string[]> {
if (source.kind === 'instance') {
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
}
return fetchStaticSiteLogs(source.siteId, tail);
}
async function loadLogs() {
loading = true;
error = '';
try {
lines = await fetchContainerLogs(projectId, stageId, instanceId, tailCount);
lines = await fetchLogs(tailCount);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load logs';
} finally {
@@ -65,8 +85,7 @@
if (eventSource) return;
following = true;
const token = getAuthToken();
const url = `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?follow=true&tail=0&token=${token}`;
eventSource = new EventSource(url);
eventSource = new EventSource(buildFollowUrl(token));
eventSource.onmessage = (e) => {
try {
@@ -87,7 +106,6 @@
eventSource.close();
eventSource = null;
}
// Flush any buffered lines before stopping.
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushPendingLines();
following = false;
@@ -108,7 +126,6 @@
loadLogs();
}
// Load on mount.
$effect(() => { loadLogs(); });
onDestroy(() => {
@@ -166,7 +183,7 @@
{:else if lines.length === 0}
<p class="text-gray-500">{$t('logs.noLogs')}</p>
{:else}
{#each lines as line, i}
{#each lines as line}
<div class="hover:bg-gray-900/50 px-1 -mx-1 rounded whitespace-pre-wrap break-all">{line}</div>
{/each}
{/if}
+104 -10
View File
@@ -1,33 +1,64 @@
<!--
Compact CPU/memory stats bars for embedding in instance cards.
Compact CPU/memory stats bars with an optional expandable history
chart. Works for both project instances and static sites via the
`source` discriminated union.
-->
<script lang="ts">
import type { ContainerStats } from '$lib/types';
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
export type StatsSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string };
interface Props {
projectId: string;
stageId: string;
instanceId: string;
source: StatsSource;
historyWindow?: '30m' | '2h' | '6h' | '24h';
}
const { projectId, stageId, instanceId }: Props = $props();
const { source, historyWindow = '2h' }: Props = $props();
let stats = $state<ContainerStats | null>(null);
let history = $state<ContainerStatsSample[]>([]);
let error = $state(false);
let expanded = $state(false);
async function fetchStats(signal: AbortSignal): Promise<ContainerStats> {
if (source.kind === 'instance') {
return api.fetchContainerStats(source.projectId, source.stageId, source.instanceId, signal);
}
return api.fetchStaticSiteStats(source.siteId, signal);
}
async function fetchHistory(signal: AbortSignal): Promise<ContainerStatsSample[]> {
if (source.kind === 'instance') {
return api.fetchInstanceStatsHistory(
source.projectId,
source.stageId,
source.instanceId,
historyWindow,
signal
);
}
return api.fetchStaticSiteStatsHistory(source.siteId, historyWindow, signal);
}
$effect(() => {
let controller = new AbortController();
async function load() {
// Abort any previous in-flight request before starting a new one.
controller.abort();
controller = new AbortController();
try {
const result = await api.fetchContainerStats(projectId, stageId, instanceId, controller.signal);
const result = await fetchStats(controller.signal);
stats = result;
error = false;
if (expanded) {
history = await fetchHistory(controller.signal);
}
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = true;
@@ -35,8 +66,6 @@
}
load();
// Poll every 30 seconds (reduced from 10s to limit concurrent connections).
const interval = setInterval(load, 30_000);
return () => {
@@ -68,6 +97,51 @@
if (stats.memory_percent > 50) return 'bg-amber-500';
return 'bg-blue-500';
});
const historyOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 10, bottom: 24, left: 40 },
tooltip: {
trigger: 'axis',
valueFormatter: (v) => (typeof v === 'number' ? v.toFixed(1) + '%' : String(v))
},
legend: {
data: [$t('resources.cpuSeries'), $t('resources.memorySeries')],
bottom: 0,
textStyle: { fontSize: 10 }
},
xAxis: { type: 'time', axisLabel: { fontSize: 10, color: '#94a3b8' } },
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
series: [
{
name: $t('resources.cpuSeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => [s.ts * 1000, Number(s.cpu_percent.toFixed(2))]),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('resources.memorySeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const pct = s.memory_limit > 0 ? (s.memory_usage / s.memory_limit) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
</script>
{#if stats}
@@ -98,6 +172,26 @@
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
</span>
</div>
<button
type="button"
class="mt-1 text-[10px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
onclick={() => (expanded = !expanded)}
>
{expanded ? '▾ ' + $t('resources.hideHistory') : '▸ ' + $t('resources.showHistory')}
</button>
{#if expanded}
{#if history.length === 0}
<p class="mt-1 text-[10px] text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
</p>
{:else}
<div class="mt-1 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-2">
<ResourceChart option={historyOption} height="140px" ariaLabel={$t('resources.workloadUtilization')} />
</div>
{/if}
{/if}
</div>
{:else if error}
<p class="mt-2 text-[10px] text-[var(--text-tertiary)]">{$t('stats.unavailable')}</p>
+2 -4
View File
@@ -147,15 +147,13 @@
</div>
{#if instance.status === 'running'}
<ContainerStats projectId={projectId} stageId={instance.stage_id} instanceId={instance.id} />
<ContainerStats source={{ kind: 'instance', projectId, stageId: instance.stage_id, instanceId: instance.id }} />
{/if}
{#if showLogs}
<div class="mt-2">
<ContainerLogs
{projectId}
stageId={instance.stage_id}
instanceId={instance.id}
source={{ kind: 'instance', projectId, stageId: instance.stage_id, instanceId: instance.id }}
onclose={() => { showLogs = false; }}
/>
</div>
@@ -0,0 +1,72 @@
<!--
Reusable ECharts wrapper for resource time-series.
Uses ECharts' tree-shakeable core import so only the modules we need
ship to the browser. The component creates one chart per mount, reuses
it via setOption, and disposes on unmount. ResizeObserver keeps the
canvas size in sync with its container.
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
LegendComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsOption } from 'echarts';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
LegendComponent,
CanvasRenderer
]);
interface Props {
option: EChartsOption;
height?: string;
ariaLabel?: string;
}
const { option, height = '160px', ariaLabel = 'Resource chart' }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let chart: echarts.ECharts | null = null;
let resizeObs: ResizeObserver | null = null;
onMount(() => {
if (!container) return;
chart = echarts.init(container, null, { renderer: 'canvas' });
chart.setOption(option);
resizeObs = new ResizeObserver(() => chart?.resize());
resizeObs.observe(container);
});
$effect(() => {
if (chart) {
chart.setOption(option, { notMerge: false, lazyUpdate: true });
}
});
onDestroy(() => {
resizeObs?.disconnect();
chart?.dispose();
chart = null;
});
</script>
<div
bind:this={container}
role="img"
aria-label={ariaLabel}
style="height: {height}; width: 100%;"
></div>
@@ -0,0 +1,274 @@
<!--
System resources panel: host capacity + workload usage chart + disk
breakdown + top consumers. Drops into the dashboard as its own section.
-->
<script lang="ts">
import type { SystemStats, SystemStatsSample, ContainerStatsSample } from '$lib/types';
import * as api from '$lib/api';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
import { t } from '$lib/i18n';
let current = $state<SystemStats | null>(null);
let history = $state<SystemStatsSample[]>([]);
let top = $state<ContainerStatsSample[]>([]);
let topBy = $state<'cpu' | 'memory'>('cpu');
let window = $state<'30m' | '2h' | '6h' | '24h'>('2h');
let dockerDown = $state(false);
let otherError = $state('');
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
if (gb < 1024) return `${gb.toFixed(2)} GB`;
const tb = gb / 1024;
return `${tb.toFixed(2)} TB`;
}
async function load(signal?: AbortSignal) {
// Each request is handled independently so a 503 on `current` does
// not prevent history/top from populating (they read from SQLite
// which is available even when Docker is down).
const [currRes, histRes, topRes] = await Promise.allSettled([
api.fetchSystemStats(signal),
api.fetchSystemStatsHistory(window, signal),
api.fetchTopContainers(topBy, 5, signal)
]);
if (currRes.status === 'fulfilled') {
current = currRes.value;
dockerDown = false;
otherError = '';
} else if (!isAbort(currRes.reason)) {
if (isDockerDown(currRes.reason)) {
dockerDown = true;
otherError = '';
} else {
otherError = errorMessage(currRes.reason);
}
}
if (histRes.status === 'fulfilled') {
history = histRes.value;
}
if (topRes.status === 'fulfilled') {
top = topRes.value;
}
}
function isAbort(e: unknown): boolean {
return e instanceof DOMException && e.name === 'AbortError';
}
function isDockerDown(e: unknown): boolean {
const msg = errorMessage(e).toLowerCase();
return msg.includes('docker') && (msg.includes('not available') || msg.includes('503'));
}
function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
$effect(() => {
// Read window/topBy so this effect re-runs when they change.
void window;
void topBy;
const controller = new AbortController();
load(controller.signal);
const t = setInterval(() => load(controller.signal), 15_000);
return () => {
controller.abort();
clearInterval(t);
};
});
const chartOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 12, bottom: 24, left: 40 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
valueFormatter: (v) => {
if (typeof v !== 'number') return String(v);
return v.toFixed(1) + '%';
}
},
legend: {
data: [$t('resources.cpuSeries'), $t('resources.memorySeries')],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'time',
axisLabel: { fontSize: 10, color: '#94a3b8' }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
series: [
{
name: $t('resources.cpuSeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const cap = (s.ncpu || 1) * 100;
const pct = cap > 0 ? (s.workload_cpu_percent / cap) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('resources.memorySeries'),
type: 'line',
smooth: true,
showSymbol: false,
data: history.map((s) => {
const pct = s.memory_total > 0 ? (s.workload_mem_usage / s.memory_total) * 100 : 0;
return [s.ts * 1000, Number(pct.toFixed(2))];
}),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
</script>
{#if dockerDown}
<p class="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
{$t('resources.dockerUnavailable')}
</p>
{:else if otherError}
<p class="text-xs text-red-500">{otherError}</p>
{/if}
{#if current}
<!-- Capacity summary -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.cpuCores')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{current.ncpu}</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.memory')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{formatBytes(current.memory_total)}</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.running')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">
{current.running}<span class="text-xs text-[var(--text-tertiary)]"> / {current.containers}</span>
</div>
</div>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{$t('resources.dockerDisk')}</div>
<div class="mt-1 text-lg font-semibold text-[var(--text-primary)]">{formatBytes(current.disk_total_bytes)}</div>
</div>
</div>
<!-- History chart + window picker -->
<div class="mt-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.workloadUtilization')}</span>
<select
bind:value={window}
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
>
<option value="30m">{$t('resources.windowMinutes', { n: '30' })}</option>
<option value="2h">{$t('resources.windowHours', { n: '2' })}</option>
<option value="6h">{$t('resources.windowHours', { n: '6' })}</option>
<option value="24h">{$t('resources.windowHours', { n: '24' })}</option>
</select>
</div>
{#if history.length === 0}
<p class="py-6 text-center text-xs text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
</p>
{:else}
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('resources.workloadUtilization')} />
{/if}
</div>
<!-- Disk breakdown -->
<div class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
{#each [
{ label: $t('resources.diskImages'), size: current.disk_images_bytes, reclaim: current.disk_images_reclaimable },
{ label: $t('resources.diskContainers'), size: current.disk_containers_bytes, reclaim: current.disk_containers_reclaimable },
{ label: $t('resources.diskVolumes'), size: current.disk_volumes_bytes, reclaim: current.disk_volumes_reclaimable },
{ label: $t('resources.diskBuildCache'), size: current.disk_build_cache_bytes, reclaim: current.disk_build_cache_reclaimable }
] as d}
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{d.label}</div>
<div class="mt-1 text-sm font-semibold text-[var(--text-primary)]">{formatBytes(d.size)}</div>
{#if d.reclaim > 0}
<div class="mt-0.5 text-[10px] text-amber-500">
{$t('resources.reclaimable', { size: formatBytes(d.reclaim) })}
</div>
{/if}
</div>
{/each}
</div>
<!-- Top consumers -->
<div class="mt-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-page)] p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.topConsumers')}</span>
<div class="flex gap-1">
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] font-medium transition-colors {topBy === 'cpu'
? 'bg-[var(--surface-card-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => (topBy = 'cpu')}
>
{$t('resources.byCpu')}
</button>
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] font-medium transition-colors {topBy === 'memory'
? 'bg-[var(--surface-card-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => (topBy = 'memory')}
>
{$t('resources.byMemory')}
</button>
</div>
</div>
{#if top.length === 0}
<p class="py-2 text-center text-xs text-[var(--text-tertiary)]">{$t('resources.noRunning')}</p>
{:else}
<div class="space-y-1">
{#each top as s}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate text-[var(--text-secondary)]">
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
{s.owner_type === 'site' ? $t('resources.site') : $t('resources.instance')}
</span>
<span class="ml-2 font-mono text-[10px] text-[var(--text-tertiary)]">
{s.container_id.slice(0, 12)}
</span>
</span>
<span class="tabular-nums text-[var(--text-primary)]">
{#if topBy === 'cpu'}
{s.cpu_percent.toFixed(1)}%
{:else}
{formatBytes(s.memory_usage)}
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if !dockerDown && !otherError}
<p class="text-xs text-[var(--text-tertiary)]">{$t('resources.loading')}</p>
{/if}
+41 -1
View File
@@ -41,7 +41,47 @@
"failedSites": "failed",
"noSites": "No static sites yet.",
"addFirstSite": "Deploy your first site",
"viewAllSites": "View all sites"
"viewAllSites": "View all sites",
"systemHealth": "System health",
"daemons": "Daemons",
"systemResources": "System resources",
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers"
},
"resources": {
"cpuCores": "CPU Cores",
"memory": "Memory",
"running": "Running",
"dockerDisk": "Docker Disk",
"workloadUtilization": "Workload utilization",
"windowMinutes": "{n} minutes",
"windowHours": "{n} hours",
"noSamples": "No samples yet — the collector samples every {interval}s.",
"diskImages": "Images",
"diskContainers": "Containers",
"diskVolumes": "Volumes",
"diskBuildCache": "Build cache",
"reclaimable": "{size} reclaimable",
"topConsumers": "Top consumers",
"byCpu": "by cpu",
"byMemory": "by memory",
"noRunning": "No running containers.",
"instance": "instance",
"site": "site",
"showHistory": "Show history",
"hideHistory": "Hide history",
"cpuSeries": "CPU %",
"memorySeries": "Memory %",
"loading": "Loading…",
"sectionTitle": "Resources",
"showLogs": "Show logs",
"hideLogs": "Hide logs",
"dockerUnavailable": "Docker is unavailable. Check that the daemon is running."
},
"statsSettings": {
"intervalLabel": "Stats collection interval (s)",
"intervalHelp": "How often resource samples are collected. 0 disables collection. Range: 5300s.",
"retentionLabel": "Stats retention (hours)",
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 024h."
},
"projects": {
"title": "Projects",
+41 -1
View File
@@ -41,7 +41,47 @@
"failedSites": "с ошибкой",
"noSites": "Статических сайтов пока нет.",
"addFirstSite": "Разверните первый сайт",
"viewAllSites": "Все сайты"
"viewAllSites": "Все сайты",
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
},
"resources": {
"cpuCores": "Ядра CPU",
"memory": "Память",
"running": "Запущено",
"dockerDisk": "Диск Docker",
"workloadUtilization": "Использование нагрузкой",
"windowMinutes": "{n} минут",
"windowHours": "{n} часов",
"noSamples": "Пока нет данных — сбор идёт каждые {interval}с.",
"diskImages": "Образы",
"diskContainers": "Контейнеры",
"diskVolumes": "Тома",
"diskBuildCache": "Кэш сборки",
"reclaimable": "{size} можно освободить",
"topConsumers": "Топ потребителей",
"byCpu": "по CPU",
"byMemory": "по памяти",
"noRunning": "Нет запущенных контейнеров.",
"instance": "экземпляр",
"site": "сайт",
"showHistory": "Показать историю",
"hideHistory": "Скрыть историю",
"cpuSeries": "CPU %",
"memorySeries": "Память %",
"loading": "Загрузка…",
"sectionTitle": "Ресурсы",
"showLogs": "Показать логи",
"hideLogs": "Скрыть логи",
"dockerUnavailable": "Docker недоступен. Проверьте, что демон запущен."
},
"statsSettings": {
"intervalLabel": "Интервал сбора статистики (с)",
"intervalHelp": "Как часто собираются замеры ресурсов. 0 отключает сбор. Диапазон: 5–300с.",
"retentionLabel": "Хранение статистики (часы)",
"retentionHelp": "Как долго хранятся замеры ресурсов. 0 отключает сбор. Диапазон: 0–24ч."
},
"projects": {
"title": "Проекты",
+55 -1
View File
@@ -130,6 +130,8 @@ export interface Settings {
backup_enabled: boolean;
backup_interval_hours: number;
backup_retention_count: number;
stats_interval_seconds: number;
stats_retention_hours: number;
updated_at: string;
}
@@ -462,11 +464,63 @@ export interface FolderEntry {
is_dir: boolean;
}
/** Container CPU and memory stats from the Docker stats API. */
/** Container CPU, memory, network, and block I/O stats from the Docker stats API. */
export interface ContainerStats {
timestamp?: string;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
memory_percent: number;
network_rx_bytes?: number;
network_tx_bytes?: number;
block_read_bytes?: number;
block_write_bytes?: number;
}
/** One persisted container sample returned by the history endpoints. */
export interface ContainerStatsSample {
container_id: string;
owner_type: 'instance' | 'site';
owner_id: string;
ts: number;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
network_rx_bytes: number;
network_tx_bytes: number;
block_read_bytes: number;
block_write_bytes: number;
}
/** Host-level snapshot returned by /api/system/stats. */
export interface SystemStats {
timestamp: string;
ncpu: number;
memory_total: number;
containers: number;
running: number;
paused: number;
stopped: number;
images: number;
disk_images_bytes: number;
disk_containers_bytes: number;
disk_volumes_bytes: number;
disk_build_cache_bytes: number;
disk_images_reclaimable: number;
disk_containers_reclaimable: number;
disk_volumes_reclaimable: number;
disk_build_cache_reclaimable: number;
disk_total_bytes: number;
}
/** One persisted system sample returned by /api/system/stats/history. */
export interface SystemStatsSample {
ts: number;
ncpu: number;
memory_total: number;
workload_cpu_percent: number;
workload_mem_usage: number;
containers_running: number;
disk_total_bytes: number;
}
+36 -50
View File
@@ -6,6 +6,8 @@
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';
@@ -181,25 +183,40 @@
{/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}
<section class="section">
<div class="section-head">
<h2 class="section-title">{$t('dashboard.staticSites')}<span class="accent">.</span></h2>
{#snippet sitesActions()}
{#if sites.length > 0}
<a href="/sites" class="section-more">
{$t('dashboard.viewAllSites')} <span class="arrow"></span>
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
{$t('dashboard.viewAllSites')}
</a>
{/if}
</div>
{/snippet}
<CollapsibleSection
id="dashboard-sites"
title={$t('dashboard.staticSites')}
badge={sites.length > 0 ? String(sites.length) : ''}
actions={sitesActions}
>
{#if sites.length === 0}
<div class="mt-4">
<EmptyState
title={$t('dashboard.noSites')}
description={$t('dashboard.addFirstSite')}
@@ -207,9 +224,8 @@
actionHref="/sites"
icon="projects"
/>
</div>
{:else}
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<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)]">
@@ -230,21 +246,23 @@
{/each}
</div>
{/if}
</section>
</CollapsibleSection>
{/if}
<!-- Project cards -->
<section class="section">
<h2 class="section-title">{$t('dashboard.projects')}<span class="accent">.</span></h2>
<CollapsibleSection
id="dashboard-projects"
title={$t('dashboard.projects')}
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
>
{#if loading}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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="mt-4 rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<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"
@@ -255,7 +273,6 @@
</button>
</div>
{:else if projects.length === 0}
<div class="mt-4">
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
@@ -263,15 +280,14 @@
actionHref="/projects"
icon="projects"
/>
</div>
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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}
</section>
</CollapsibleSection>
</div>
<style>
@@ -297,34 +313,4 @@
: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; }
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1rem;
}
.section-title {
font-family: var(--font-family-sans);
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0 0 1rem;
}
.section-title .accent {
color: var(--color-brand-600);
font-weight: 700;
}
.section-more {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-brand-600);
text-decoration: none;
}
.section-more .arrow { display: inline-block; transition: transform 150ms ease; }
.section-more:hover .arrow { transform: translateX(3px); }
</style>
@@ -21,6 +21,8 @@
let staleThresholdDays = $state('7');
let imagePruneThresholdMb = $state('1024');
let statsIntervalSeconds = $state('15');
let statsRetentionHours = $state('2');
async function load() {
loading = true;
@@ -28,6 +30,8 @@
const s = await getSettings();
staleThresholdDays = String(s.stale_threshold_days ?? 7);
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
statsRetentionHours = String(s.stats_retention_hours ?? 2);
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
@@ -38,9 +42,20 @@
async function handleSave() {
saving = true;
try {
const intervalParsed = parseInt(statsIntervalSeconds, 10);
const retentionParsed = parseInt(statsRetentionHours, 10);
// Interval 0 disables collection; otherwise clamp to [5, 300].
const interval = isNaN(intervalParsed)
? 15
: intervalParsed === 0
? 0
: Math.max(5, Math.min(300, intervalParsed));
const retention = isNaN(retentionParsed) ? 2 : Math.max(0, Math.min(24, retentionParsed));
await updateSettings({
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0)
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0),
stats_interval_seconds: interval,
stats_retention_hours: retention
} as any);
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
@@ -99,6 +114,22 @@
placeholder="1024"
helpText={$t('settings.pruneThresholdHelp')}
/>
<FormField
label={$t('statsSettings.intervalLabel')}
name="statsIntervalSeconds"
type="number"
bind:value={statsIntervalSeconds}
placeholder="15"
helpText={$t('statsSettings.intervalHelp')}
/>
<FormField
label={$t('statsSettings.retentionLabel')}
name="statsRetentionHours"
type="number"
bind:value={statsRetentionHours}
placeholder="2"
helpText={$t('statsSettings.retentionHelp')}
/>
</div>
<div class="mt-6">
+27
View File
@@ -9,6 +9,8 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
let site = $state<StaticSite | null>(null);
@@ -26,6 +28,7 @@
let secretEncrypted = $state(true);
let secretSubmitting = $state(false);
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
let showLogs = $state(false);
const siteId = $derived($page.params.id);
@@ -251,6 +254,30 @@
{/if}
</div>
<!-- Resource usage + logs for deployed sites. -->
{#if site.container_id}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('resources.sectionTitle')}</h2>
<button
type="button"
onclick={() => { showLogs = !showLogs; }}
class="rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
{showLogs ? $t('resources.hideLogs') : $t('resources.showLogs')}
</button>
</div>
<ContainerStats source={{ kind: 'site', siteId: site.id }} />
</div>
{#if showLogs}
<ContainerLogs
source={{ kind: 'site', siteId: site.id }}
onclose={() => { showLogs = false; }}
/>
{/if}
{/if}
<!-- Webhook -->
<WebhookPanel
title={$t('sites.webhookTitle')}