main.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. // Command admind is the admin HTTP API + (later) UI host. Tenant CRUD,
  2. // DLQ inspection, replay, audit log.
  3. //
  4. // M0: /health, /metrics, /v1/ping.
  5. // M8: + DLQ surface —
  6. // GET /v1/dlq — list/filter DLQ rows
  7. // GET /v1/dlq/{id} — single row (with payload)
  8. // POST /v1/dlq/{id}/replay — re-INSERT into the deliverd NATS subject
  9. // POST /v1/dlq/{id}/discard — mark discarded (hidden from default list)
  10. // GET /dlq — minimal HTML UI
  11. //
  12. // Auth: M8 ships without auth (LAN-only). The M11 work
  13. // gates /v1/dlq* behind an operator JWT.
  14. package main
  15. import (
  16. "context"
  17. "embed"
  18. "encoding/json"
  19. "errors"
  20. "fmt"
  21. "html"
  22. "html/template"
  23. "log/slog"
  24. "net/http"
  25. "os"
  26. "os/signal"
  27. "strconv"
  28. "strings"
  29. "syscall"
  30. "time"
  31. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  36. "github.com/jackc/pgx/v5"
  37. "github.com/nats-io/nats.go"
  38. )
  39. //go:embed ui/*
  40. var uiFS embed.FS
  41. // dlqRow is the wire shape returned by /v1/dlq and
  42. // rendered by the HTML UI. The payload field is omitted
  43. // from the list endpoint (operators can fetch the full
  44. // row via /v1/dlq/{id}).
  45. type dlqRow struct {
  46. ID int64 `json:"id"`
  47. AlertID string `json:"alert_id"`
  48. CompanyID string `json:"company_id"`
  49. IndividualID string `json:"individual_id"`
  50. Channel string `json:"channel"`
  51. Target string `json:"target"`
  52. OriginalSubject string `json:"original_subject"`
  53. Attempts int `json:"attempts"`
  54. LastError string `json:"last_error"`
  55. Discarded bool `json:"discarded"`
  56. DiscardedAt *time.Time `json:"discarded_at,omitempty"`
  57. DiscardedBy string `json:"discarded_by,omitempty"`
  58. CreatedAt time.Time `json:"created_at"`
  59. Payload json.RawMessage `json:"payload,omitempty"`
  60. }
  61. func main() {
  62. cfg, err := config.LoadCommon("admind")
  63. if err != nil {
  64. os.Stderr.WriteString("config: " + err.Error() + "\n")
  65. os.Exit(1)
  66. }
  67. logger := observability.Init(cfg.Env, cfg.LogLevel, "admind")
  68. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  69. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  70. defer stop()
  71. br, err := broker.Connect(ctx, cfg.NATSURL)
  72. if err != nil {
  73. logger.Error("nats connect", "err", err)
  74. os.Exit(1)
  75. }
  76. defer br.Close()
  77. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  78. if err != nil {
  79. logger.Error("postgres connect", "err", err)
  80. os.Exit(1)
  81. }
  82. defer pool.Close()
  83. reg, _ := observability.NewRegistry("admind")
  84. srv := httpserver.New(httpserver.Config{
  85. Addr: cfg.HTTPAddr,
  86. ServiceName: "admind",
  87. ShutdownGrace: cfg.ShutdownGrace,
  88. }, logger, observability.MetricsHandler(reg))
  89. mux := srv.Mux()
  90. mux.HandleFunc("GET /v1/ping", handlePing)
  91. mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
  92. mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
  93. mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
  94. mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
  95. mux.HandleFunc("GET /dlq", handleDLQUI(pool, logger))
  96. errCh := make(chan error, 1)
  97. go func() { errCh <- srv.Start() }()
  98. select {
  99. case <-ctx.Done():
  100. logger.Info("shutdown signal received")
  101. case err := <-errCh:
  102. if err != nil {
  103. logger.Error("http server", "err", err)
  104. os.Exit(1)
  105. }
  106. }
  107. if err := srv.Shutdown(ctx); err != nil {
  108. logger.Warn("graceful shutdown", "err", err)
  109. }
  110. logger.Info("bye")
  111. }
  112. func handlePing(w http.ResponseWriter, r *http.Request) {
  113. w.Header().Set("Content-Type", "application/json")
  114. _ = json.NewEncoder(w).Encode(map[string]any{
  115. "pong": true,
  116. "service": "admind",
  117. "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
  118. })
  119. }
  120. // listFilters is the parsed query string for /v1/dlq.
  121. type listFilters struct {
  122. CompanyID string
  123. Channel string
  124. AlertID string
  125. Include string // "all" to show discarded; default hides them
  126. Limit int
  127. Offset int
  128. }
  129. func parseListFilters(r *http.Request) listFilters {
  130. q := r.URL.Query()
  131. limit, _ := strconv.Atoi(q.Get("limit"))
  132. if limit <= 0 || limit > 500 {
  133. limit = 100
  134. }
  135. offset, _ := strconv.Atoi(q.Get("offset"))
  136. if offset < 0 {
  137. offset = 0
  138. }
  139. return listFilters{
  140. CompanyID: strings.TrimSpace(q.Get("company_id")),
  141. Channel: strings.TrimSpace(q.Get("channel")),
  142. AlertID: strings.TrimSpace(q.Get("alert_id")),
  143. Include: strings.TrimSpace(q.Get("include")),
  144. Limit: limit,
  145. Offset: offset,
  146. }
  147. }
  148. // handleListDLQ returns a paginated list of DLQ rows.
  149. // The list excludes discarded rows by default; pass
  150. // include=all to see them.
  151. func handleListDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  152. return func(w http.ResponseWriter, r *http.Request) {
  153. f := parseListFilters(r)
  154. rows, err := queryDLQ(r.Context(), pool, dlqQuery{
  155. CompanyID: f.CompanyID,
  156. Channel: f.Channel,
  157. AlertID: f.AlertID,
  158. IncludeDiscarded: f.Include == "all",
  159. Limit: f.Limit,
  160. Offset: f.Offset,
  161. })
  162. if err != nil {
  163. logger.Error("dlq list", "err", err)
  164. http.Error(w, "dlq list: "+err.Error(), http.StatusInternalServerError)
  165. return
  166. }
  167. w.Header().Set("Content-Type", "application/json")
  168. _ = json.NewEncoder(w).Encode(map[string]any{
  169. "rows": rows,
  170. "limit": f.Limit,
  171. "offset": f.Offset,
  172. })
  173. }
  174. }
  175. // handleGetDLQ returns one row by id, with the payload
  176. // included. Used by the UI's "view payload" expand.
  177. func handleGetDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  178. return func(w http.ResponseWriter, r *http.Request) {
  179. id, ok := parseID(r)
  180. if !ok {
  181. http.Error(w, "bad id", http.StatusBadRequest)
  182. return
  183. }
  184. row, err := getDLQ(r.Context(), pool, id)
  185. if err != nil {
  186. logger.Error("dlq get", "err", err, "id", id)
  187. http.Error(w, "dlq get: "+err.Error(), http.StatusInternalServerError)
  188. return
  189. }
  190. if row == nil {
  191. http.Error(w, "not found", http.StatusNotFound)
  192. return
  193. }
  194. w.Header().Set("Content-Type", "application/json")
  195. _ = json.NewEncoder(w).Encode(row)
  196. }
  197. }
  198. // handleReplayDLQ re-publishes the original NATS envelope
  199. // (stored in deliveries_dlq.payload) onto the original
  200. // subject, then marks the DLQ row discarded=true (with
  201. // discarded_by='replay') and writes a fresh audit row in
  202. // the live deliveries table.
  203. //
  204. // Idempotency: a single replay succeeds or fails. If it
  205. // fails, the DLQ row is NOT marked discarded so the
  206. // operator can retry. There is no race because the
  207. // discarded=true UPDATE happens in the same handler call
  208. // as the Publish; a second concurrent replay would
  209. // publish twice (the second one will hit dedupe at the
  210. // ingestd layer if the alert_id has been seen recently,
  211. // but for the DLQ replay path we don't dedupe — the
  212. // operator explicitly asked for a re-send).
  213. func handleReplayDLQ(br *broker.Client, pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  214. return func(w http.ResponseWriter, r *http.Request) {
  215. id, ok := parseID(r)
  216. if !ok {
  217. http.Error(w, "bad id", http.StatusBadRequest)
  218. return
  219. }
  220. row, err := getDLQ(r.Context(), pool, id)
  221. if err != nil {
  222. logger.Error("dlq replay get", "err", err, "id", id)
  223. http.Error(w, "dlq replay: "+err.Error(), http.StatusInternalServerError)
  224. return
  225. }
  226. if row == nil {
  227. http.Error(w, "not found", http.StatusNotFound)
  228. return
  229. }
  230. if row.Discarded {
  231. http.Error(w, "already discarded", http.StatusConflict)
  232. return
  233. }
  234. // Re-publish the original payload (json.RawMessage
  235. // holds the NATS envelope bytes verbatim).
  236. nc := br.NC()
  237. if err := nc.Publish(row.OriginalSubject, row.Payload); err != nil {
  238. logger.Error("dlq replay publish", "err", err, "id", id, "subject", row.OriginalSubject)
  239. http.Error(w, "publish: "+err.Error(), http.StatusBadGateway)
  240. return
  241. }
  242. // Flush so the message is on the wire before we
  243. // mark the row discarded. If the worker can't
  244. // deliver it, it will go back into the DLQ with
  245. // a fresh row, and the operator will see both.
  246. if err := nc.Flush(); err != nil {
  247. logger.Warn("dlq replay flush", "err", err, "id", id)
  248. }
  249. // Mark the DLQ row discarded. We do this AFTER
  250. // the publish so a publish failure leaves the
  251. // DLQ row in place for retry.
  252. now := time.Now().UTC()
  253. _, dbErr := pool.Exec(r.Context(), `
  254. UPDATE deliveries_dlq
  255. SET discarded = true,
  256. discarded_at = $1,
  257. discarded_by = $2
  258. WHERE id = $3 AND created_at IN (
  259. SELECT created_at FROM deliveries_dlq WHERE id = $3 LIMIT 1
  260. )
  261. `, now, "replay", id)
  262. if dbErr != nil {
  263. // Publish succeeded but the DB update
  264. // didn't. The replay still happened —
  265. // log loud, return 200, and let the
  266. // operator handle the duplicate UI row
  267. // if they re-replay.
  268. logger.Error("dlq replay: publish ok but update failed",
  269. "err", dbErr, "id", id)
  270. }
  271. logger.Info("dlq replay ok", "id", id, "subject", row.OriginalSubject, "alert_id", row.AlertID)
  272. w.Header().Set("Content-Type", "application/json")
  273. _ = json.NewEncoder(w).Encode(map[string]any{
  274. "replayed": true,
  275. "id": id,
  276. "subject": row.OriginalSubject,
  277. "alert_id": row.AlertID,
  278. "company_id": row.CompanyID,
  279. "original_attempts": row.Attempts,
  280. })
  281. }
  282. }
  283. // handleDiscardDLQ marks a DLQ row as discarded. After
  284. // discard, the row is hidden from /v1/dlq by default and
  285. // from the HTML UI.
  286. func handleDiscardDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  287. return func(w http.ResponseWriter, r *http.Request) {
  288. id, ok := parseID(r)
  289. if !ok {
  290. http.Error(w, "bad id", http.StatusBadRequest)
  291. return
  292. }
  293. now := time.Now().UTC()
  294. // The (id, created_at) PK means the UPDATE
  295. // has to match the row's created_at too. We
  296. // fetch first, then UPDATE with both keys.
  297. row, err := getDLQ(r.Context(), pool, id)
  298. if err != nil {
  299. logger.Error("dlq discard get", "err", err, "id", id)
  300. http.Error(w, "dlq discard: "+err.Error(), http.StatusInternalServerError)
  301. return
  302. }
  303. if row == nil {
  304. http.Error(w, "not found", http.StatusNotFound)
  305. return
  306. }
  307. if row.Discarded {
  308. // Idempotent: a second discard is a no-op.
  309. w.Header().Set("Content-Type", "application/json")
  310. _ = json.NewEncoder(w).Encode(map[string]any{"discarded": true, "id": id, "already": true})
  311. return
  312. }
  313. _, dbErr := pool.Exec(r.Context(), `
  314. UPDATE deliveries_dlq
  315. SET discarded = true,
  316. discarded_at = $1,
  317. discarded_by = $2
  318. WHERE id = $3 AND created_at = $4
  319. `, now, "operator", id, row.CreatedAt)
  320. if dbErr != nil {
  321. logger.Error("dlq discard", "err", dbErr, "id", id)
  322. http.Error(w, "discard: "+dbErr.Error(), http.StatusInternalServerError)
  323. return
  324. }
  325. logger.Info("dlq discard ok", "id", id, "alert_id", row.AlertID)
  326. w.Header().Set("Content-Type", "application/json")
  327. _ = json.NewEncoder(w).Encode(map[string]any{
  328. "discarded": true,
  329. "id": id,
  330. "discarded_at": now,
  331. })
  332. }
  333. }
  334. // dlqQuery is the structured query for the list endpoint.
  335. type dlqQuery struct {
  336. CompanyID string
  337. Channel string
  338. AlertID string
  339. IncludeDiscarded bool
  340. Limit int
  341. Offset int
  342. }
  343. // queryDLQ returns the rows that match the filters.
  344. // Excludes the payload from the list view (operators
  345. // fetch /v1/dlq/{id} for the full row).
  346. func queryDLQ(ctx context.Context, pool *postgres.Pool, q dlqQuery) ([]dlqRow, error) {
  347. // Build the WHERE clause dynamically. We always
  348. // filter on created_at > now - 30d for the list
  349. // view (operators rarely need ancient rows; the
  350. // archiver ships them to CH).
  351. conds := []string{"created_at > now() - INTERVAL '30 days'"}
  352. args := []any{}
  353. if q.CompanyID != "" {
  354. args = append(args, q.CompanyID)
  355. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  356. }
  357. if q.Channel != "" {
  358. args = append(args, q.Channel)
  359. conds = append(conds, fmt.Sprintf("channel = $%d", len(args)))
  360. }
  361. if q.AlertID != "" {
  362. args = append(args, q.AlertID)
  363. conds = append(conds, fmt.Sprintf("alert_id = $%d", len(args)))
  364. }
  365. if !q.IncludeDiscarded {
  366. conds = append(conds, "discarded = false")
  367. }
  368. where := strings.Join(conds, " AND ")
  369. args = append(args, q.Limit, q.Offset)
  370. q1 := fmt.Sprintf(`
  371. SELECT id, alert_id, company_id, individual_id, channel, target,
  372. original_subject, attempts, last_error, discarded,
  373. discarded_at, discarded_by, created_at
  374. FROM deliveries_dlq
  375. WHERE %s
  376. ORDER BY created_at DESC
  377. LIMIT $%d OFFSET $%d
  378. `, where, len(args)-1, len(args))
  379. rows, err := pool.Query(ctx, q1, args...)
  380. if err != nil {
  381. return nil, err
  382. }
  383. defer rows.Close()
  384. var out []dlqRow
  385. for rows.Next() {
  386. var r dlqRow
  387. var discardedAt *time.Time
  388. var discardedBy *string
  389. if err := rows.Scan(
  390. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  391. &r.OriginalSubject, &r.Attempts, &r.LastError, &r.Discarded,
  392. &discardedAt, &discardedBy, &r.CreatedAt,
  393. ); err != nil {
  394. return nil, err
  395. }
  396. r.DiscardedAt = discardedAt
  397. if discardedBy != nil {
  398. r.DiscardedBy = *discardedBy
  399. }
  400. out = append(out, r)
  401. }
  402. return out, rows.Err()
  403. }
  404. // getDLQ returns one row by id (with payload). Returns
  405. // nil, nil if the row doesn't exist.
  406. func getDLQ(ctx context.Context, pool *postgres.Pool, id int64) (*dlqRow, error) {
  407. row := pool.QueryRow(ctx, `
  408. SELECT id, alert_id, company_id, individual_id, channel, target,
  409. original_subject, attempts, last_error, payload, discarded,
  410. discarded_at, discarded_by, created_at
  411. FROM deliveries_dlq
  412. WHERE id = $1
  413. LIMIT 1
  414. `, id)
  415. var r dlqRow
  416. var payload []byte
  417. var discardedAt *time.Time
  418. var discardedBy *string
  419. if err := row.Scan(
  420. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  421. &r.OriginalSubject, &r.Attempts, &r.LastError, &payload, &r.Discarded,
  422. &discardedAt, &discardedBy, &r.CreatedAt,
  423. ); err != nil {
  424. if errors.Is(err, pgx.ErrNoRows) {
  425. return nil, nil
  426. }
  427. return nil, err
  428. }
  429. r.Payload = json.RawMessage(payload)
  430. r.DiscardedAt = discardedAt
  431. if discardedBy != nil {
  432. r.DiscardedBy = *discardedBy
  433. }
  434. return &r, nil
  435. }
  436. func parseID(r *http.Request) (int64, bool) {
  437. raw := r.PathValue("id")
  438. id, err := strconv.ParseInt(raw, 10, 64)
  439. if err != nil || id <= 0 {
  440. return 0, false
  441. }
  442. return id, true
  443. }
  444. // ── HTML UI ──────────────────────────────────────────────────────
  445. // uiData is the template data for the /dlq HTML page.
  446. type uiData struct {
  447. Rows []dlqRow
  448. Filters listFilters
  449. ShowAll bool
  450. HasFilters bool
  451. }
  452. // handleDLQUI serves a minimal HTML page with the DLQ
  453. // list and inline replay/discard buttons. POSTs go to
  454. // the same /v1/dlq/{id}/{replay,discard} endpoints; the
  455. // JS does a fetch and reloads the page.
  456. func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  457. tpl := template.Must(template.New("dlq").Funcs(template.FuncMap{
  458. "safeHTML": func(s string) template.HTML { return template.HTML(html.EscapeString(s)) },
  459. "shortErr": func(s string) string {
  460. if len(s) > 80 {
  461. return s[:80] + "…"
  462. }
  463. return s
  464. },
  465. "ago": func(t time.Time) string {
  466. d := time.Since(t)
  467. switch {
  468. case d < time.Minute:
  469. return fmt.Sprintf("%ds ago", int(d.Seconds()))
  470. case d < time.Hour:
  471. return fmt.Sprintf("%dm ago", int(d.Minutes()))
  472. case d < 24*time.Hour:
  473. return fmt.Sprintf("%dh ago", int(d.Hours()))
  474. default:
  475. return fmt.Sprintf("%dd ago", int(d.Hours()/24))
  476. }
  477. },
  478. }).ParseFS(uiFS, "ui/*.html"))
  479. return func(w http.ResponseWriter, r *http.Request) {
  480. f := parseListFilters(r)
  481. filters := dlqQuery{
  482. CompanyID: f.CompanyID,
  483. Channel: f.Channel,
  484. AlertID: f.AlertID,
  485. IncludeDiscarded: f.Include == "all",
  486. Limit: f.Limit,
  487. Offset: f.Offset,
  488. }
  489. rows, err := queryDLQ(r.Context(), pool, filters)
  490. if err != nil {
  491. logger.Error("dlq ui list", "err", err)
  492. http.Error(w, "dlq ui: "+err.Error(), http.StatusInternalServerError)
  493. return
  494. }
  495. data := uiData{
  496. Rows: rows,
  497. Filters: f,
  498. ShowAll: f.Include == "all",
  499. HasFilters: f.CompanyID != "" || f.Channel != "" || f.AlertID != "",
  500. }
  501. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  502. if err := tpl.ExecuteTemplate(w, "dlq.html", data); err != nil {
  503. logger.Error("dlq ui render", "err", err)
  504. }
  505. }
  506. }
  507. // broker re-export so the handler signatures stay clean.
  508. // We import broker just for the Connect + Conn() pair.
  509. var _ = nats.Conn{}