Files
tiny-forge/internal/store/stats_by_workload_test.go
alexei.dolgolyov 0c4c338bfe feat(apps): per-workload deploy history, rollback, and resource metrics
Two additions to the app detail page, each backed by a per-workload
endpoint.

Deploy history + rollback:
- New deploy_history table — a structured, version-pinned ledger of every
  dispatch (success AND failure), distinct from the free-text event_log.
  Recorded at the single DispatchPlugin choke point so every source kind
  is covered. The raw deploy error is never persisted (it can carry
  registry-auth / compose-stdout secrets) — only a generic marker, with
  detail going to slog. Pruned to the newest N per workload; cascade-
  deleted with the workload.
- GET /api/workloads/{id}/deploys lists the ledger; POST .../rollback
  (admin) replays a prior successful deploy's pinned reference as a
  rollback-reason dispatch. Phase 1 is image-source only (RollbackCapable);
  git-built sources need checkout-by-commit, a later phase.
- DeployHistoryPanel.svelte renders the ledger with confirm-gated rollback.

Per-workload metrics:
- ListContainerStatsSamplesByWorkload joins the existing container stats
  samples through the containers index; GET /api/workloads/{id}/stats/history
  aggregates CPU/memory per timestamp across the workload's containers.
- WorkloadMetricsPanel.svelte reuses ResourceChart (CPU% + memory MiB,
  windowed, 15s poll).

en/ru i18n added with parity. Tests: store CRUD + cascade + workload-scoped
join, deployer recording (incl. secret-non-leak on failure), API rollback
guards, and per-timestamp aggregation. Plans under docs/plans/.
2026-06-19 16:22:12 +03:00

57 lines
1.8 KiB
Go

package store
import "testing"
func TestListContainerStatsSamplesByWorkload_ScopedToWorkload(t *testing.T) {
s := newTestStore(t)
wa := seedWorkload(t, s, "wa")
wb := seedWorkload(t, s, "wb")
ca, err := s.CreateContainer(Container{WorkloadID: wa.ID, WorkloadKind: "image", ContainerID: "da", Host: "local", State: "running"})
if err != nil {
t.Fatalf("CreateContainer a: %v", err)
}
cb, err := s.CreateContainer(Container{WorkloadID: wb.ID, WorkloadKind: "image", ContainerID: "db", Host: "local", State: "running"})
if err != nil {
t.Fatalf("CreateContainer b: %v", err)
}
// owner_id is the container ROW id.
mustInsertSample(t, s, ca.ID, 100, 12.5, 2048)
mustInsertSample(t, s, ca.ID, 200, 15.0, 3072)
mustInsertSample(t, s, cb.ID, 150, 99.0, 9999)
got, err := s.ListContainerStatsSamplesByWorkload(wa.ID, 0)
if err != nil {
t.Fatalf("ListContainerStatsSamplesByWorkload: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 samples for workload a, got %d", len(got))
}
// ts ascending.
if got[0].TS != 100 || got[1].TS != 200 {
t.Fatalf("expected ts-ascending 100,200, got %d,%d", got[0].TS, got[1].TS)
}
for _, sm := range got {
if sm.OwnerID != ca.ID {
t.Fatalf("leaked a sample from another workload: %+v", sm)
}
}
// Since-cutoff filters older samples.
recent, _ := s.ListContainerStatsSamplesByWorkload(wa.ID, 150)
if len(recent) != 1 || recent[0].TS != 200 {
t.Fatalf("expected only ts=200 after cutoff, got %+v", recent)
}
}
func mustInsertSample(t *testing.T, s *Store, ownerID string, ts int64, cpu float64, mem int64) {
t.Helper()
if err := s.InsertContainerStatsSample(ContainerStatsSample{
ContainerID: "c-" + ownerID, OwnerType: "instance", OwnerID: ownerID, TS: ts,
CPUPercent: cpu, MemoryUsage: mem, MemoryLimit: mem * 2,
}); err != nil {
t.Fatalf("InsertContainerStatsSample: %v", err)
}
}