0c4c338bfe
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/.
57 lines
1.8 KiB
Go
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)
|
|
}
|
|
}
|