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/.
65 lines
2.0 KiB
Go
65 lines
2.0 KiB
Go
package api
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
func TestAggregateWorkloadStats_SumsPerTimestamp(t *testing.T) {
|
|
// Two containers reporting at the same two ticks → summed per ts.
|
|
samples := []store.ContainerStatsSample{
|
|
{TS: 100, CPUPercent: 10, MemoryUsage: 1000, MemoryLimit: 4000},
|
|
{TS: 100, CPUPercent: 5, MemoryUsage: 500, MemoryLimit: 8000},
|
|
{TS: 200, CPUPercent: 20, MemoryUsage: 2000, MemoryLimit: 4000},
|
|
}
|
|
pts := aggregateWorkloadStats(samples)
|
|
if len(pts) != 2 {
|
|
t.Fatalf("expected 2 buckets, got %d", len(pts))
|
|
}
|
|
if pts[0].TS != 100 || pts[0].CPUPercent != 15 || pts[0].MemoryUsage != 1500 {
|
|
t.Fatalf("ts=100 bucket wrong: %+v", pts[0])
|
|
}
|
|
// Memory limit takes the max across containers.
|
|
if pts[0].MemoryLimit != 8000 {
|
|
t.Fatalf("expected max memory limit 8000, got %d", pts[0].MemoryLimit)
|
|
}
|
|
if pts[1].TS != 200 || pts[1].CPUPercent != 20 {
|
|
t.Fatalf("ts=200 bucket wrong: %+v", pts[1])
|
|
}
|
|
}
|
|
|
|
func TestAggregateWorkloadStats_Empty(t *testing.T) {
|
|
pts := aggregateWorkloadStats(nil)
|
|
if pts == nil {
|
|
t.Fatal("expected non-nil empty slice for clean JSON []")
|
|
}
|
|
if len(pts) != 0 {
|
|
t.Fatalf("expected 0 points, got %d", len(pts))
|
|
}
|
|
}
|
|
|
|
func TestWorkloadStatsHistory_UnknownWorkload404(t *testing.T) {
|
|
e := newAPITestEnv(t)
|
|
resp := e.do(t, "GET", "/api/workloads/nope/stats/history", nil)
|
|
if resp.StatusCode != 404 {
|
|
t.Fatalf("expected 404 for unknown workload, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestWorkloadStatsHistory_KnownWorkloadEmpty(t *testing.T) {
|
|
e := newAPITestEnv(t)
|
|
id := createImageWorkload(t, e, "metrics-app")
|
|
resp := e.do(t, "GET", "/api/workloads/"+id+"/stats/history", nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var pts []workloadStatsPoint
|
|
if errMsg := decodeEnvelope(t, resp, &pts); errMsg != "" {
|
|
t.Fatalf("envelope error: %q", errMsg)
|
|
}
|
|
if len(pts) != 0 {
|
|
t.Fatalf("expected empty series for app with no samples, got %d", len(pts))
|
|
}
|
|
}
|