feat(stats): resource metrics dashboard + sites logs/stats
Build / build (push) Successful in 10m50s
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:
@@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/registry"
|
"github.com/alexei/tinyforge/internal/registry"
|
||||||
"github.com/alexei/tinyforge/internal/stale"
|
"github.com/alexei/tinyforge/internal/stale"
|
||||||
"github.com/alexei/tinyforge/internal/stack"
|
"github.com/alexei/tinyforge/internal/stack"
|
||||||
|
"github.com/alexei/tinyforge/internal/stats"
|
||||||
"github.com/alexei/tinyforge/internal/staticsite"
|
"github.com/alexei/tinyforge/internal/staticsite"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
"github.com/alexei/tinyforge/internal/webhook"
|
"github.com/alexei/tinyforge/internal/webhook"
|
||||||
@@ -276,6 +277,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
|
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.
|
// Initialize static site manager and health checker.
|
||||||
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
|
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
|
||||||
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
|
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
|
||||||
@@ -364,6 +371,7 @@ func main() {
|
|||||||
staticSiteHealth.Stop()
|
staticSiteHealth.Stop()
|
||||||
staleScanner.Stop()
|
staleScanner.Stop()
|
||||||
poller.Stop()
|
poller.Stop()
|
||||||
|
statsCollector.Stop()
|
||||||
|
|
||||||
// Drain in-progress deploys and notifications.
|
// Drain in-progress deploys and notifications.
|
||||||
dep.Drain()
|
dep.Drain()
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if s.docker == nil {
|
||||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
||||||
return
|
return
|
||||||
@@ -83,9 +90,9 @@ func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get("Accept")
|
||||||
isSSE := strings.Contains(accept, "text/event-stream")
|
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 {
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to get container logs")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/stages/{stage}/env", s.listStageEnv)
|
r.Get("/stages/{stage}/env", s.listStageEnv)
|
||||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
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("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
|
||||||
r.Get("/images", s.listProjectImages)
|
r.Get("/images", s.listProjectImages)
|
||||||
r.Get("/volumes", s.listVolumes)
|
r.Get("/volumes", s.listVolumes)
|
||||||
@@ -288,6 +289,9 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/", s.getStaticSite)
|
r.Get("/", s.getStaticSite)
|
||||||
r.Get("/secrets", s.listStaticSiteSecrets)
|
r.Get("/secrets", s.listStaticSiteSecrets)
|
||||||
r.Get("/storage", s.getStaticSiteStorage)
|
r.Get("/storage", s.getStaticSiteStorage)
|
||||||
|
r.Get("/logs", s.streamStaticSiteLogs)
|
||||||
|
r.Get("/stats", s.getStaticSiteStats)
|
||||||
|
r.Get("/stats/history", s.getStaticSiteStatsHistory)
|
||||||
|
|
||||||
// Admin-only mutations.
|
// Admin-only mutations.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -333,6 +337,11 @@ func (s *Server) Router() chi.Router {
|
|||||||
// Stale container endpoints (read).
|
// Stale container endpoints (read).
|
||||||
r.Get("/containers/stale", s.listStaleContainers)
|
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.
|
// Admin-only routes: require admin role.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.AdminOnly)
|
r.Use(auth.AdminOnly)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type settingsRequest struct {
|
|||||||
BackupEnabled *bool `json:"backup_enabled,omitempty"`
|
BackupEnabled *bool `json:"backup_enabled,omitempty"`
|
||||||
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
|
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
|
||||||
BackupRetentionCount *int `json:"backup_retention_count,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.
|
// getSettings handles GET /api/settings.
|
||||||
@@ -86,6 +88,8 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"backup_enabled": settings.BackupEnabled,
|
"backup_enabled": settings.BackupEnabled,
|
||||||
"backup_interval_hours": settings.BackupIntervalHours,
|
"backup_interval_hours": settings.BackupIntervalHours,
|
||||||
"backup_retention_count": settings.BackupRetentionCount,
|
"backup_retention_count": settings.BackupRetentionCount,
|
||||||
|
"stats_interval_seconds": settings.StatsIntervalSeconds,
|
||||||
|
"stats_retention_hours": settings.StatsRetentionHours,
|
||||||
"updated_at": settings.UpdatedAt,
|
"updated_at": settings.UpdatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -238,6 +242,22 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
updated.BackupRetentionCount = *req.BackupRetentionCount
|
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 {
|
if err := s.store.UpdateSettings(updated); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-10
@@ -4,21 +4,30 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/client"
|
"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 {
|
type ContainerStats struct {
|
||||||
CPUPercent float64 `json:"cpu_percent"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
MemoryUsage int64 `json:"memory_usage"`
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
MemoryLimit int64 `json:"memory_limit"`
|
MemoryUsage int64 `json:"memory_usage"`
|
||||||
MemoryPercent float64 `json:"memory_percent"`
|
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
|
// 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) {
|
func (c *Client) GetContainerStats(ctx context.Context, containerID string) (ContainerStats, error) {
|
||||||
result, err := c.api.ContainerStats(ctx, containerID, client.ContainerStatsOptions{
|
result, err := c.api.ContainerStats(ctx, containerID, client.ContainerStatsOptions{
|
||||||
Stream: false,
|
Stream: false,
|
||||||
@@ -42,14 +51,53 @@ func (c *Client) GetContainerStats(ctx context.Context, containerID string) (Con
|
|||||||
memPercent = float64(memUsage) / float64(memLimit) * 100.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{
|
return ContainerStats{
|
||||||
CPUPercent: cpuPercent,
|
Timestamp: ts,
|
||||||
MemoryUsage: memUsage,
|
CPUPercent: cpuPercent,
|
||||||
MemoryLimit: memLimit,
|
MemoryUsage: memUsage,
|
||||||
MemoryPercent: memPercent,
|
MemoryLimit: memLimit,
|
||||||
|
MemoryPercent: memPercent,
|
||||||
|
NetworkRxBytes: rxBytes,
|
||||||
|
NetworkTxBytes: txBytes,
|
||||||
|
BlockReadBytes: readBytes,
|
||||||
|
BlockWriteBytes: writeBytes,
|
||||||
}, nil
|
}, 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
|
// calculateCPUPercent computes CPU usage percentage from a stats response
|
||||||
// using the delta between current and previous CPU readings.
|
// using the delta between current and previous CPU readings.
|
||||||
func calculateCPUPercent(stats container.StatsResponse) float64 {
|
func calculateCPUPercent(stats container.StatsResponse) float64 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,9 +78,40 @@ type Settings struct {
|
|||||||
BackupEnabled bool `json:"backup_enabled"`
|
BackupEnabled bool `json:"backup_enabled"`
|
||||||
BackupIntervalHours int `json:"backup_interval_hours"`
|
BackupIntervalHours int `json:"backup_interval_hours"`
|
||||||
BackupRetentionCount int `json:"backup_retention_count"`
|
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"`
|
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.
|
// Backup represents a backup metadata record.
|
||||||
type Backup struct {
|
type Backup struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
||||||
image_prune_threshold_mb,
|
image_prune_threshold_mb,
|
||||||
backup_enabled, backup_interval_hours, backup_retention_count,
|
backup_enabled, backup_interval_hours, backup_retention_count,
|
||||||
|
stats_interval_seconds, stats_retention_hours,
|
||||||
updated_at
|
updated_at
|
||||||
FROM settings WHERE id = 1`,
|
FROM settings WHERE id = 1`,
|
||||||
).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
).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.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
|
||||||
&st.ImagePruneThresholdMB,
|
&st.ImagePruneThresholdMB,
|
||||||
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
||||||
|
&st.StatsIntervalSeconds, &st.StatsRetentionHours,
|
||||||
&st.UpdatedAt)
|
&st.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, fmt.Errorf("query settings: %w", err)
|
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=?,
|
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
|
||||||
image_prune_threshold_mb=?,
|
image_prune_threshold_mb=?,
|
||||||
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
||||||
|
stats_interval_seconds=?, stats_retention_hours=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
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.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
|
||||||
st.ImagePruneThresholdMB,
|
st.ImagePruneThresholdMB,
|
||||||
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
||||||
|
st.StatsIntervalSeconds, st.StatsRetentionHours,
|
||||||
st.UpdatedAt,
|
st.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -133,10 +133,46 @@ func (s *Store) runMigrations() error {
|
|||||||
// avoid a destructive migration on SQLite.
|
// avoid a destructive migration on SQLite.
|
||||||
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE static_sites 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
|
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||||
// schema constant so older databases pick them up on restart.
|
// 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{
|
stackTables := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS stacks (
|
`CREATE TABLE IF NOT EXISTS stacks (
|
||||||
id TEXT PRIMARY KEY,
|
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 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_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 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 {
|
for _, idx := range indexes {
|
||||||
if _, err := s.db.Exec(idx); err != nil {
|
if _, err := s.db.Exec(idx); err != nil {
|
||||||
|
|||||||
Generated
+46
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/instrument-serif": "^5.2.8",
|
"@fontsource/instrument-serif": "^5.2.8",
|
||||||
"@fontsource/inter": "^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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
@@ -1335,6 +1336,15 @@
|
|||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.1",
|
"version": "5.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||||
@@ -2013,6 +2023,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true
|
"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": {
|
"dependencies": {
|
||||||
@@ -2833,6 +2856,15 @@
|
|||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||||
"dev": true
|
"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": {
|
"enhanced-resolve": {
|
||||||
"version": "5.20.1",
|
"version": "5.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||||
@@ -3231,6 +3263,11 @@
|
|||||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
},
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true
|
"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
@@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/instrument-serif": "^5.2.8",
|
"@fontsource/instrument-serif": "^5.2.8",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8"
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"echarts": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
ApiEnvelope,
|
ApiEnvelope,
|
||||||
ContainerStats,
|
ContainerStats,
|
||||||
|
ContainerStatsSample,
|
||||||
|
SystemStats,
|
||||||
|
SystemStatsSample,
|
||||||
Deploy,
|
Deploy,
|
||||||
DeployLog,
|
DeployLog,
|
||||||
DockerHealth,
|
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 ──────────────────────────────────────────────────────
|
// ── Static Sites ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
|
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>
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
<!--
|
<!--
|
||||||
Container log viewer with tail line limit and auto-scroll.
|
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">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { fetchContainerLogs } from '$lib/api';
|
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api';
|
||||||
import { getAuthToken } from '$lib/auth';
|
import { getAuthToken } from '$lib/auth';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconX } from '$lib/components/icons';
|
import { IconLoader, IconX } from '$lib/components/icons';
|
||||||
|
|
||||||
|
export type LogSource =
|
||||||
|
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
|
||||||
|
| { kind: 'site'; siteId: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
source: LogSource;
|
||||||
stageId: string;
|
|
||||||
instanceId: string;
|
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId, stageId, instanceId, onclose }: Props = $props();
|
const { source, onclose }: Props = $props();
|
||||||
|
|
||||||
let lines = $state<string[]>([]);
|
let lines = $state<string[]>([]);
|
||||||
let loading = $state(true);
|
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() {
|
async function loadLogs() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
lines = await fetchContainerLogs(projectId, stageId, instanceId, tailCount);
|
lines = await fetchLogs(tailCount);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to load logs';
|
error = err instanceof Error ? err.message : 'Failed to load logs';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,8 +85,7 @@
|
|||||||
if (eventSource) return;
|
if (eventSource) return;
|
||||||
following = true;
|
following = true;
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
const url = `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?follow=true&tail=0&token=${token}`;
|
eventSource = new EventSource(buildFollowUrl(token));
|
||||||
eventSource = new EventSource(url);
|
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +106,6 @@
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
// Flush any buffered lines before stopping.
|
|
||||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
flushPendingLines();
|
flushPendingLines();
|
||||||
following = false;
|
following = false;
|
||||||
@@ -108,7 +126,6 @@
|
|||||||
loadLogs();
|
loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount.
|
|
||||||
$effect(() => { loadLogs(); });
|
$effect(() => { loadLogs(); });
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -166,7 +183,7 @@
|
|||||||
{:else if lines.length === 0}
|
{:else if lines.length === 0}
|
||||||
<p class="text-gray-500">{$t('logs.noLogs')}</p>
|
<p class="text-gray-500">{$t('logs.noLogs')}</p>
|
||||||
{:else}
|
{: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>
|
<div class="hover:bg-gray-900/50 px-1 -mx-1 rounded whitespace-pre-wrap break-all">{line}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import type { ContainerStats } from '$lib/types';
|
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
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 {
|
interface Props {
|
||||||
projectId: string;
|
source: StatsSource;
|
||||||
stageId: string;
|
historyWindow?: '30m' | '2h' | '6h' | '24h';
|
||||||
instanceId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId, stageId, instanceId }: Props = $props();
|
const { source, historyWindow = '2h' }: Props = $props();
|
||||||
|
|
||||||
let stats = $state<ContainerStats | null>(null);
|
let stats = $state<ContainerStats | null>(null);
|
||||||
|
let history = $state<ContainerStatsSample[]>([]);
|
||||||
let error = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
let controller = new AbortController();
|
let controller = new AbortController();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// Abort any previous in-flight request before starting a new one.
|
|
||||||
controller.abort();
|
controller.abort();
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
try {
|
try {
|
||||||
const result = await api.fetchContainerStats(projectId, stageId, instanceId, controller.signal);
|
const result = await fetchStats(controller.signal);
|
||||||
stats = result;
|
stats = result;
|
||||||
error = false;
|
error = false;
|
||||||
|
if (expanded) {
|
||||||
|
history = await fetchHistory(controller.signal);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
error = true;
|
error = true;
|
||||||
@@ -35,8 +66,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
|
||||||
// Poll every 30 seconds (reduced from 10s to limit concurrent connections).
|
|
||||||
const interval = setInterval(load, 30_000);
|
const interval = setInterval(load, 30_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -68,6 +97,51 @@
|
|||||||
if (stats.memory_percent > 50) return 'bg-amber-500';
|
if (stats.memory_percent > 50) return 'bg-amber-500';
|
||||||
return 'bg-blue-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>
|
</script>
|
||||||
|
|
||||||
{#if stats}
|
{#if stats}
|
||||||
@@ -98,6 +172,26 @@
|
|||||||
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
|
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="mt-2 text-[10px] text-[var(--text-tertiary)]">{$t('stats.unavailable')}</p>
|
<p class="mt-2 text-[10px] text-[var(--text-tertiary)]">{$t('stats.unavailable')}</p>
|
||||||
|
|||||||
@@ -147,15 +147,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if instance.status === 'running'}
|
{#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}
|
||||||
|
|
||||||
{#if showLogs}
|
{#if showLogs}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<ContainerLogs
|
<ContainerLogs
|
||||||
{projectId}
|
source={{ kind: 'instance', projectId, stageId: instance.stage_id, instanceId: instance.id }}
|
||||||
stageId={instance.stage_id}
|
|
||||||
instanceId={instance.id}
|
|
||||||
onclose={() => { showLogs = false; }}
|
onclose={() => { showLogs = false; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,7 +41,47 @@
|
|||||||
"failedSites": "failed",
|
"failedSites": "failed",
|
||||||
"noSites": "No static sites yet.",
|
"noSites": "No static sites yet.",
|
||||||
"addFirstSite": "Deploy your first site",
|
"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: 5–300s.",
|
||||||
|
"retentionLabel": "Stats retention (hours)",
|
||||||
|
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 0–24h."
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Projects",
|
"title": "Projects",
|
||||||
|
|||||||
@@ -41,7 +41,47 @@
|
|||||||
"failedSites": "с ошибкой",
|
"failedSites": "с ошибкой",
|
||||||
"noSites": "Статических сайтов пока нет.",
|
"noSites": "Статических сайтов пока нет.",
|
||||||
"addFirstSite": "Разверните первый сайт",
|
"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": {
|
"projects": {
|
||||||
"title": "Проекты",
|
"title": "Проекты",
|
||||||
|
|||||||
+55
-1
@@ -130,6 +130,8 @@ export interface Settings {
|
|||||||
backup_enabled: boolean;
|
backup_enabled: boolean;
|
||||||
backup_interval_hours: number;
|
backup_interval_hours: number;
|
||||||
backup_retention_count: number;
|
backup_retention_count: number;
|
||||||
|
stats_interval_seconds: number;
|
||||||
|
stats_retention_hours: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,11 +464,63 @@ export interface FolderEntry {
|
|||||||
is_dir: boolean;
|
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 {
|
export interface ContainerStats {
|
||||||
|
timestamp?: string;
|
||||||
cpu_percent: number;
|
cpu_percent: number;
|
||||||
memory_usage: number;
|
memory_usage: number;
|
||||||
memory_limit: number;
|
memory_limit: number;
|
||||||
memory_percent: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-69
@@ -6,6 +6,8 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||||
import SystemDaemonsCard from '$lib/components/SystemDaemonsCard.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 ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -181,35 +183,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- System health summary -->
|
<!-- System health summary -->
|
||||||
<SystemHealthCard />
|
<CollapsibleSection id="system-health" title={$t('dashboard.systemHealth')}>
|
||||||
|
<SystemHealthCard />
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
|
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
|
||||||
<SystemDaemonsCard />
|
<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 -->
|
<!-- Static sites summary -->
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
<section class="section">
|
{#snippet sitesActions()}
|
||||||
<div class="section-head">
|
{#if sites.length > 0}
|
||||||
<h2 class="section-title">{$t('dashboard.staticSites')}<span class="accent">.</span></h2>
|
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
|
||||||
{#if sites.length > 0}
|
{$t('dashboard.viewAllSites')} →
|
||||||
<a href="/sites" class="section-more">
|
</a>
|
||||||
{$t('dashboard.viewAllSites')} <span class="arrow">→</span>
|
{/if}
|
||||||
</a>
|
{/snippet}
|
||||||
{/if}
|
<CollapsibleSection
|
||||||
</div>
|
id="dashboard-sites"
|
||||||
|
title={$t('dashboard.staticSites')}
|
||||||
|
badge={sites.length > 0 ? String(sites.length) : ''}
|
||||||
|
actions={sitesActions}
|
||||||
|
>
|
||||||
{#if sites.length === 0}
|
{#if sites.length === 0}
|
||||||
<div class="mt-4">
|
<EmptyState
|
||||||
<EmptyState
|
title={$t('dashboard.noSites')}
|
||||||
title={$t('dashboard.noSites')}
|
description={$t('dashboard.addFirstSite')}
|
||||||
description={$t('dashboard.addFirstSite')}
|
actionLabel={$t('sites.title')}
|
||||||
actionLabel={$t('sites.title')}
|
actionHref="/sites"
|
||||||
actionHref="/sites"
|
icon="projects"
|
||||||
icon="projects"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{: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)}
|
{#each sites as site (site.id)}
|
||||||
{@const badge = siteStatusBadge(site.status)}
|
{@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)]">
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</CollapsibleSection>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Project cards -->
|
<!-- Project cards -->
|
||||||
<section class="section">
|
<CollapsibleSection
|
||||||
<h2 class="section-title">{$t('dashboard.projects')}<span class="accent">.</span></h2>
|
id="dashboard-projects"
|
||||||
|
title={$t('dashboard.projects')}
|
||||||
|
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
|
||||||
|
>
|
||||||
{#if loading}
|
{#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 _}
|
{#each Array(3) as _}
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{: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>
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -255,23 +273,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if projects.length === 0}
|
{:else if projects.length === 0}
|
||||||
<div class="mt-4">
|
<EmptyState
|
||||||
<EmptyState
|
title={$t('empty.noProjects')}
|
||||||
title={$t('empty.noProjects')}
|
description={$t('empty.noProjectsDesc')}
|
||||||
description={$t('empty.noProjectsDesc')}
|
actionLabel={$t('empty.createProject')}
|
||||||
actionLabel={$t('empty.createProject')}
|
actionHref="/projects"
|
||||||
actionHref="/projects"
|
icon="projects"
|
||||||
icon="projects"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{: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)}
|
{#each projects as project (project.id)}
|
||||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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; }
|
: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 { 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>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
let staleThresholdDays = $state('7');
|
let staleThresholdDays = $state('7');
|
||||||
let imagePruneThresholdMb = $state('1024');
|
let imagePruneThresholdMb = $state('1024');
|
||||||
|
let statsIntervalSeconds = $state('15');
|
||||||
|
let statsRetentionHours = $state('2');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
const s = await getSettings();
|
const s = await getSettings();
|
||||||
staleThresholdDays = String(s.stale_threshold_days ?? 7);
|
staleThresholdDays = String(s.stale_threshold_days ?? 7);
|
||||||
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
|
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
|
||||||
|
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
|
||||||
|
statsRetentionHours = String(s.stats_retention_hours ?? 2);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,9 +42,20 @@
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
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({
|
await updateSettings({
|
||||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
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);
|
} as any);
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -99,6 +114,22 @@
|
|||||||
placeholder="1024"
|
placeholder="1024"
|
||||||
helpText={$t('settings.pruneThresholdHelp')}
|
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>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import WebhookPanel from '$lib/components/WebhookPanel.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';
|
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
||||||
|
|
||||||
let site = $state<StaticSite | null>(null);
|
let site = $state<StaticSite | null>(null);
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
let secretEncrypted = $state(true);
|
let secretEncrypted = $state(true);
|
||||||
let secretSubmitting = $state(false);
|
let secretSubmitting = $state(false);
|
||||||
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
|
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
|
||||||
|
let showLogs = $state(false);
|
||||||
|
|
||||||
const siteId = $derived($page.params.id);
|
const siteId = $derived($page.params.id);
|
||||||
|
|
||||||
@@ -251,6 +254,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Webhook -->
|
||||||
<WebhookPanel
|
<WebhookPanel
|
||||||
title={$t('sites.webhookTitle')}
|
title={$t('sites.webhookTitle')}
|
||||||
|
|||||||
Reference in New Issue
Block a user