df08682bed
- Audit log: backend only returns a next cursor when the page is full, so the "Load more" button no longer needs two clicks (and hides at the last page). Table now spans the full screen width and long detail JSON wraps instead of overflowing. - Bounty board: pin each card's action row to the bottom so "Request assignment" lines up across equal-height cards. - Light theme: restore the Y2K dither texture on the sidebar nav panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
6.3 KiB
Go
201 lines
6.3 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
|
|
"bountyboard/internal/store"
|
|
)
|
|
|
|
func (s *Server) routesAdmin(mux *http.ServeMux) {
|
|
adm := func(h http.HandlerFunc) http.Handler { return s.authedRole("admin", h) }
|
|
|
|
mux.Handle("GET /api/v1/admin/users", adm(s.handleAdminListUsers))
|
|
mux.Handle("PATCH /api/v1/admin/users/{id}", adm(s.handleAdminPatchUser))
|
|
mux.Handle("DELETE /api/v1/admin/users/{id}", adm(s.handleAdminDeleteUser))
|
|
|
|
mux.Handle("GET /api/v1/admin/customers", adm(s.handleAdminListCustomers))
|
|
mux.Handle("POST /api/v1/admin/customers", adm(s.handleAdminCreateCustomer))
|
|
mux.Handle("GET /api/v1/admin/customers/{id}", adm(s.handleAdminGetCustomer))
|
|
mux.Handle("PATCH /api/v1/admin/customers/{id}", adm(s.handleAdminPatchCustomer))
|
|
mux.Handle("DELETE /api/v1/admin/customers/{id}", adm(s.handleAdminDeleteCustomer))
|
|
mux.Handle("POST /api/v1/admin/customers/test-connection", adm(s.handleAdminTestConnectionUnsaved))
|
|
mux.Handle("POST /api/v1/admin/customers/{id}/test-connection", adm(s.handleAdminTestConnection))
|
|
mux.Handle("POST /api/v1/admin/customers/{id}/sync-now", adm(s.handleAdminSyncNow))
|
|
|
|
mux.Handle("GET /api/v1/admin/settings", adm(s.handleAdminGetSettings))
|
|
mux.Handle("PATCH /api/v1/admin/settings", adm(s.handleAdminPatchSettings))
|
|
mux.Handle("GET /api/v1/admin/audit-log", adm(s.handleAdminAuditLog))
|
|
mux.Handle("GET /api/v1/admin/service-status", adm(s.handleAdminServiceStatus))
|
|
}
|
|
|
|
// audit records a privileged mutation; failure is logged, never fatal.
|
|
func (s *Server) audit(r *http.Request, action, entity, entityID string, diff map[string]any) {
|
|
err := s.store.Audit(r.Context(), store.AuditEntry{
|
|
ActorID: CurrentUser(r.Context()).ID,
|
|
ActorIP: ClientIP(r.Context()).String(),
|
|
Action: action,
|
|
Entity: entity,
|
|
EntityID: entityID,
|
|
Diff: diff,
|
|
})
|
|
if err != nil {
|
|
s.log.Error("audit write failed", "action", action, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
st, err := s.store.GetSettings(r.Context())
|
|
if err != nil {
|
|
s.internalError(w, r, "get settings", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
|
}
|
|
|
|
func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
BrandingName *string `json:"brandingName"`
|
|
AtomizerBaseURLOverride *string `json:"atomizerBaseUrlOverride"`
|
|
HideCompetingClaims *bool `json:"hideCompetingClaims"`
|
|
DefaultPollIntervalSec *int `json:"defaultPollIntervalSec"`
|
|
DefaultCustomerBudget *float64 `json:"defaultCustomerBudget"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
set := bson.M{}
|
|
if req.BrandingName != nil {
|
|
set["brandingName"] = *req.BrandingName
|
|
}
|
|
if req.AtomizerBaseURLOverride != nil {
|
|
set["atomizerBaseUrlOverride"] = *req.AtomizerBaseURLOverride
|
|
}
|
|
if req.HideCompetingClaims != nil {
|
|
set["hideCompetingClaims"] = *req.HideCompetingClaims
|
|
}
|
|
if req.DefaultPollIntervalSec != nil {
|
|
if *req.DefaultPollIntervalSec < 10 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s")
|
|
return
|
|
}
|
|
set["defaultPollIntervalSec"] = *req.DefaultPollIntervalSec
|
|
}
|
|
if req.DefaultCustomerBudget != nil {
|
|
set["defaultCustomerBudget"] = *req.DefaultCustomerBudget
|
|
}
|
|
if len(set) == 0 {
|
|
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
|
|
return
|
|
}
|
|
st, err := s.store.PatchSettings(r.Context(), set)
|
|
if err != nil {
|
|
s.internalError(w, r, "patch settings", err)
|
|
return
|
|
}
|
|
s.audit(r, "settings.update", "settings", "app", map[string]any{"set": set})
|
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
|
}
|
|
|
|
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
limit := atoiDefault(q.Get("limit"), 50)
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
|
q.Get("cursor"), limit)
|
|
if err != nil {
|
|
s.internalError(w, r, "list audit", err)
|
|
return
|
|
}
|
|
// Only advertise a next cursor when the page is full; otherwise this is the
|
|
// last page and a further fetch would return nothing (the "click twice" bug).
|
|
next := ""
|
|
if len(entries) == limit {
|
|
next = entries[len(entries)-1].ID
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
|
}
|
|
|
|
// serviceStatus is the §11.17 admin panel payload. Breaker and queue fields
|
|
// are filled in by the phases that introduce them.
|
|
type externalStatus struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Healthy bool `json:"healthy"`
|
|
LatencyMs int64 `json:"latencyMs"`
|
|
Error string `json:"error,omitempty"`
|
|
Breaker string `json:"breaker"`
|
|
}
|
|
|
|
func (s *Server) handleAdminServiceStatus(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
|
defer cancel()
|
|
|
|
atomizer := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx))
|
|
performer := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL)
|
|
if s.atomizer != nil {
|
|
atomizer.Breaker = s.atomizer.BreakerState()
|
|
}
|
|
if s.performer != nil {
|
|
performer.Breaker = s.performer.BreakerState()
|
|
}
|
|
|
|
mongoOK := true
|
|
mongoErr := ""
|
|
if err := s.store.Ping(ctx); err != nil {
|
|
mongoOK, mongoErr = false, err.Error()
|
|
}
|
|
|
|
out := map[string]any{
|
|
"mongo": map[string]any{"healthy": mongoOK, "error": mongoErr},
|
|
"atomizer": atomizer,
|
|
"workPerformer": performer,
|
|
"syncWorkers": s.syncStatuses(),
|
|
"jobs": s.jobStatuses(),
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) probeExternal(ctx context.Context, name, baseURL string) externalStatus {
|
|
st := externalStatus{Name: name, URL: baseURL, Breaker: "unknown"}
|
|
start := time.Now()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil)
|
|
if err != nil {
|
|
st.Error = err.Error()
|
|
return st
|
|
}
|
|
resp, err := healthProbeClient.Do(req)
|
|
st.LatencyMs = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
st.Error = err.Error()
|
|
return st
|
|
}
|
|
resp.Body.Close()
|
|
st.Healthy = resp.StatusCode == http.StatusOK
|
|
if !st.Healthy {
|
|
st.Error = resp.Status
|
|
}
|
|
return st
|
|
}
|
|
|
|
var healthProbeClient = &http.Client{Timeout: 3 * time.Second}
|
|
|
|
func atoiDefault(s string, def int) int {
|
|
if s == "" {
|
|
return def
|
|
}
|
|
var n int
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return def
|
|
}
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n
|
|
}
|