diff --git a/README.md b/README.md index 4daff53..e2aa471 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 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, and priority aging. + +It also shows a dead-letter queue, Prometheus metrics, a web dashboard, and an append-only event log. ## Value @@ -21,6 +23,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 a read-only HTML dashboard for queue inspection. - `internal/fixture` provides repeatable sample workloads. A job starts as `pending`. @@ -44,6 +47,7 @@ go build -o jobqueue . ./jobqueue enqueue -kind email -payload '{"to":"user@example.com"}' -priority 20 ./jobqueue work -kind email ./jobqueue inspect +./jobqueue web ``` Use `-priority` to place urgent work ahead of normal work. @@ -89,7 +93,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 +106,26 @@ Use `-aging 0` to disable aging. Use `-metrics-addr` to serve Prometheus metrics beside the worker. +Use `-web-addr` to serve the web dashboard beside the worker. + +### `web` + +```text +jobqueue web [-addr ] [-db ] +``` + +The command serves a read-only HTML dashboard. + +Open the dashboard in a browser to inspect queue state. + +The dashboard shows state counts, jobs, and recent events. + +Each job page shows one job and its full event timeline. + +The default address is `:8080`. + +Every page reads the store and shows current data. + ### `inspect` ```text @@ -260,6 +284,20 @@ 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 a read-only HTML dashboard. + +The dashboard shows state counts, jobs, and recent events. + +Each job page shows one job and its event timeline. + +The pages read the store on every request. + +The dashboard refreshes every five seconds. + +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 +377,16 @@ The demo uses generated job IDs and current timestamps. The final counts depend on the scenario and run deadline. +Serve the web dashboard with this command. + +```text +jobqueue web -db queue.db +2026/08/04 12:00:00 dashboard listening on :8080 (db=queue.db) +2026/08/04 12:00:00 open the dashboard at http://localhost:8080 +``` + +The dashboard shows the same data as `jobqueue inspect`. + ## Verification Run the full test suite with this command. @@ -376,7 +424,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 and cannot change queue state. ## Roadmap @@ -386,12 +434,26 @@ 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 a read-only HTML interface. + +The dashboard shows state counts, jobs, and recent events. + +Each job page shows one job and its full event timeline. + +The pages read the SQLite store on every request. + +Use `work -web-addr` to serve the dashboard beside a worker. + +The dashboard is read-only; the queue API keeps full control. + +The previous release added Prometheus metrics. The new `metrics` command serves the exposition format over HTTP. diff --git a/internal/cli/demo.go b/internal/cli/demo.go index 7048f28..1bae935 100644 --- a/internal/cli/demo.go +++ b/internal/cli/demo.go @@ -208,6 +208,7 @@ func Demo(args []string) error { fmt.Printf("\ninspect again with: jobqueue inspect -db %q\n", path) fmt.Printf("inspect a job with: jobqueue history -db %q\n", path) fmt.Printf("requeue a dead letter with: jobqueue requeue -db %q\n", path) + fmt.Printf("view in a browser with: jobqueue web -db %q\n", path) return nil } diff --git a/internal/cli/web.go b/internal/cli/web.go new file mode 100644 index 0000000..eaa186a --- /dev/null +++ b/internal/cli/web.go @@ -0,0 +1,31 @@ +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 a read-only HTML dashboard for queue inspection. The dashboard +// shows state counts, jobs, and recent events, and each job page shows its full +// event timeline. Open the printed address in a browser. +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() + + log.Printf("dashboard listening on %s (db=%s)", *addr, *dbPath) + log.Printf("open the dashboard at http://localhost%s", *addr) + return http.ListenAndServe(*addr, web.Handler(store)) +} diff --git a/internal/cli/work.go b/internal/cli/work.go index 1013445..768a384 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 web dashboard on, e.g. :8080 (empty disables)") fs.Parse(args) store, err := queue.NewSQLiteStore(*dbPath, queue.WithAgingInterval(*aging)) @@ -53,16 +55,25 @@ func Work(args []string) error { worker.WithPollInterval(*pollInterval), ) - var srv *http.Server + var metricsSrv, webSrv *http.Server if *metricsAddr != "" { - srv = &http.Server{Addr: *metricsAddr, Handler: metrics.Handler(store)} + metricsSrv = &http.Server{Addr: *metricsAddr, Handler: metrics.Handler(store)} go func() { - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("metrics server: %v", err) } }() log.Printf("metrics listening on %s", *metricsAddr) } + if *webAddr != "" { + webSrv = &http.Server{Addr: *webAddr, Handler: web.Handler(store)} + go func() { + if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("web dashboard: %v", err) + } + }() + log.Printf("web dashboard listening on %s", *webAddr) + } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -71,10 +82,15 @@ func Work(args []string) error { *kind, *concurrency, *leaseDuration, *pollInterval, *aging) err = w.Run(ctx) - if srv != nil { + if metricsSrv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = metricsSrv.Shutdown(shutdownCtx) + } + if webSrv != nil { shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - _ = srv.Shutdown(shutdownCtx) + _ = webSrv.Shutdown(shutdownCtx) } 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..ad97850 --- /dev/null +++ b/internal/web/dashboard.html @@ -0,0 +1,84 @@ +{{define "title"}}Dashboard{{end}} +{{define "content"}} +

