Report inbox: /api/reports in its own bounded reports.db, dashboard Reports page (list, filter, triage, copy as text)
Build and Push Docker Image / build (push) Failing after 1m20s
Build and Push Docker Image / build (push) Failing after 1m20s
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
// Report inbox (/api/reports): bug reports and feedback filed from the client
|
||||
// apps. An app's browser builds the report, the app's server forwards it here
|
||||
// server-to-server, and the dashboard's Reports page reads it back. Storage
|
||||
// and bounds live in internal/reports.
|
||||
//
|
||||
// Auth: like the rest of /api, gated at the perimeter — do NOT add to the
|
||||
// nginx-hash-lock unauth bypass. Apps reach meta-core on the internal network.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
)
|
||||
|
||||
// maxReportBody bounds one POST. A report is at most a few blocks of 32 KiB
|
||||
// plus a 200-line log tail; anything larger is not a report.
|
||||
const maxReportBody = 512 << 10
|
||||
|
||||
// SetReports attaches the report inbox. Separate from NewServer so its
|
||||
// signature (and its tests) stay untouched; nil leaves the routes answering
|
||||
// 503.
|
||||
func (s *Server) SetReports(r *reports.Store) {
|
||||
s.reports = r
|
||||
}
|
||||
|
||||
func (s *Server) setupReportRoutes() {
|
||||
s.router.HandleFunc("/api/reports", s.handlePostReport).Methods("POST")
|
||||
s.router.HandleFunc("/api/reports", s.handleListReports).Methods("GET")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleGetReport).Methods("GET")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handlePatchReport).Methods("PATCH")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleDeleteReport).Methods("DELETE")
|
||||
}
|
||||
|
||||
func (s *Server) reportsReady(w http.ResponseWriter) bool {
|
||||
if s.reports == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "report inbox not available")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reportID(r *http.Request) int64 {
|
||||
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// handlePostReport handles POST /api/reports → 201 {id}.
|
||||
func (s *Server) handlePostReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
var in map[string]json.RawMessage
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody)).Decode(&in); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "report larger than 512 KiB")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "body must be a JSON object")
|
||||
return
|
||||
}
|
||||
rep, err := reports.Normalize(in, time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := s.reports.Insert(rep)
|
||||
if err != nil {
|
||||
log.Printf("[reports] insert failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not store the report")
|
||||
return
|
||||
}
|
||||
log.Printf("[reports] #%d filed by %s (%s)", id, rep.App, firstLine(rep.Note))
|
||||
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
|
||||
}
|
||||
|
||||
// handleListReports handles GET /api/reports?app=&status=&q=&before=&limit=.
|
||||
func (s *Server) handleListReports(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := reports.Filter{App: q.Get("app"), Status: q.Get("status"), Q: q.Get("q")}
|
||||
f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
|
||||
f.Limit, _ = strconv.Atoi(q.Get("limit"))
|
||||
if f.Status != "" && !reports.ValidStatus(f.Status) {
|
||||
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
|
||||
return
|
||||
}
|
||||
page, err := s.reports.List(f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
// handleGetReport handles GET /api/reports/{id}.
|
||||
func (s *Server) handleGetReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
rep, err := s.reports.Get(reportID(r))
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handlePatchReport handles PATCH /api/reports/{id} {status?, triage_note?}.
|
||||
func (s *Server) handlePatchReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Status *string `json:"status"`
|
||||
TriageNote *string `json:"triage_note"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "body must be a JSON object")
|
||||
return
|
||||
}
|
||||
if in.Status == nil && in.TriageNote == nil {
|
||||
writeError(w, http.StatusBadRequest, "nothing to change: send status and/or triage_note")
|
||||
return
|
||||
}
|
||||
if in.Status != nil && !reports.ValidStatus(*in.Status) {
|
||||
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
|
||||
return
|
||||
}
|
||||
rep, err := s.reports.Update(reportID(r), in.Status, in.TriageNote)
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handleDeleteReport handles DELETE /api/reports/{id}.
|
||||
func (s *Server) handleDeleteReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
err := s.reports.Delete(reportID(r))
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// pruneReports re-applies the inbox bounds hourly. Insert already prunes, so
|
||||
// this only matters when a bound was lowered between restarts.
|
||||
func (s *Server) pruneReports() {
|
||||
run := func() {
|
||||
if n, err := s.reports.Prune(); err != nil {
|
||||
log.Printf("[reports] prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[reports] pruned %d report(s) over the inbox bounds", n)
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if r := []rune(s); len(r) > 80 {
|
||||
s = string(r[:80]) + "…"
|
||||
}
|
||||
if s == "" {
|
||||
return "no note"
|
||||
}
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
)
|
||||
|
||||
func newReportServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store, err := reports.Open(filepath.Join(t.TempDir(), "reports.db"), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
s := &Server{router: mux.NewRouter(), reports: store}
|
||||
s.setupReportRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
func TestReportsRoundTrip(t *testing.T) {
|
||||
s := newReportServer(t)
|
||||
|
||||
rec, out := do(t, s, "POST", "/api/reports", map[string]any{
|
||||
"app": "meta-read", "note": "blank page", "uid": "u1",
|
||||
"reader": map[string]any{"page": 3}, "log": []string{"a", "b"},
|
||||
})
|
||||
if rec.Code != http.StatusCreated || out["id"] == nil {
|
||||
t.Fatalf("post: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := int64(out["id"].(float64))
|
||||
|
||||
rec, out = do(t, s, "GET", "/api/reports", nil)
|
||||
list, _ := out["reports"].([]any)
|
||||
if rec.Code != 200 || len(list) != 1 {
|
||||
t.Fatalf("list: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
first := list[0].(map[string]any)
|
||||
if _, hasBody := first["body"]; hasBody || first["log_lines"].(float64) != 2 {
|
||||
t.Fatalf("summary should carry log_lines, not the body: %v", first)
|
||||
}
|
||||
|
||||
path := "/api/reports/" + itoa(id)
|
||||
rec, out = do(t, s, "GET", path, nil)
|
||||
body, _ := out["body"].(map[string]any)
|
||||
if rec.Code != 200 || body["reader"] == nil || out["uid"] != "u1" {
|
||||
t.Fatalf("get: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
rec, out = do(t, s, "PATCH", path, map[string]any{"status": "triaged", "triage_note": "Deck #1"})
|
||||
if rec.Code != 200 || out["status"] != "triaged" || out["triage_note"] != "Deck #1" {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, _ = do(t, s, "GET", "/api/reports?status=triaged&app=meta-read", nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"triaged":1`) {
|
||||
t.Fatalf("filtered list: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
if rec, _ = do(t, s, "DELETE", path, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
if rec, _ = do(t, s, "GET", path, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("get after delete: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsRejects(t *testing.T) {
|
||||
s := newReportServer(t)
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
want int
|
||||
}{
|
||||
{"no app", "POST", "/api/reports", map[string]any{"note": "x"}, 400},
|
||||
{"bad status filter", "GET", "/api/reports?status=done", nil, 400},
|
||||
{"patch nothing", "PATCH", "/api/reports/1", map[string]any{}, 400},
|
||||
{"patch bad status", "PATCH", "/api/reports/1", map[string]any{"status": "done"}, 400},
|
||||
{"patch missing", "PATCH", "/api/reports/1", map[string]any{"status": "closed"}, 404},
|
||||
{"delete missing", "DELETE", "/api/reports/1", nil, 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if rec, _ := do(t, s, c.method, c.path, c.body); rec.Code != c.want {
|
||||
t.Errorf("%s: got %d want %d (%s)", c.name, rec.Code, c.want, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Not a JSON object.
|
||||
req := httptest.NewRequest("POST", "/api/reports", strings.NewReader(`[1,2]`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("array body: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Over the body cap.
|
||||
big := bytes.Repeat([]byte("x"), maxReportBody+1)
|
||||
req = httptest.NewRequest("POST", "/api/reports", strings.NewReader(`{"app":"a","note":"`+string(big)+`"}`))
|
||||
rec = httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized body: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsUnavailableWithoutStore(t *testing.T) {
|
||||
s := &Server{router: mux.NewRouter()}
|
||||
s.setupReportRoutes()
|
||||
if rec, _ := do(t, s, "GET", "/api/reports", nil); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("no store: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
+11
-1
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/metazla/meta-core/internal/leader"
|
||||
"github.com/metazla/meta-core/internal/meshdisco"
|
||||
"github.com/metazla/meta-core/internal/mounts"
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
"github.com/metazla/meta-core/internal/schema"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
"github.com/metazla/meta-core/internal/watcher"
|
||||
@@ -47,6 +48,8 @@ type Server struct {
|
||||
mesh *meshdisco.Node
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
// reports is the bug-report inbox (reports.go); nil = routes answer 503.
|
||||
reports *reports.Store
|
||||
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
||||
// dashboard never puts a multi-second store walk on the request path.
|
||||
stats *statsCache
|
||||
@@ -279,6 +282,10 @@ func (s *Server) setupRoutes() {
|
||||
s.router.HandleFunc("/neighbors", s.handleListNeighbors).Methods("GET")
|
||||
s.router.HandleFunc("/api/neighbors", s.handleListNeighbors).Methods("GET")
|
||||
|
||||
// Report inbox: bug reports / feedback forwarded by the client apps,
|
||||
// read by the dashboard's Reports page. Perimeter-gated (reports.go).
|
||||
s.setupReportRoutes()
|
||||
|
||||
// Mount management routes (if manager initialized)
|
||||
if s.mountsHandlers != nil {
|
||||
s.mountsHandlers.RegisterRoutes(s.router)
|
||||
@@ -363,6 +370,9 @@ func (s *Server) Start() error {
|
||||
}()
|
||||
go s.logWriteStats()
|
||||
}
|
||||
if s.reports != nil {
|
||||
go s.pruneReports()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -468,7 +478,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
|
||||
Reference in New Issue
Block a user