3800b34a2b
CI / Test (rest) (pull_request) Successful in 29s
CI / Lint (pull_request) Successful in 32s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Test (store) (pull_request) Successful in 1m22s
CI / Test (server-http) (pull_request) Successful in 1m30s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 41s
Smoothes the rough edges that came up exercising a live deployment.
First-run bootstrap UI: /bootstrap renders a username + password form
that uses the in-memory token directly (operator no longer copies it
out of the log); /login redirects there while bootstrap is available.
Agent reliability: failJob synthetic envelopes so command.run early
returns no longer hang the server-side job; runtime probe of restic
restore --help drives --no-ownership instead of version sniffing
(0.18.x had it removed). Server unit re-shaped: ProtectSystem=full
plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore
can now write anywhere a user might want.
Restore wizard: default target is /root/rm-restore/<job-id>/ with
clearer help text. Re-init confirm input uses .field (was .input,
which doesn't exist — text was invisible).
NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete
with hostname-confirm danger zone, audit, FK cascade, live WS close.
NS-02 enrollment-token recovery: outstanding-tokens panel on
/hosts/new, regenerate (preserves attachments) and revoke handlers
+ audit, store-level ListOutstandingEnrollmentTokens and
DeleteEnrollmentToken.
NS-03 repo init / probe surface: migration 0020 adds
hosts.repo_status + repo_status_error; WS handler projects every
init job's outcome onto the host row (idempotent already-initialised
collapses to ready); creds-save resets status and dispatches a fresh
probe; /hosts/{id}/repo/probe retry endpoint with banner.
NS-04 dashboard live + sort + filter: query-string filter
(q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the
alerts pattern with a localStorage live toggle, sortable column
headers, filter row + clear.
Alerts page: ack'd-by line resolves user_id ULID to username.
Compose.yaml ignored — host-specific.
197 lines
5.5 KiB
Go
197 lines
5.5 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
stdhttp "net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
type alertsPage struct {
|
|
Filter store.AlertFilter
|
|
Alerts []store.Alert
|
|
Counts alertCounts
|
|
HostNames map[string]string // host_id → name for table rendering
|
|
Usernames map[string]string // user_id → username for the "ack'd by …" line
|
|
RefreshURL string // self-URL for the live-refresh poll
|
|
}
|
|
|
|
type alertCounts struct {
|
|
Open int
|
|
Acknowledged int
|
|
Resolved24h int
|
|
}
|
|
|
|
// handleUIAlerts renders the alerts page with the chosen filters.
|
|
func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := store.AlertFilter{
|
|
Status: q.Get("status"),
|
|
Severity: q.Get("severity"),
|
|
HostID: q.Get("host_id"),
|
|
Search: strings.TrimSpace(q.Get("q")),
|
|
Limit: 200,
|
|
}
|
|
if f.Status == "" {
|
|
f.Status = "open"
|
|
}
|
|
|
|
alerts, err := s.deps.Store.ListAlerts(r.Context(), f)
|
|
if err != nil {
|
|
slog.Error("ui alerts: list", "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
page := alertsPage{
|
|
Filter: f,
|
|
Alerts: alerts,
|
|
HostNames: map[string]string{},
|
|
Usernames: map[string]string{},
|
|
RefreshURL: r.URL.RequestURI(),
|
|
}
|
|
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
|
|
for _, h := range hosts {
|
|
page.HostNames[h.ID] = h.Name
|
|
}
|
|
}
|
|
// Resolve user IDs that appear on acknowledged rows to usernames so
|
|
// the "ack'd by …" line shows a human name rather than the
|
|
// underlying ULID. Cheap at fleet sizes we care about (one extra
|
|
// query per alerts page render). Disabled users are still resolved
|
|
// — operators want to know *who* ack'd, even if the account is
|
|
// since gone.
|
|
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
|
|
for _, usr := range users {
|
|
page.Usernames[usr.ID] = usr.Username
|
|
}
|
|
}
|
|
page.Counts = computeAlertCounts(s, r)
|
|
|
|
view := s.baseView(r, u)
|
|
view.Title = "Alerts · restic-manager"
|
|
view.Active = "alerts"
|
|
view.Page = page
|
|
if err := s.deps.UI.Render(w, "alerts", view); err != nil {
|
|
slog.Error("ui alerts: render", "err", err)
|
|
}
|
|
}
|
|
|
|
func computeAlertCounts(s *Server, r *stdhttp.Request) alertCounts {
|
|
open, _ := s.deps.Store.ListAlerts(r.Context(),
|
|
store.AlertFilter{Status: "open"})
|
|
acked, _ := s.deps.Store.ListAlerts(r.Context(),
|
|
store.AlertFilter{Status: "acknowledged"})
|
|
cutoff := time.Now().UTC().Add(-24 * time.Hour)
|
|
all, _ := s.deps.Store.ListAlerts(r.Context(),
|
|
store.AlertFilter{Status: "resolved"})
|
|
res := 0
|
|
for _, a := range all {
|
|
if a.ResolvedAt != nil && a.ResolvedAt.After(cutoff) {
|
|
res++
|
|
}
|
|
}
|
|
return alertCounts{Open: len(open), Acknowledged: len(acked), Resolved24h: res}
|
|
}
|
|
|
|
// handleAPIAlerts is the JSON list — same filter shape.
|
|
func (s *Server) handleAPIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
if _, ok := s.requireUser(r); !ok {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := store.AlertFilter{
|
|
Status: q.Get("status"),
|
|
Severity: q.Get("severity"),
|
|
HostID: q.Get("host_id"),
|
|
Search: strings.TrimSpace(q.Get("q")),
|
|
Limit: 200,
|
|
}
|
|
alerts, err := s.deps.Store.ListAlerts(r.Context(), f)
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(alerts)
|
|
}
|
|
|
|
// handleUIAlertAcknowledge is POST /alerts/{id}/acknowledge.
|
|
func (s *Server) handleUIAlertAcknowledge(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
var err error
|
|
if s.deps.AlertEngine != nil {
|
|
err = s.deps.AlertEngine.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
|
|
} else {
|
|
err = s.deps.Store.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
|
|
}
|
|
if err != nil {
|
|
slog.Warn("ui alerts: ack", "err", err)
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
|
Action: "alert.acknowledge",
|
|
TargetKind: ptr("alert"), TargetID: &id,
|
|
TS: time.Now().UTC(),
|
|
})
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery)
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
return
|
|
}
|
|
stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
// handleUIAlertResolve is POST /alerts/{id}/resolve.
|
|
func (s *Server) handleUIAlertResolve(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
var err error
|
|
if s.deps.AlertEngine != nil {
|
|
err = s.deps.AlertEngine.Resolve(r.Context(), id, time.Now().UTC())
|
|
} else {
|
|
err = s.deps.Store.Resolve(r.Context(), id, time.Now().UTC())
|
|
}
|
|
if err != nil {
|
|
slog.Warn("ui alerts: resolve", "err", err)
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
|
Action: "alert.resolve",
|
|
TargetKind: ptr("alert"), TargetID: &id,
|
|
TS: time.Now().UTC(),
|
|
})
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery)
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
return
|
|
}
|
|
stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther)
|
|
}
|