Dashboard

+ +
+{{range .Stats}} +
+
{{.Count}}
+
{{.State}}
+
+{{end}} +
+ +
+

Jobs by kind and state

+ {{if .Kinds}} + + + + + + {{range .Kinds}} + + + + + + {{end}} + +
KindStateCount
{{.Kind}}{{.State}}{{.Count}}
+ {{else}} +

No jobs yet.

+ {{end}} +
+ +
+

Recent events ({{len .Events}})

+ {{if .Events}} + + + + + + {{range .Events}} + + + + + + + {{end}} + +
TimeJobEventDetail
{{fmtTime .Timestamp}}{{shortID .JobID}}{{.EventType}}{{if .Metadata}}{{.Metadata}}{{else}}none{{end}}
+ {{else}} +

No events recorded yet.

+ {{end}} +
+ +
+

Jobs ({{len .Jobs}})

+ {{if .Jobs}} + + + + + + {{range .Jobs}} + + + + + + + + + + {{end}} + +
IDKindStatePriorityAttemptsCreatedPayload
{{shortID .ID}}{{.Kind}}{{.State}}{{.Priority}}{{.RetryCount}}/{{.MaxAttempts}}{{fmtTime .CreatedAt}}{{.Payload}}
+ {{else}} +

No jobs in the database.

+ {{end}} +
+{{end}} diff --git a/internal/web/job.html b/internal/web/job.html new file mode 100644 index 0000000..ed3f8f9 --- /dev/null +++ b/internal/web/job.html @@ -0,0 +1,47 @@ +{{define "title"}}Job {{.Job.ID}}{{end}} +{{define "content"}} +

Job {{.Job.ID}}

+

← back to dashboard

+ +
+

Details

+ + + + + + + + {{if .Job.IdempotencyKey}}{{end}} + {{if .Job.RunAt}}{{end}} + {{if .Job.LeasedUntil}}{{end}} +
State{{.Job.State}}
Kind{{.Job.Kind}}
Priority{{.Job.Priority}}
Attempts{{.Job.RetryCount}} / {{.Job.MaxAttempts}}
Created{{fmtTime .Job.CreatedAt}}
Updated{{fmtTime .Job.UpdatedAt}}
Idempotency key{{.Job.IdempotencyKey}}
Run at{{fmtTime .Job.RunAt}}
Leased until{{fmtTime .Job.LeasedUntil}}
+
+ +
+

Payload

+
{{.Job.Payload}}
+
+ +
+

Event timeline ({{len .Events}})

+ {{if .Events}} + + + + + + {{range .Events}} + + + + + + {{end}} + +
TimeEventDetail
{{fmtTime .Timestamp}}{{.EventType}}{{if .Metadata}}{{.Metadata}}{{else}}none{{end}}
+ {{else}} +

No events for this job.

