diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 15f8473..465b1b2 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -287,6 +287,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/probe", s.handleUIRepoProbe) r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave) r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave) + r.Post("/hosts/{id}/mode", s.handleUIHostModeSave) r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index e0c4515..d2e1d98 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -983,6 +983,43 @@ func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Reque stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther) } +// handleUIHostModeSave flips a host's always-on flag. Checkbox present +// in the form (value any) => always-on; absent => intermittent. +// Operator-band; mounted in server.go. On change we clear open +// offline/staleness alerts via the engine so the next sweep re-raises +// only what still applies under the new mode. +func (s *Server) handleUIHostModeSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + stdhttp.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + alwaysOn := r.PostForm.Get("always_on") != "" + if err := s.deps.Store.SetHostAlwaysOn(r.Context(), hostID, alwaysOn); err != nil { + slog.Error("ui host mode: save", "host_id", hostID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if s.deps.AlertEngine != nil { + s.deps.AlertEngine.ResolveOnModeChange(r.Context(), hostID, time.Now().UTC()) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "host.mode_updated", + TargetKind: ptr("host"), TargetID: &hostID, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther) +} + // normaliseTags splits a comma-separated string, lowercases each token, // trims whitespace, drops empties, and dedupes. Order is preserved // from first occurrence (so the user's typing order shows on screen). diff --git a/internal/server/http/ui_host_mode_test.go b/internal/server/http/ui_host_mode_test.go new file mode 100644 index 0000000..b23ca02 --- /dev/null +++ b/internal/server/http/ui_host_mode_test.go @@ -0,0 +1,88 @@ +// ui_host_mode_test.go — covers handleUIHostModeSave: toggling a +// host's always-on flag via POST /hosts/{id}/mode. +package http + +import ( + "context" + stdhttp "net/http" + "net/url" + "strings" + "testing" +) + +// TestHostModeSaveToggle verifies the checkbox-absent ⇒ intermittent +// and checkbox-present ⇒ always-on semantics, and that the audit row +// lands for each request. +func TestHostModeSaveToggle(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "mode-toggle-host") + + cookie := loginAsAdmin(t, st) + + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + + // --- POST with no always_on field => intermittent --- + form := url.Values{} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + _ = res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res.StatusCode) + } + if loc := res.Header.Get("Location"); loc != "/hosts/"+hostID { + t.Errorf("Location: got %q, want /hosts/%s", loc, hostID) + } + + got, err := st.GetHost(context.Background(), hostID) + if err != nil { + t.Fatalf("GetHost: %v", err) + } + if got.AlwaysOn { + t.Errorf("AlwaysOn after empty form: got true, want false") + } + + // --- POST with always_on=on => always-on --- + form2 := url.Values{"always_on": {"on"}} + req2, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode", + strings.NewReader(form2.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req2.AddCookie(cookie) + res2, err := cli.Do(req2) + if err != nil { + t.Fatalf("do: %v", err) + } + _ = res2.Body.Close() + if res2.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res2.StatusCode) + } + + got2, err := st.GetHost(context.Background(), hostID) + if err != nil { + t.Fatalf("GetHost: %v", err) + } + if !got2.AlwaysOn { + t.Errorf("AlwaysOn after always_on=on: got false, want true") + } + + // Audit rows must exist (one per request). + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'host.mode_updated' AND target_id = ?`, + hostID).Scan(&n); err != nil { + t.Fatalf("count audit: %v", err) + } + if n != 2 { + t.Errorf("audit rows: got %d, want 2", n) + } +}