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/.
124 lines
3.8 KiB
Go
124 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// InsertDeployHistory appends one row to the per-workload deploy ledger.
|
|
// Callers (the deployer choke point) treat this as best-effort: a failure
|
|
// here must never fail an otherwise-successful deploy. Error is expected to
|
|
// be a fixed, secret-free marker — never the raw source error.
|
|
func (s *Store) InsertDeployHistory(e DeployHistoryEntry) (DeployHistoryEntry, error) {
|
|
if e.StartedAt == "" {
|
|
e.StartedAt = Now()
|
|
}
|
|
if e.FinishedAt == "" {
|
|
e.FinishedAt = Now()
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO deploy_history
|
|
(workload_id, source_kind, reference, reason, triggered_by,
|
|
note, outcome, error, started_at, finished_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
e.WorkloadID, e.SourceKind, e.Reference, e.Reason, e.TriggeredBy,
|
|
e.Note, e.Outcome, e.Error, e.StartedAt, e.FinishedAt,
|
|
)
|
|
if err != nil {
|
|
return DeployHistoryEntry{}, fmt.Errorf("insert deploy history: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return DeployHistoryEntry{}, fmt.Errorf("get deploy history id: %w", err)
|
|
}
|
|
e.ID = id
|
|
return e, nil
|
|
}
|
|
|
|
// ListDeployHistory returns a workload's ledger newest-first. limit/offset
|
|
// are assumed pre-clamped by the API layer; a non-positive limit falls back
|
|
// to a sane default so a bad query can't return the whole table.
|
|
func (s *Store) ListDeployHistory(workloadID string, limit, offset int) ([]DeployHistoryEntry, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
|
|
note, outcome, error, started_at, finished_at
|
|
FROM deploy_history
|
|
WHERE workload_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT ? OFFSET ?`,
|
|
workloadID, limit, offset,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query deploy history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]DeployHistoryEntry, 0, limit)
|
|
for rows.Next() {
|
|
var e DeployHistoryEntry
|
|
if err := rows.Scan(
|
|
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
|
|
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan deploy history: %w", err)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetDeployHistory fetches one ledger row by id, or ErrNotFound. The
|
|
// rollback handler uses this to resolve the pinned reference to replay.
|
|
func (s *Store) GetDeployHistory(id int64) (DeployHistoryEntry, error) {
|
|
row := s.db.QueryRow(
|
|
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
|
|
note, outcome, error, started_at, finished_at
|
|
FROM deploy_history WHERE id = ?`, id,
|
|
)
|
|
var e DeployHistoryEntry
|
|
err := row.Scan(
|
|
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
|
|
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return DeployHistoryEntry{}, fmt.Errorf("deploy history %d: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return DeployHistoryEntry{}, fmt.Errorf("scan deploy history: %w", err)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// PruneDeployHistory keeps only the newest `keep` rows for a workload,
|
|
// deleting older ones. Bounds unbounded growth on hot workloads. Best-
|
|
// effort and id-monotonic (newer rows always have larger ids), so it
|
|
// deletes everything below the keep-th id. A non-positive keep is treated
|
|
// as "keep a sane default" rather than "delete everything".
|
|
func (s *Store) PruneDeployHistory(workloadID string, keep int) error {
|
|
if keep <= 0 {
|
|
keep = 50
|
|
}
|
|
_, err := s.db.Exec(
|
|
`DELETE FROM deploy_history
|
|
WHERE workload_id = ?
|
|
AND id NOT IN (
|
|
SELECT id FROM deploy_history
|
|
WHERE workload_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
)`,
|
|
workloadID, workloadID, keep,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("prune deploy history: %w", err)
|
|
}
|
|
return nil
|
|
}
|