+ {{end}} +
+{{end}} diff --git a/internal/web/layout.html b/internal/web/layout.html new file mode 100644 index 0000000..a79e6af --- /dev/null +++ b/internal/web/layout.html @@ -0,0 +1,86 @@ + + + + + + +{{template "title" .}} · Local-first Job Queue + + + +
+ +
+
+{{template "content" .}} +
+
Generated {{fmtTime .GeneratedAt}} UTC · SQLite-backed queue
+ + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..de4df03 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,203 @@ +// Package web serves a read-only HTML dashboard for the queue. An operator +// opens the dashboard in a browser to inspect queue state, jobs, and the +// append-only event log. Every page reads the shared SQLite store, so it shows +// the same data as the inspect and metrics commands. +package web + +import ( + "bytes" + "database/sql" + "embed" + "errors" + "fmt" + "html/template" + "net/http" + "time" + + "github.com/local-first-job-queue/internal/queue" +) + +//go:embed *.html +var templateFiles embed.FS + +// Each page is its own template set. A shared layout file defines the page +// frame, and the page file fills the title and content blocks. Keeping the sets +// separate stops the page-specific block names from overwriting each other. +var dashboardTemplates = template.Must( + template.New("layout.html").Funcs(templateFuncs).ParseFS(templateFiles, "layout.html", "dashboard.html"), +) +var jobTemplates = template.Must( + template.New("layout.html").Funcs(templateFuncs).ParseFS(templateFiles, "layout.html", "job.html"), +) + +var templateFuncs = template.FuncMap{ + "shortID": shortID, + "fmtTime": fmtTime, + "stateClass": stateClass, +} + +// recentEventsLimit caps the events shown on the dashboard. The event log is +// append-only and grows without bound, so the dashboard keeps the newest rows. +const recentEventsLimit = 25 + +// The canonical state order keeps the stat cards stable across reloads. The +// failed state appears for legacy databases that predate the dead-letter queue. +var stateOrder = []queue.JobState{ + queue.StatePending, + queue.StateLeased, + queue.StateCompleted, + queue.StateDeadLetter, + queue.StateFailed, +} + +type stateStat struct { + State queue.JobState + Count int +} + +// dashboardPage carries the data rendered by the dashboard template. +type dashboardPage struct { + GeneratedAt time.Time + Stats []stateStat + Kinds []queue.KindStateCount + Events []queue.Event + Jobs []queue.Job +} + +// jobPage carries the data rendered by the job detail template. +type jobPage struct { + GeneratedAt time.Time + Job queue.Job + Events []queue.Event +} + +// Handler returns the HTTP handler for the dashboard. The root path shows the +// queue overview and /jobs/{id} shows one job with its event timeline. +func Handler(store *queue.SQLiteStore) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", dashboard(store)) + mux.HandleFunc("GET /jobs/{id}", job(store)) + return mux +} + +func dashboard(store *queue.SQLiteStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stats, err := store.GetQueueStats() + if err != nil { + serverError(w) + return + } + kinds, err := store.GetStateKindCounts() + if err != nil { + serverError(w) + return + } + events, err := store.GetAllEvents() + if err != nil { + serverError(w) + return + } + jobs, err := store.GetAllJobs() + if err != nil { + serverError(w) + return + } + if len(events) > recentEventsLimit { + events = events[:recentEventsLimit] + } + write(w, dashboardTemplates, dashboardPage{ + GeneratedAt: time.Now().UTC(), + Stats: orderedStats(stats), + Kinds: kinds, + Events: events, + Jobs: jobs, + }) + } +} + +func job(store *queue.SQLiteStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, err := store.GetJob(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + serverError(w) + return + } + events, err := store.GetJobEvents(id) + if err != nil { + serverError(w) + return + } + write(w, jobTemplates, jobPage{ + GeneratedAt: time.Now().UTC(), + Job: job, + Events: events, + }) + } +} + +func orderedStats(stats map[queue.JobState]int) []stateStat { + out := make([]stateStat, 0, len(stateOrder)) + for _, s := range stateOrder { + out = append(out, stateStat{State: s, Count: stats[s]}) + } + return out +} + +// write renders a page into a buffer before sending it. A template failure +// therefore produces a clean 500 instead of a truncated page. +func write(w http.ResponseWriter, t *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "layout.html", data); err != nil { + serverError(w) + return + } + _, _ = w.Write(buf.Bytes()) +} + +func serverError(w http.ResponseWriter) { + http.Error(w, "internal server error", http.StatusInternalServerError) +} + +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +// fmtTime renders a timestamp in a fixed UTC form. It accepts a time.Time or a +// pointer, so templates can pass nullable fields directly. +func fmtTime(t any) string { + switch v := t.(type) { + case time.Time: + return v.UTC().Format("2006-01-02 15:04:05") + case *time.Time: + if v == nil { + return "" + } + return v.UTC().Format("2006-01-02 15:04:05") + default: + return fmt.Sprint(t) + } +} + +// stateClass maps a job state to a CSS class used for badges and stat cards. +func stateClass(s queue.JobState) string { + switch s { + case queue.StatePending: + return "pending" + case queue.StateLeased: + return "leased" + case queue.StateCompleted: + return "completed" + case queue.StateDeadLetter, queue.StateFailed: + return "dead" + } + return "" +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..8702540 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,216 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/local-first-job-queue/internal/queue" +) + +func newTestStore(t *testing.T) (*queue.SQLiteStore, *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 s, 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 +} + +// TestDashboardEmptyQueue verifies that a fresh queue renders every state with +// a zero count and the empty-state messages. +func TestDashboardEmptyQueue(t *testing.T) { + s, _ := newTestStore(t) + rec := get(t, Handler(s), "/") + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "Local-first Job Queue", + ">pending<", + ">leased<", + ">completed<", + ">dead_letter<", + ">failed<", + "
0
", + "No jobs yet.", + "No events recorded yet.", + "No jobs in the database.", + } { + if !strings.Contains(body, want) { + t.Errorf("dashboard missing %q in:\n%s", want, body) + } + } +} + +// TestDashboardRendersWorkload verifies that a queue with completed, pending, +// and dead-lettered jobs shows each state count, kind, and recent event. +func TestDashboardRendersWorkload(t *testing.T) { + s, q := newTestStore(t) + ctx := context.Background() + + email, err := q.Enqueue("email", `{"to":"a@example.com"}`) + if err != nil { + t.Fatalf("enqueue email: %v", err) + } + flaky, err := q.Enqueue("report", `{}`, queue.WithMaxAttempts(1), queue.WithPriority(1)) + if err != nil { + t.Fatalf("enqueue flaky: %v", err) + } + if _, err := q.Enqueue("report", `{}`); err != nil { + t.Fatalf("enqueue report: %v", err) + } + + job, err := q.Lease(ctx, "email", time.Minute) + if err != nil || job == nil { + t.Fatalf("lease email: %v %v", job, err) + } + if err := q.Acknowledge(job.ID); err != nil { + t.Fatalf("ack email: %v", err) + } + leased, err := q.Lease(ctx, "report", time.Minute) + if err != nil || leased == nil || leased.ID != flaky.ID { + t.Fatalf("lease flaky: %v %v", leased, err) + } + if err := q.Fail(leased.ID, "boom"); err != nil { + t.Fatalf("fail flaky: %v", err) + } + + rec := get(t, Handler(s), "/") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "email", + "report", + "acknowledged", + "dead_lettered", + `href="/jobs/` + email.ID + `"`, + `href="/jobs/` + flaky.ID + `"`, + } { + if !strings.Contains(body, want) { + t.Errorf("dashboard missing %q in:\n%s", want, body) + } + } +} + +// TestDashboardEscapesPayloads verifies that job payloads are HTML-escaped, so +// a hostile payload cannot inject markup into the page. +func TestDashboardEscapesPayloads(t *testing.T) { + s, q := newTestStore(t) + payload := `{"n":1,"x":""}` + if _, err := q.Enqueue("test", payload); err != nil { + t.Fatalf("enqueue: %v", err) + } + + rec := get(t, Handler(s), "/") + body := rec.Body.String() + if strings.Contains(body, "") { + t.Fatalf("payload was not escaped:\n%s", body) + } + if !strings.Contains(body, "<script>alert(1)</script>") { + t.Errorf("expected escaped payload, got:\n%s", body) + } +} + +// TestJobPageRendersTimeline verifies that one job page shows the job details +// and its complete event timeline in order. +func TestJobPageRendersTimeline(t *testing.T) { + s, q := newTestStore(t) + ctx := context.Background() + + job, err := q.Enqueue("test", `{"n":1}`) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + leased, err := q.Lease(ctx, "test", 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, Handler(s), "/jobs/"+job.ID) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "Job " + job.ID, + ">completed<", + "Payload", + "enqueued", + "leased", + "acknowledged", + "back to dashboard", + } { + if !strings.Contains(body, want) { + t.Errorf("job page missing %q in:\n%s", want, body) + } + } + // The timeline must be oldest first: enqueued before acknowledged. + if strings.Index(body, ">enqueued<") > strings.Index(body, ">acknowledged<") { + t.Errorf("expected enqueued event before acknowledged event:\n%s", body) + } +} + +// TestJobPageShowsMetadata verifies that a scheduled job with an idempotency +// key renders both fields on its detail page. +func TestJobPageShowsMetadata(t *testing.T) { + s, q := newTestStore(t) + + job, err := q.Enqueue("test", `{}`, + queue.WithIdempotencyKey("dup-123"), + queue.WithRunAt(time.Now().Add(time.Hour)), + ) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + rec := get(t, Handler(s), "/jobs/"+job.ID) + body := rec.Body.String() + for _, want := range []string{ + "Idempotency key", + "dup-123", + "Run at", + ">scheduled<", + } { + if !strings.Contains(body, want) { + t.Errorf("job page missing %q in:\n%s", want, body) + } + } +} + +// TestJobPageUnknownJob404 verifies that a missing job id returns 404. +func TestJobPageUnknownJob404(t *testing.T) { + s, _ := newTestStore(t) + rec := get(t, Handler(s), "/jobs/does-not-exist") + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +// TestDashboardContentType verifies that pages are served as HTML. +func TestDashboardContentType(t *testing.T) { + s, _ := newTestStore(t) + rec := get(t, Handler(s), "/") + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html content type, got %q", ct) + } +} diff --git a/main.go b/main.go index 9119ad9..8472258 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 a read-only HTML 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: