410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
335 lines
9.4 KiB
Go
335 lines
9.4 KiB
Go
// 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
|
|
|
|
startOnce sync.Once
|
|
stopOnce sync.Once
|
|
started bool
|
|
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. Safe to call multiple times — only the first call has
|
|
// an effect.
|
|
func (c *Collector) Start() {
|
|
c.startOnce.Do(func() {
|
|
c.started = true
|
|
go c.run()
|
|
})
|
|
}
|
|
|
|
// Stop signals the collector to exit and blocks until it has finished the
|
|
// in-flight tick. If Start was never called, Stop returns immediately.
|
|
func (c *Collector) Stop() {
|
|
c.stopOnce.Do(func() {
|
|
close(c.stop)
|
|
if !c.started {
|
|
close(c.done)
|
|
}
|
|
})
|
|
<-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)
|
|
|
|
// Derive a base context that's cancelled when Stop is called so in-flight
|
|
// Docker requests abort instead of waiting out their timeout.
|
|
baseCtx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() {
|
|
<-c.stop
|
|
cancel()
|
|
}()
|
|
|
|
// 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(baseCtx, 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(parent context.Context, retentionHours int) {
|
|
ctx, cancel := context.WithTimeout(parent, 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 container rows that have a docker container_id bound.
|
|
// Project containers and stack containers are surfaced as OwnerTypeInstance
|
|
// (the stats sample owner_type is kept for back-compat with the persisted
|
|
// schema and the dashboard's group-by semantics).
|
|
func (c *Collector) buildTargets() []target {
|
|
var out []target
|
|
|
|
containers, err := c.store.ListContainers(store.ContainerFilter{})
|
|
if err != nil {
|
|
slog.Warn("stats collector: list containers", "error", err)
|
|
} else {
|
|
for _, row := range containers {
|
|
if row.ContainerID == "" {
|
|
continue
|
|
}
|
|
ownerType := OwnerTypeInstance
|
|
if row.WorkloadKind == string(store.WorkloadKindSite) {
|
|
ownerType = OwnerTypeSite
|
|
}
|
|
out = append(out, target{
|
|
ContainerID: row.ContainerID,
|
|
OwnerType: ownerType,
|
|
OwnerID: row.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
|
|
loop:
|
|
for i, t := range targets {
|
|
// Acquire the semaphore in the parent loop so ctx cancellation
|
|
// short-circuits the queue rather than spawning goroutines that
|
|
// block on an unreachable slot. The labelled break exits the for
|
|
// loop directly; a bare `break` inside `select` would only break
|
|
// the select and let the loop continue.
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
break loop
|
|
}
|
|
wg.Add(1)
|
|
go func(i int, t target) {
|
|
defer wg.Done()
|
|
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
|
|
}
|
|
ts := sysStats.Timestamp.Unix()
|
|
if ts <= 0 {
|
|
ts = time.Now().UTC().Unix()
|
|
}
|
|
sample := store.SystemStatsSample{
|
|
TS: ts,
|
|
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)
|
|
}
|
|
}
|