P1-22: snapshot listing via restic snapshots --json
Agent calls restic snapshots --json after each successful backup
(60s timeout, separate from the backup ctx) and ships the projection
over the existing snapshots.report WS envelope. Failure here is
logged but doesn't fail the job — the next successful backup catches
the projection up.
Server-side ReplaceHostSnapshots is delete-then-insert plus a
hosts.snapshot_count update in one transaction so the dashboard's
per-host count stays consistent with the projection. New read
endpoint GET /api/hosts/{id}/snapshots returns the cached list with
a refreshed_at marker so the UI can show staleness when an agent
has been offline.
Schema: dropped the unused snapshots.repo_id FK (repos as a
first-class entity is P2 work), added short_id and refreshed_at
columns, switched the time index to DESC for the most-recent-first
list query. api.Snapshot gains short_id; size_bytes/file_count come
from the embedded summary block on restic 0.16+ and stay zero on
older clients.
Tests cover round-trip, authoritative replacement after forget+prune
shrinkage, and empty-after-wipe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// snapshotView is the public JSON shape for a snapshot. Matches the
|
||||
// wire-protocol Snapshot type plus a `refreshed_at` field so the UI
|
||||
// can show "list last refreshed Xm ago" — useful when the projection
|
||||
// is stale because the host has been offline for a while.
|
||||
type snapshotView struct {
|
||||
ID string `json:"id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Hostname string `json:"hostname"`
|
||||
Paths []string `json:"paths"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
FileCount int64 `json:"file_count,omitempty"`
|
||||
}
|
||||
|
||||
type listSnapshotsResponse struct {
|
||||
HostID string `json:"host_id"`
|
||||
Count int `json:"count"`
|
||||
RefreshedAt *time.Time `json:"refreshed_at,omitempty"`
|
||||
Snapshots []snapshotView `json:"snapshots"`
|
||||
}
|
||||
|
||||
// handleListHostSnapshots returns the cached snapshot projection for
|
||||
// one host. The agent refreshes this by sending `snapshots.report`
|
||||
// after each successful backup; this endpoint is a read-only view
|
||||
// onto whatever the server most recently received.
|
||||
func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if _, ok := s.requireUser(r); !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_host_id", "")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
|
||||
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
|
||||
out := listSnapshotsResponse{
|
||||
HostID: hostID,
|
||||
Count: len(snaps),
|
||||
Snapshots: make([]snapshotView, len(snaps)),
|
||||
}
|
||||
if len(snaps) > 0 {
|
||||
t := snaps[0].RefreshedAt
|
||||
out.RefreshedAt = &t
|
||||
}
|
||||
for i, sn := range snaps {
|
||||
out.Snapshots[i] = snapshotView{
|
||||
ID: sn.ID,
|
||||
ShortID: sn.ShortID,
|
||||
Time: sn.Time,
|
||||
Hostname: sn.Hostname,
|
||||
Paths: sn.Paths,
|
||||
Tags: sn.Tags,
|
||||
SizeBytes: sn.SizeBytes,
|
||||
FileCount: sn.FileCount,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, stdhttp.StatusOK, out)
|
||||
}
|
||||
@@ -185,8 +185,33 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
|
||||
slog.Warn("ws: append job log", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
|
||||
case api.MsgSnapshotsRpt, api.MsgRepoStats, api.MsgScheduleAck, api.MsgCommandResult:
|
||||
// TODO(P1-22 + P2): persist these projections.
|
||||
case api.MsgSnapshotsRpt:
|
||||
var p api.SnapshotsReportPayload
|
||||
if err := env.UnmarshalPayload(&p); err != nil {
|
||||
slog.Warn("ws: bad snapshots.report payload", "host_id", hostID, "err", err)
|
||||
break
|
||||
}
|
||||
snaps := make([]store.Snapshot, len(p.Snapshots))
|
||||
for i, s := range p.Snapshots {
|
||||
snaps[i] = store.Snapshot{
|
||||
ID: s.ID,
|
||||
ShortID: s.ShortID,
|
||||
Time: s.Time,
|
||||
Hostname: s.Hostname,
|
||||
Paths: s.Paths,
|
||||
Tags: s.Tags,
|
||||
SizeBytes: s.SizeBytes,
|
||||
FileCount: s.FileCount,
|
||||
}
|
||||
}
|
||||
if err := deps.Store.ReplaceHostSnapshots(ctx, hostID, snaps, time.Now().UTC()); err != nil {
|
||||
slog.Warn("ws: replace snapshots", "host_id", hostID, "err", err)
|
||||
} else {
|
||||
slog.Info("ws: snapshots refreshed", "host_id", hostID, "count", len(snaps))
|
||||
}
|
||||
|
||||
case api.MsgRepoStats, api.MsgScheduleAck, api.MsgCommandResult:
|
||||
// TODO(P2): persist these projections.
|
||||
slog.Debug("ws msg not yet handled", "type", env.Type, "host_id", hostID)
|
||||
|
||||
case api.MsgError:
|
||||
|
||||
Reference in New Issue
Block a user