feat(http): host mode toggle handler + route (host.mode_updated)
This commit is contained in:
@@ -287,6 +287,7 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Post("/hosts/{id}/repo/probe", s.handleUIRepoProbe)
|
r.Post("/hosts/{id}/repo/probe", s.handleUIRepoProbe)
|
||||||
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
||||||
r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave)
|
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", s.handleUIAdminCredentialsSave)
|
||||||
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
||||||
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
||||||
|
|||||||
@@ -983,6 +983,43 @@ func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Reque
|
|||||||
stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
|
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,
|
// normaliseTags splits a comma-separated string, lowercases each token,
|
||||||
// trims whitespace, drops empties, and dedupes. Order is preserved
|
// trims whitespace, drops empties, and dedupes. Order is preserved
|
||||||
// from first occurrence (so the user's typing order shows on screen).
|
// from first occurrence (so the user's typing order shows on screen).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user