diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a960b72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DanielCuevas1208 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4daff53..4a8acb9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small durable background queue built with Go and SQLite. -The project shows leases, retries, idempotency, crash recovery, priority dispatch, priority aging, a dead-letter queue, Prometheus metrics, and an append-only event log. +The project shows leases, retries, idempotency, crash recovery, priority dispatch, priority aging, a dead-letter queue, Prometheus metrics, an append-only event log, and an HTML inspection dashboard. ## Value @@ -21,6 +21,7 @@ The queue separates durable state from worker execution. - `internal/fault` injects deterministic errors, panics, delays, and stalls. - `internal/cli` renders commands, snapshots, history, and the demo. - `internal/metrics` renders queue state in the Prometheus text format. +- `internal/web` serves the HTML inspection dashboard and its JSON API. - `internal/fixture` provides repeatable sample workloads. A job starts as `pending`. @@ -67,6 +68,14 @@ go run . seed -db queue.db go run . inspect -db queue.db ``` +Serve the inspection dashboard with this command. + +```text +go run . web -addr :8080 -db queue.db +``` + +Open http://localhost:8080 in a browser. + Inspect and requeue a dead-lettered job with these commands. ```text @@ -89,7 +98,7 @@ jobqueue enqueue -kind -payload [-priority ] [-idempotency-key ### `work` ```text -jobqueue work -kind [-concurrency ] [-lease ] [-poll ] [-aging ] [-metrics-addr ] [-db ] +jobqueue work -kind [-concurrency ] [-lease ] [-poll ] [-aging ] [-metrics-addr ] [-web-addr ] [-db ] ``` The worker recovers expired leases when it starts. @@ -102,6 +111,8 @@ Use `-aging 0` to disable aging. Use `-metrics-addr` to serve Prometheus metrics beside the worker. +Use `-web-addr` to serve the inspection dashboard beside the worker. + ### `inspect` ```text @@ -154,6 +165,30 @@ Scrape the endpoint with a Prometheus server. Use `-once` to print one snapshot and exit. +### `web` + +```text +jobqueue web [-addr ] [-db ] +``` + +The command serves the HTML inspection dashboard. + +The default address is `:8080`. + +The dashboard shows state counts, jobs by kind, and a filterable job table. + +Click any job to see its payload and complete event timeline. + +The page refreshes itself every few seconds. + +The JSON API lives under `/api/`. + +Use `/api/snapshot` to fetch the full queue state. + +Use `/api/jobs/` to fetch one job and its events. + +Use `POST /api/jobs//requeue` to return a dead-lettered job to pending. + ### `demo` ```text @@ -260,6 +295,22 @@ Use the `metrics` command for one snapshot or a live endpoint. Use `work -metrics-addr` to serve the same endpoint beside a worker. +### Web dashboard + +The `web` command serves an HTML page and a JSON API. + +The page renders in the browser and needs no build step. + +State counts, job tables, and the event feed update on a timer. + +Click any job to inspect its payload and full timeline. + +The dashboard reads the same SQLite store as the worker. + +The JSON API is available to scripts without the HTML page. + +Use `work -web-addr` to serve the dashboard beside a worker. + ### Scheduling A scheduled job stores its earliest lease time in `run_at`. @@ -339,6 +390,32 @@ The demo uses generated job IDs and current timestamps. The final counts depend on the scenario and run deadline. +### Dashboard + +Run `jobqueue web -db queue.db` and open the root path. + +The page shows the whole queue in one view. + +```text +Local-first Durable Job Queue db: queue.db updated 18:13:10 + + pending 3 | leased 0 | completed 6 | dead letter 1 | failed 0 + +Jobs by kind + kind pending leased completed dead letter failed total + email 0 0 3 0 0 3 + report 2 0 0 1 0 3 + +Jobs + status kind pri attempts created payload + dead_letter report 0 3/3 2026-08-03 18:13:05 {"task":"report"} + pending report 2 0/3 2026-08-03 18:13:05 {"task":"report"} +``` + +The page polls the JSON API every three seconds. + +Click a job to open its detail panel and full timeline. + ## Verification Run the full test suite with this command. @@ -376,7 +453,7 @@ A high-priority stream can delay lower-priority jobs until aging lifts them. The worker is one process and does not coordinate across hosts. -The project does not provide a web interface. +The dashboard is read-only except for the requeue action. ## Roadmap @@ -386,12 +463,24 @@ The project does not provide a web interface. - [x] Priority aging to prevent starvation. - [x] Dead-letter queue with requeue of permanently failed jobs. - [x] Prometheus metrics for queue inspection. -- [ ] Web UI for queue inspection. +- [x] Web UI for queue inspection. - [ ] Horizontal scaling with a shared SQLite file. ### Release notes -This release adds Prometheus metrics. +This release adds a web dashboard for queue inspection. + +The new `web` command serves an HTML page and a JSON API. + +The page shows state counts, jobs by kind, and a filterable job table. + +Click a job to see its payload and complete event timeline. + +The page refreshes itself every few seconds while a worker runs. + +Use `work -web-addr` to serve the dashboard beside a worker. + +The previous release added Prometheus metrics. The new `metrics` command serves the exposition format over HTTP. diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..7444ced --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + "os" + + "github.com/local-first-job-queue/internal/cli" +) + +func main() { + log.SetFlags(0) + if err := cli.Web(os.Args[1:]); err != nil { + log.Fatal(err) + os.Exit(1) + } +} diff --git a/internal/cli/web.go b/internal/cli/web.go new file mode 100644 index 0000000..faa42b1 --- /dev/null +++ b/internal/cli/web.go @@ -0,0 +1,33 @@ +package cli + +import ( + "flag" + "fmt" + "log" + "net/http" + + "github.com/local-first-job-queue/internal/queue" + "github.com/local-first-job-queue/internal/web" +) + +// Web serves the HTML inspection dashboard and its JSON API. The dashboard +// reads the same SQLite store as the worker, so it shows jobs and events as +// they change. Point a browser at the listen address and open the root path. +func Web(args []string) error { + fs := flag.NewFlagSet("web", flag.ExitOnError) + addr := fs.String("addr", ":8080", "listen address for the dashboard") + dbPath := fs.String("db", "queue.db", "database path") + fs.Parse(args) + + store, err := queue.NewSQLiteStore(*dbPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + handler := web.New(store, web.WithDBPath(*dbPath)).Handler() + + log.Printf("dashboard listening on %s (db=%s)", *addr, *dbPath) + log.Printf("open: http://localhost%s/", *addr) + return http.ListenAndServe(*addr, handler) +} diff --git a/internal/cli/work.go b/internal/cli/work.go index 1013445..f394f11 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -14,6 +14,7 @@ import ( "github.com/local-first-job-queue/internal/fault" "github.com/local-first-job-queue/internal/metrics" "github.com/local-first-job-queue/internal/queue" + "github.com/local-first-job-queue/internal/web" "github.com/local-first-job-queue/internal/worker" ) @@ -37,6 +38,7 @@ func Work(args []string) error { pollInterval := fs.Duration("poll", time.Second, "time between lease attempts") aging := fs.Duration("aging", queue.DefaultAgingInterval, "priority aging interval; a job gains one priority point per interval it waits (0 disables)") metricsAddr := fs.String("metrics-addr", "", "address to serve Prometheus metrics on, e.g. :9090 (empty disables)") + webAddr := fs.String("web-addr", "", "address to serve the inspection dashboard on, e.g. :8080 (empty disables)") fs.Parse(args) store, err := queue.NewSQLiteStore(*dbPath, queue.WithAgingInterval(*aging)) @@ -53,15 +55,23 @@ func Work(args []string) error { worker.WithPollInterval(*pollInterval), ) - var srv *http.Server - if *metricsAddr != "" { - srv = &http.Server{Addr: *metricsAddr, Handler: metrics.Handler(store)} + var servers []*http.Server + serve := func(addr string, h http.Handler, label string) { + if addr == "" { + return + } + srv := &http.Server{Addr: addr, Handler: h} + servers = append(servers, srv) go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Printf("metrics server: %v", err) + log.Printf("%s: %v", label, err) } }() - log.Printf("metrics listening on %s", *metricsAddr) + log.Printf("%s listening on %s", label, addr) + } + serve(*metricsAddr, metrics.Handler(store), "metrics") + if *webAddr != "" { + serve(*webAddr, web.New(store, web.WithDBPath(*dbPath)).Handler(), "dashboard") } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -71,10 +81,10 @@ func Work(args []string) error { *kind, *concurrency, *leaseDuration, *pollInterval, *aging) err = w.Run(ctx) - if srv != nil { + for _, srv := range servers { shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() _ = srv.Shutdown(shutdownCtx) + cancel() } if errors.Is(err, context.Canceled) { // A signal cancelled the run. This is a normal shutdown, not an error. diff --git a/internal/web/dashboard.html b/internal/web/dashboard.html new file mode 100644 index 0000000..f636002 --- /dev/null +++ b/internal/web/dashboard.html @@ -0,0 +1,460 @@ + + + + + +Job Queue Dashboard + + + +
+

