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/.
This commit is contained in:
2026-06-19 16:22:12 +03:00
parent c8e71a0c34
commit 0c4c338bfe
23 changed files with 1828 additions and 0 deletions
+78
View File
@@ -250,6 +250,84 @@ func TestDispatchReconcile_PropagatesSourceError(t *testing.T) {
}
}
// ---- Deploy history recording ----------------------------------------------
// seedDispatchWorkload inserts a real workloads row so deploy_history's FK
// (workload_id REFERENCES workloads) is satisfied, then returns a plugin
// workload pointing at the fake source.
func seedDispatchWorkload(t *testing.T, d *Deployer) plugin.Workload {
t.Helper()
row, err := d.store.CreateWorkload(store.Workload{Kind: "project", RefID: "dh", Name: "dh"})
if err != nil {
t.Fatalf("CreateWorkload: %v", err)
}
return plugin.Workload{ID: row.ID, Name: "dh", SourceKind: "dispatchertest"}
}
func TestDispatchPlugin_RecordsSuccessHistory(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
intent := plugin.DeploymentIntent{Reason: "manual", Reference: "v9", TriggeredBy: "alice",
Metadata: map[string]string{"note": "ship it"}}
if err := d.DispatchPlugin(context.Background(), w, intent); err != nil {
t.Fatalf("DispatchPlugin: %v", err)
}
rows, err := d.store.ListDeployHistory(w.ID, 10, 0)
if err != nil {
t.Fatalf("ListDeployHistory: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 history row, got %d", len(rows))
}
got := rows[0]
if got.Outcome != "success" || got.Reason != "manual" || got.Reference != "v9" {
t.Fatalf("unexpected row: %+v", got)
}
if got.TriggeredBy != "alice" || got.Note != "ship it" {
t.Fatalf("intent fields not recorded: %+v", got)
}
if got.Error != "" {
t.Fatalf("success row must have empty error, got %q", got.Error)
}
}
func TestDispatchPlugin_RecordsFailureWithoutLeakingError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
// A deploy error carrying a "secret" must never reach the persisted row.
dispatchTestSource.setDeployErr(errors.New("compose up failed (output: SUPER_SECRET=hunter2)"))
_ = d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{Reason: "manual"})
rows, _ := d.store.ListDeployHistory(w.ID, 10, 0)
if len(rows) != 1 {
t.Fatalf("expected 1 history row, got %d", len(rows))
}
if rows[0].Outcome != "failure" {
t.Fatalf("expected failure outcome, got %q", rows[0].Outcome)
}
if strings.Contains(rows[0].Error, "hunter2") || strings.Contains(rows[0].Error, "SECRET") {
t.Fatalf("raw error leaked into history: %q", rows[0].Error)
}
}
func TestDispatchReconcile_RecordsNoHistory(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
if err := d.DispatchReconcile(context.Background(), w); err != nil {
t.Fatalf("DispatchReconcile: %v", err)
}
rows, _ := d.store.ListDeployHistory(w.ID, 10, 0)
if len(rows) != 0 {
t.Fatalf("reconcile must not write history, got %d rows", len(rows))
}
}
// ---- PluginDeps -------------------------------------------------------------
func TestPluginDeps_PassesStoreAndEncKey(t *testing.T) {