package http import ( stdhttp "net/http" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // 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"` Stale bool `json:"stale"` 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, "unauthorised", "") return } hostID := chi.URLParam(r, "id") if hostID == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_host_id", "") return } host, err := s.deps.Store.GetHost(r.Context(), hostID) if 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)), } out.RefreshedAt = host.SnapshotRefreshedAt mutationAt, err := s.deps.Store.LatestSuccessfulRepoMutation(r.Context(), hostID) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } out.Stale = mutationAt != nil && (out.RefreshedAt == nil || out.RefreshedAt.Before(*mutationAt)) 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) } func (s *Server) handleRefreshHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) { writeJSONError(w, stdhttp.StatusConflict, "host_offline", "agent is not currently connected") return } env, _ := api.Marshal(api.MsgSnapshotsRefresh, ulid.Make().String(), nil) if err := s.deps.Hub.Send(r.Context(), hostID, env); err != nil { writeJSONError(w, stdhttp.StatusConflict, "host_offline", err.Error()) return } now := time.Now().UTC() _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", Action: "host.snapshots_refresh", TargetKind: ptr("host"), TargetID: &hostID, TS: now, }) writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "refresh_requested"}) }