Local-first Durable Job Queue

+
+ db: {{.DBPath}} + updated - + + json +
+
+ +
+
+
+
+ +
+

Jobs by kind

+ + + +
KindPendingLeasedCompletedDead letterFailedTotal
+
+ +
+
+

Jobs

+
+
+ + + + + +
StatusKindPriAttemptsCreatedPayload
+

No jobs match the current filter.

+
+ +
+

Recent events

+
    +
    +
    + +
    + Inspect with: jobqueue inspect -db <path> | View one job with: jobqueue history <id> -db <path> | Requeue a dead letter with: jobqueue requeue <id> -db <path> +
    + + + + + + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..ad5ce3e --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,151 @@ +// Package web serves an HTML dashboard and a small JSON API for queue +// inspection. The dashboard reads the same SQLite store as the other tools, so +// it shows the live state of every job and event. The JSON endpoints are also +// useful on their own, because scripts can poll them without parsing HTML. +// +// The dashboard renders entirely in the browser. Each page load fetches one +// snapshot from /api/snapshot and re-renders the tables. A timer refreshes the +// snapshot every few seconds, so a worker's progress appears without a reload. +package web + +import ( + "database/sql" + _ "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "log" + "net/http" + + "github.com/local-first-job-queue/internal/queue" +) + +//go:embed dashboard.html +var dashboardHTML string + +var dashboardTpl = template.Must(template.New("dashboard").Parse(dashboardHTML)) + +// Server exposes queue state over HTTP. It owns no worker state, so any number +// of servers may read the same database file. +type Server struct { + store *queue.SQLiteStore + q *queue.Queue + dbPath string +} + +// Option configures a Server. +type Option func(*Server) + +// WithDBPath sets the database path shown in the dashboard header. The value is +// cosmetic: the store still reads from the path the caller opened. +func WithDBPath(path string) Option { + return func(s *Server) { + s.dbPath = path + } +} + +// New returns a Server that reads queue state from store. +func New(store *queue.SQLiteStore, opts ...Option) *Server { + s := &Server{store: store, q: queue.NewQueue(store)} + for _, o := range opts { + o(s) + } + return s +} + +// Handler returns the HTTP routes for the dashboard and its JSON API. +// +// GET / dashboard page +// GET /api/snapshot queue snapshot as JSON +// GET /api/jobs/{id} one job and its event timeline +// POST /api/jobs/{id}/requeue return a dead-lettered job to pending +// GET /healthz liveness probe +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /", s.dashboard) + mux.HandleFunc("GET /api/snapshot", s.snapshot) + mux.HandleFunc("GET /api/jobs/{id}", s.jobDetail) + mux.HandleFunc("POST /api/jobs/{id}/requeue", s.requeue) + mux.HandleFunc("GET /healthz", s.health) + return mux +} + +func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + page := struct{ DBPath string }{DBPath: s.dbPath} + if err := dashboardTpl.Execute(w, page); err != nil { + log.Printf("render dashboard: %v", err) + } +} + +func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { + snap, err := s.q.Inspect() + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("snapshot: %w", err)) + return + } + writeJSON(w, http.StatusOK, snap) +} + +// jobDetail returns one job and its event timeline. An unknown id produces a +// 404 so a dashboard can tell a missing job from a failed read. +func (s *Server) jobDetail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, err := s.store.GetJob(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, fmt.Errorf("job %s not found", id)) + return + } + writeError(w, http.StatusInternalServerError, fmt.Errorf("get job %s: %w", id, err)) + return + } + events, err := s.store.GetJobEvents(id) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("get events for %s: %w", id, err)) + return + } + writeJSON(w, http.StatusOK, jobDetail{Job: job, Events: events}) +} + +// requeue returns a dead-lettered job to the pending state. The queue layer +// rejects jobs that are not dead-lettered, so the endpoint reports a 400 when +// the caller asks for an invalid transition. +func (s *Server) requeue(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, err := s.q.Requeue(id) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, job) +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "ok") +} + +type jobDetail struct { + Job queue.Job `json:"job"` + Events []queue.Event `json:"events"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("encode json: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintln(w, err) +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..1ecfa0c --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,251 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/local-first-job-queue/internal/queue" +) + +func newTestServer(t *testing.T) (*Server, *queue.Queue) { + t.Helper() + s, err := queue.NewSQLiteStore("file:web_" + t.Name() + "?mode=memory&cache=shared") + if err != nil { + t.Fatalf("new store: %v", err) + } + t.Cleanup(func() { s.Close() }) + return New(s, WithDBPath("web_test.db")), queue.NewQueue(s) +} + +func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func post(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// TestDashboardServesPage verifies that the dashboard page renders with the +// configured database path and that the page shell is present. +func TestDashboardServesPage(t *testing.T) { + srv, _ := newTestServer(t) + rec := get(t, srv.Handler(), "/") + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected html content type, got %q", ct) + } + for _, want := range []string{ + "Local-first Durable Job Queue", + "web_test.db", + `id="jobs"`, + `id="events"`, + "/api/snapshot", + } { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("dashboard missing %q", want) + } + } +} + +// TestDashboardUnknownPathIs404 verifies that the dashboard does not claim +// paths it does not own. +func TestDashboardUnknownPathIs404(t *testing.T) { + srv, _ := newTestServer(t) + rec := get(t, srv.Handler(), "/nope") + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } +} + +// TestHealthz verifies the liveness endpoint. +func TestHealthz(t *testing.T) { + srv, _ := newTestServer(t) + rec := get(t, srv.Handler(), "/healthz") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if strings.TrimSpace(rec.Body.String()) != "ok" { + t.Errorf("expected body ok, got %q", rec.Body.String()) + } +} + +// TestSnapshotReflectsQueue verifies that the JSON API reports the same jobs, +// events, and state counts that the queue layer sees. +func TestSnapshotReflectsQueue(t *testing.T) { + srv, q := newTestServer(t) + ctx := context.Background() + + job, err := q.Enqueue("email", `{"to":"a@example.com"}`) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + leased, err := q.Lease(ctx, "email", time.Minute) + if err != nil || leased == nil { + t.Fatalf("lease: %v %v", leased, err) + } + if err := q.Acknowledge(leased.ID); err != nil { + t.Fatalf("ack: %v", err) + } + + rec := get(t, srv.Handler(), "/api/snapshot") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var snap queue.QueueSnapshot + if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if len(snap.Jobs) != 1 || snap.Jobs[0].ID != job.ID { + t.Errorf("expected one job with id %s, got %+v", job.ID, snap.Jobs) + } + if snap.Stats[queue.StateCompleted] != 1 { + t.Errorf("expected 1 completed job, got %v", snap.Stats) + } + if len(snap.Events) != 3 { + t.Errorf("expected 3 events (enqueued, leased, acknowledged), got %d", len(snap.Events)) + } +} + +// TestJobDetailShowsTimeline verifies that one job's endpoint returns the job +// and its events in insertion order. +func TestJobDetailShowsTimeline(t *testing.T) { + srv, q := newTestServer(t) + ctx := context.Background() + + job, err := q.Enqueue("report", `{"n":1}`, queue.WithMaxAttempts(2)) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + leased, err := q.Lease(ctx, "report", time.Minute) + if err != nil || leased == nil { + t.Fatalf("lease: %v %v", leased, err) + } + if err := q.Fail(leased.ID, "boom"); err != nil { + t.Fatalf("fail: %v", err) + } + + rec := get(t, srv.Handler(), "/api/jobs/"+job.ID) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String()) + } + var d jobDetail + if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil { + t.Fatalf("decode job detail: %v", err) + } + if d.Job.ID != job.ID || d.Job.State != queue.StatePending { + t.Errorf("expected pending job %s, got %+v", job.ID, d.Job) + } + if d.Job.RetryCount != 1 { + t.Errorf("expected one retry, got %d", d.Job.RetryCount) + } + if len(d.Events) != 3 { + t.Fatalf("expected 3 events, got %d", len(d.Events)) + } + wantTypes := []queue.EventType{queue.EventEnqueued, queue.EventLeased, queue.EventRetried} + for i, want := range wantTypes { + if d.Events[i].EventType != want { + t.Errorf("event %d: expected %s, got %s", i, want, d.Events[i].EventType) + } + } +} + +// TestJobDetailNotFound verifies that an unknown id is a 404 rather than an +// internal error. +func TestJobDetailNotFound(t *testing.T) { + srv, _ := newTestServer(t) + rec := get(t, srv.Handler(), "/api/jobs/does-not-exist") + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } +} + +// TestRequeueReturnsDeadLetteredJob verifies the POST endpoint that returns a +// dead-lettered job to the pending state. +func TestRequeueReturnsDeadLetteredJob(t *testing.T) { + srv, q := newTestServer(t) + ctx := context.Background() + + job, err := q.Enqueue("email", `{"bad":true}`, queue.WithMaxAttempts(1)) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + leased, err := q.Lease(ctx, "email", time.Minute) + if err != nil || leased == nil { + t.Fatalf("lease: %v %v", leased, err) + } + if err := q.Fail(leased.ID, "disk full"); err != nil { + t.Fatalf("fail: %v", err) + } + + rec := post(t, srv.Handler(), "/api/jobs/"+job.ID+"/requeue") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String()) + } + var updated queue.Job + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatalf("decode requeue response: %v", err) + } + if updated.State != queue.StatePending { + t.Errorf("expected pending after requeue, got %s", updated.State) + } + if updated.RetryCount != 0 { + t.Errorf("expected retry count reset, got %d", updated.RetryCount) + } + + snap, err := q.Inspect() + if err != nil { + t.Fatalf("inspect: %v", err) + } + found := false + for _, e := range snap.Events { + if e.JobID == job.ID && e.EventType == queue.EventRequeued { + found = true + } + } + if !found { + t.Error("expected a requeued event in the log") + } +} + +// TestRequeueRejectsLiveJob verifies that the endpoint refuses to requeue a job +// that is not dead-lettered, matching the queue layer's rule. +func TestRequeueRejectsLiveJob(t *testing.T) { + srv, q := newTestServer(t) + job, err := q.Enqueue("email", `{}`) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + rec := post(t, srv.Handler(), "/api/jobs/"+job.ID+"/requeue") + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rec.Code) + } +} + +// TestReadOnlyRoutesRejectPOST verifies that the Go mux returns 405 for writes +// to read-only endpoints. +func TestReadOnlyRoutesRejectPOST(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + for _, path := range []string{"/api/snapshot", "/healthz"} { + rec := post(t, h, path) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: expected 405, got %d", path, rec.Code) + } + } +} diff --git a/main.go b/main.go index 9119ad9..2687f08 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ Commands: requeue Return a dead-lettered job to the queue seed Load bundled sample data metrics Expose queue state for Prometheus + web Serve the inspection dashboard demo Run a self-contained scenario with fault injection Use -help for command flags.`) @@ -48,6 +49,8 @@ Use -help for command flags.`) err = cli.Seed(args) case "metrics": err = cli.Metrics(args) + case "web": + err = cli.Web(args) case "demo": err = cli.Demo(args) default: