diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eac7ee6..1e747d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,34 @@ jobs: - name: Lint run: "$(go env GOPATH)/bin/golangci-lint run ./..." + + web: + name: dashboard (build & test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + # Fail if the committed web/dist is stale vs a fresh build of the source. + - name: Verify committed dist is in sync + run: git diff --exit-code -- dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c331b0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +# visual-companion brainstorm mockups (ephemeral) +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index dcf70c5..1043397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,20 +10,22 @@ project: the point is to *prove understanding of queue internals*, not to wrap a library. Do not introduce a queue dependency (BullMQ, asynq, Machinery, Celery, etc.) — the mechanics are the deliverable. -**Status: Phase 1 complete; Phase 2 complete; Phase 3 in progress — 3a (HTTP API + server) ✅ -done.** The core engine plus delayed jobs, the promoter, retry backoff, priority, idempotency -enforcement, per-queue rate limiting, Prometheus metrics, and the JSON REST API + server are -built, tested against a real Redis under `-race`, and CI is green. Dashboard (3b), producer SDK -(3c), and packaging/deploy (3d) remain. Repo: . What -exists today: +**Status: Phase 1 complete; Phase 2 complete; Phase 3 in progress — 3a (HTTP API + server) ✅, +3b (dashboard) ✅ done.** The core engine plus delayed jobs, the promoter, retry backoff, priority, +idempotency enforcement, per-queue rate limiting, Prometheus metrics, the JSON REST API + server, +and the embedded React dashboard are built, tested against a real Redis under `-race`, and CI is +green. Producer SDK (3c) and packaging/deploy (3d) remain. Repo: . +What exists today: - `internal/job` — the `Job` model + Redis-hash encoding (`ToHash`/`FromHash`). - `internal/broker` — `Enqueue` (with `WithDelay`/`WithReadyAt`/`WithPriority`/`WithIdempotencyKey` options), atomic `Claim`, `Ack`, `Nack` (full-jitter backoff via the delayed set), `Reap`, `Promote`, `Extend` (heartbeat), `Stats` (ZCARD/LLEN snapshot per queue), `ListDLQ` (paged DLQ inspection), `RequeueDLQ` - (atomic dlq→ready reset via `requeue.lua`), `Queues` (SCAN-based queue discovery), with - Lua under `internal/broker/scripts/`: `enqueue.lua`, `claim.lua`, `ack.lua`, `nack.lua`, - `reaper.lua`, `promote.lua`, `heartbeat.lua`, `requeue.lua`. Broker options: `WithBackoff`, + (atomic dlq→ready reset via `requeue.lua`), `Queues` (SCAN-based queue discovery), + `Counters(ctx, queue)` (reads `q:{name}:processed` and `q:{name}:dead` cumulative counters), with + Lua under `internal/broker/scripts/`: `enqueue.lua`, `claim.lua`, `ack.lua` (INCRs + `q:{name}:processed`), `nack.lua` (INCRs `q:{name}:dead` on dead-letter), `reaper.lua`, + `promote.lua`, `heartbeat.lua`, `requeue.lua`. Broker options: `WithBackoff`, `WithDedupTTL`, `WithRateLimit(queue, rate, burst)` (token-bucket per-queue rate limiting via Redis hash), `WithMetrics(m)` (installs a `broker.Metrics` implementation; default is a no-op). - `internal/metrics` — Prometheus `Recorder` (implements `broker.Metrics`; counters @@ -35,16 +37,25 @@ exists today: - `internal/api` — JSON REST API over stdlib `net/http` (Go 1.22 method+path routing). Endpoints: `POST /api/queues/{queue}/jobs` (enqueue; 409 on idempotency dup), `GET /api/queues/{queue}/stats`, `GET /api/queues/{queue}/dlq?limit=&offset=`, `POST /api/queues/{queue}/dlq/{id}/requeue` - (404 if not in DLQ), `GET /api/queues`. Constructed via `api.New(b, logger) http.Handler`. + (404 if not in DLQ), `GET /api/queues`, `GET /api/stream` (SSE; pushes per-queue depth + + `processed`/`dead` counters to every connected dashboard every ~1 s; implemented in + `internal/api/stream.go`). Constructed via `api.New(b, logger) http.Handler`. - `cmd/worker`, `cmd/demo` — thin runnable entrypoints (worker pool + reaper + promoter; load generator with `--delay`). `cmd/worker` accepts `--metrics-addr` (default "" = off); when set, serves `/metrics` and registers the depth collector with graceful shutdown. - `cmd/server` — wires Redis + broker (with a `metrics.Recorder` so API enqueues are counted) + - the API handler + `/metrics` + `/healthz`; graceful shutdown on SIGINT/SIGTERM. Flags: `-addr`, - `-redis`, `-queues` (comma-separated queues for the depth collector). -- `.github/workflows/ci.yml` — Redis service + `go test -race` + `golangci-lint`. - -Dashboard (3b), producer SDK (3c), and packaging/deploy (3d) are **not** built yet. + the API handler + `/metrics` + `/healthz` + embedded dashboard at `/`; graceful shutdown on + SIGINT/SIGTERM. Flags: `-addr`, `-redis`, `-queues` (comma-separated queues for the depth + collector). +- `web/` — Vite+React+TypeScript dark-editorial dashboard. Source under `web/src/`; production + build committed to `web/dist/` (embedded via `web/embed.go` using `go:embed`, served at `/` by + `cmd/server` with SPA index.html fallback). Includes vitest unit tests for pure logic + (format helpers, series builders) and a snapshot test. `web/` has its own `package.json`; the + Go module gains no dependency. +- `.github/workflows/ci.yml` — Redis service + `go test -race` + `golangci-lint` + dashboard + build/typecheck/test/dist-sync check. + +Producer SDK (3c) and packaging/deploy (3d) are **not** built yet. ## Source of truth @@ -83,6 +94,9 @@ spec disagree, the spec wins until the spec is deliberately updated. - **Rate-limit config is per-worker, not stored in Redis.** All workers on a queue must register the same `WithRateLimit` (they share one Redis bucket and pass rate/burst on every claim); mismatched configs refill inconsistently. A rate-limited claim is indistinguishable from an empty queue to the worker (it polls again). - **Metrics are per-process and opt-in.** `broker.WithMetrics` installs a Prometheus recorder (default is a no-op); `cmd/worker --metrics-addr` serves `/metrics`. Counters/latency are per worker process — aggregate across workers in Prometheus. Queue-depth gauges read shared Redis at scrape time (one round-trip per queue/state), so every worker reports the same depths (aggregate with max/avg, not sum). Label cardinality is per queue. `cmd/server` also exposes `/metrics`; depth gauges there cover only the queues listed in `-queues` at startup. - **HTTP API is demo-grade, no auth.** The API server has no authentication or authorization layer. Payloads are treated as UTF-8 strings (base64 encoding for binary payloads is a future addition). DLQ paging is offset/limit (no cursor). `Queues` discovery uses Redis SCAN (eventually-consistent) and sorts results in Go. +- **Dashboard charts are in-memory rolling windows.** The client-side time-series buffer resets on page reload; there is no server-side history. `processed`/`dead` counters are monotonic Redis INCRs (no reset); the dashboard derives a rate by differencing successive SSE snapshots. +- **SSE is per-connection.** Each open dashboard tab runs its own server-side ticker goroutine reading Redis every ~1 s. This is fine for a demo; a production deployment would fan-out from a single poller. +- **Committed `web/dist` must be rebuilt on UI change.** The Go binary embeds the committed dist; CI has a `git diff --exit-code -- dist` step to catch stale builds. Run `cd web && npm run build` and commit the updated dist whenever source changes. ## Redis data model & job lifecycle (the architecture in brief) @@ -99,6 +113,8 @@ and the whole engine follows: | `q:{name}:delayed` | ZSET | ready-at ts | scheduled + backoff jobs; **promoter scans this** and moves due ones (`ready-at ≤ now`) to `ready` | | `q:{name}:dedup:{key}` | string | — | per-key string with TTL; **enqueue dedup** — a keyed duplicate is dropped with ErrDuplicate | | `q:{name}:ratelimit` | hash | — | per-queue token bucket (`tokens`, `ts`); claim consumes a token only on a successful pop | +| `q:{name}:processed` | string | — | cumulative INCR on every `ack`; read by `Counters` + SSE stream to back dashboard throughput display | +| `q:{name}:dead` | string | — | cumulative INCR when a job is dead-lettered (in `nack.lua`); read by `Counters` + SSE stream | States in use today: `pending` (constructed, not enqueued), `ready`, `inflight`, `delayed` (scheduled or waiting out a backoff), `dead`. @@ -119,7 +135,9 @@ enqueue(WithDelay) → delayed ──[promoter: ready-at≤now]──→ ready Two background loops (reaper, promoter) plus the worker claim loop move jobs between states automatically; the only operator-driven transition is `RequeueDLQ` (dlq→ready, exposed via the API). Heartbeat (`broker.Extend`, `ZADD XX`) pushes a job's `inflight` deadline forward while a -long handler runs, so the reaper does not reclaim live work. +long handler runs, so the reaper does not reclaim live work. The `q:{name}:processed` and +`q:{name}:dead` counters are **observational** (monotonic INCRs inside `ack.lua`/`nack.lua`) — +they do not introduce any new job-state transition. ## Layout (✅ built · ◻ planned) @@ -134,9 +152,9 @@ internal/worker/ # ✅ Worker + Reaper + Promoter runtime internal/metrics/ # ✅ Prometheus Recorder + DepthCollector internal/api/ # ✅ JSON REST API handler (Phase 3a) internal/client/ # ◻ producer SDK (Phase 3c) -web/ # ◻ embedded dashboard assets (Phase 3b) +web/ # ✅ Vite+React dashboard + web/embed.go (Phase 3b) deployments/docker-compose.yml # ◻ redis + server + N workers + demo (Phase 3d) -.github/workflows/ci.yml # ✅ Redis service + go test -race + golangci-lint +.github/workflows/ci.yml # ✅ Redis service + go test -race + golangci-lint + dashboard CI ``` Use `internal/` for everything not meant as a public import surface. `cmd/` holds only thin @@ -146,7 +164,7 @@ Use `internal/` for everything not meant as a public import surface. `cmd/` hold 1. **Phase 1 — core: ✅ done.** job model; enqueue/claim/ack/nack Lua; reaper; worker runtime; basic DLQ; integration tests; CI. A working, testable queue ships first. 2. **Phase 2 — depth: ✅ done.** delayed jobs + promoter ✅; backoff + jitter ✅; priority ✅; idempotency ✅; rate limiting ✅; Prometheus metrics ✅. -3. **Phase 3 — polish (in progress):** 3a HTTP API + server ✅ done; 3b dashboard (web/); 3c producer SDK (`internal/client`); 3d docker-compose + deploy + README diagram. +3. **Phase 3 — polish (in progress):** 3a HTTP API + server ✅; 3b dashboard ✅; 3c producer SDK (`internal/client`); 3d docker-compose + deploy + README diagram. 4. **Future work (NOT now):** Postgres-backed (`SKIP LOCKED`) mode; exactly-once via consumer outbox. ## Conventions @@ -170,11 +188,16 @@ Use `internal/` for everything not meant as a public import surface. `cmd/` hold - `github.com/redis/go-redis/v9` — a Redis *driver*, not a queue library. - `github.com/prometheus/client_golang` — a metrics instrumentation library, not a queue library. Neither violates the "build the queue from scratch on Redis" rule. The queue logic is ours. +- **Frontend (`web/`)** builds with Node/Vite under its own `package.json`; `npm run typecheck`, + `npm run test` (vitest), and `npm run build` are all independent of the Go module. The Go binary + embeds the committed `web/dist` at compile time via `go:embed` (`web/embed.go`), so `go build + ./...` needs no Node toolchain — only a stale dist would be an issue (CI catches it). - **Tests need a real Redis** at `localhost:6379` (override with `REDIS_ADDR`). Each Redis-using package claims its own logical DB so `go test ./...` runs them in parallel without flushing each other (broker → **DB 15**, worker → **DB 14**, metrics → **DB 13**, api → **DB 12**; a new one picks another), with `FlushDB` per test, and they **skip** (not fail) when Redis is unreachable - — so a green local run with no Redis means those suites were skipped. CI provides a Redis service. + — so a green local run with no Redis means those suites were skipped. CI provides a Redis + service. Frontend tests (`npm run test`) need no Redis. ```sh go build ./... @@ -184,7 +207,10 @@ golangci-lint run # CI pins v2.12.2; default linters, # run it end to end against a local Redis: go run ./cmd/worker -queue demo -concurrency 4 & # worker pool + reaper go run ./cmd/demo -queue demo -count 100 # enqueue load -go run ./cmd/server -queues demo # API :8080 + /metrics + /healthz +go run ./cmd/server -queues demo # API + dashboard at http://localhost:8080 + +# frontend dev/test (requires Node 20+): +cd web && npm ci && npm run typecheck && npm run test && npm run build ``` Keep this section updated as the Makefile / docker-compose take shape. diff --git a/cmd/server/main.go b/cmd/server/main.go index 2ab5ab5..b7786b1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,6 +20,7 @@ import ( "github.com/StrangeNoob/relay/internal/api" "github.com/StrangeNoob/relay/internal/broker" "github.com/StrangeNoob/relay/internal/metrics" + "github.com/StrangeNoob/relay/web" ) func main() { @@ -56,6 +57,9 @@ func main() { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) + // Serve the embedded dashboard at / (SPA fallback). Registered last and at the + // root, so the more specific /api/, /metrics, /healthz patterns take priority. + mux.Handle("/", web.Handler()) srv := &http.Server{Addr: *addr, Handler: mux} go func() { diff --git a/docs/superpowers/plans/2026-06-08-relay-phase3b-dashboard.md b/docs/superpowers/plans/2026-06-08-relay-phase3b-dashboard.md new file mode 100644 index 0000000..a8fa903 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-relay-phase3b-dashboard.md @@ -0,0 +1,1317 @@ +# Phase 3b Live Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a live, embedded dark-editorial dashboard for Relay: per-queue depth + throughput over SSE, a dead-letter table with one-click requeue, and an enqueue form — a Vite+React+TS app served from `cmd/server` via `go:embed`. + +**Architecture:** Backend adds cluster-wide `processed`/`dead` Redis counters (additive `INCR` in `ack.lua`/`nack.lua`), a `broker.Counters` reader, and an SSE endpoint (`GET /api/stream`) that pushes per-queue depth + counters every ~1s. The frontend (`web/`, Vite+React+TS) listens via `EventSource`, derives throughput client-side, draws hand-rolled SVG sparklines, and calls the existing 3a REST endpoints for DLQ/requeue/enqueue. The built bundle (`web/dist`) is committed and embedded by a `web` package the server serves at `/`. + +**Tech Stack:** Go (stdlib `net/http`, `go:embed`), `redis/go-redis/v9`, real-Redis integration tests; Vite + React 18 + TypeScript + Vitest; no chart/router/state libraries. + +**Spec:** [`docs/superpowers/specs/2026-06-08-relay-phase3b-dashboard-design.md`](../specs/2026-06-08-relay-phase3b-dashboard-design.md) + +**Execution note:** Frontend tasks need Node 20+ and network access for `npm`. If `npm` or the registry is unavailable in the sandbox, report BLOCKED rather than faking a build. + +--- + +## File Structure + +- **Modify `internal/broker/scripts/ack.lua`** — `INCR` the processed counter (key passed from Go). +- **Modify `internal/broker/scripts/nack.lua`** — `INCR` the dead counter on the dead branch (key passed from Go). +- **Modify `internal/broker/broker.go`** — `processedKey`/`deadKey` helpers; pass the counter keys into `ack`/`nack`; add `Counters` struct + method. +- **Modify `internal/broker/broker_test.go`** — counter increment + `Counters` tests. +- **Create `internal/api/stream.go`** — SSE handler + snapshot type (keep `api.go` focused). +- **Modify `internal/api/api.go`** — register `GET /api/stream`. +- **Modify `internal/api/api_test.go`** — SSE test. +- **Create `web/`** — Vite+React+TS app: `package.json`, `vite.config.ts`, `tsconfig.json`, `index.html`, `src/` (`main.tsx`, `App.tsx`, `theme.css`, `api.ts`, `hooks/useStream.ts`, `lib/format.ts`, `lib/series.ts`, `components/*`), and the committed build output `web/dist/`. +- **Create `web/embed.go`** — `package web`, `//go:embed all:dist`, `Handler()` with SPA fallback. +- **Create `web/handler_test.go`** — serves index.html for `/` and client routes. +- **Modify `cmd/server/main.go`** — serve `web.Handler()` at `/`. +- **Modify `.github/workflows/ci.yml`** — add a frontend job. +- **Modify `CLAUDE.md`** — document 3b. + +--- + +## Task 1: `ack.lua` processed counter + +**Files:** `internal/broker/scripts/ack.lua`, `internal/broker/broker.go`, `internal/broker/broker_test.go` + +- [ ] **Step 1: Write the failing test** — append to `internal/broker/broker_test.go`: + +```go +func TestAckIncrementsProcessedCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + if err := b.Enqueue(ctx, job.New("emails", []byte("x"))); err != nil { + t.Fatalf("Enqueue: %v", err) + } + j, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: ok=%v err=%v", ok, err) + } + if err := b.Ack(ctx, j); err != nil { + t.Fatalf("Ack: %v", err) + } + if n, _ := rdb.Get(ctx, "q:emails:processed").Int64(); n != 1 { + t.Errorf("q:emails:processed = %d, want 1", n) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/broker/ -run TestAckIncrementsProcessedCounter -v` +Expected: FAIL — `q:emails:processed = 0, want 1`. (Needs Redis; note if it SKIPs.) + +- [ ] **Step 3: Edit `ack.lua`** — add the processed key as `KEYS[2]` and increment it: + +```lua +-- ack.lua — acknowledge that a job was processed successfully. +-- +-- KEYS[1] = inflight set q:{name}:inflight +-- KEYS[2] = processed counter q:{name}:processed (cluster-wide throughput counter) +-- ARGV[1] = job id +-- ARGV[2] = job hash key prefix ("job:") + +local id = ARGV[1] +redis.call('ZREM', KEYS[1], id) +redis.call('DEL', ARGV[2] .. id) +redis.call('INCR', KEYS[2]) +return 1 +``` + +- [ ] **Step 4: Update `Ack` in `broker.go`** — add the key helper and pass it. Add near the other key helpers: + +```go +// processedKey is the Redis key for a queue's cumulative processed counter: +// `q:{name}:processed`, INCR'd by ack.lua. Read by the dashboard for throughput. +func processedKey(queue string) string { return "q:" + queue + ":processed" } +``` + +Change the `ackScript.Run` KEYS slice to include it: + +```go + if err := ackScript.Run(ctx, b.rdb, + []string{inflightKey(j.Queue), processedKey(j.Queue)}, + j.ID, jobKeyPrefix, + ).Err(); err != nil { + return fmt.Errorf("broker: acking job %s: %w", j.ID, err) + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/broker/ -run 'TestAck' -v` +Expected: PASS (the new test plus the existing `TestAckRecordsProcessedAndLatency` — the increment is additive). Then `gofmt -l internal/broker/`, `go build ./...`, `go vet ./internal/broker/` clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/broker/scripts/ack.lua internal/broker/broker.go internal/broker/broker_test.go +git commit -m "Increment a cluster-wide processed counter on ack" +``` + +--- + +## Task 2: `nack.lua` dead counter + +**Files:** `internal/broker/scripts/nack.lua`, `internal/broker/broker.go`, `internal/broker/broker_test.go` + +- [ ] **Step 1: Write the failing test** — append to `internal/broker/broker_test.go` (reuses the `deadLetter`/`nackTestJob` helpers added in Phase 3a / Phase 2): + +```go +func TestNackDeadIncrementsDeadCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + _ = deadLetter(t, b, ctx, "emails", "x") // enqueue maxRetries=0 -> claim -> nack -> dead + if n, _ := rdb.Get(ctx, "q:emails:dead").Int64(); n != 1 { + t.Errorf("q:emails:dead = %d, want 1", n) + } +} + +func TestNackRetryDoesNotIncrementDeadCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + j := job.New("emails", []byte("x")) // default MaxRetries=5 -> first nack retries + if err := b.Enqueue(ctx, j); err != nil { + t.Fatalf("Enqueue: %v", err) + } + claimed, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: ok=%v err=%v", ok, err) + } + if err := b.Nack(ctx, claimed); err != nil { + t.Fatalf("Nack: %v", err) + } + if n, _ := rdb.Get(ctx, "q:emails:dead").Int64(); n != 0 { + t.Errorf("q:emails:dead = %d, want 0 (retry must not increment)", n) + } +} +``` + +(If `deadLetter` is not present in this file, define it as in the Phase 3a plan: enqueue a job with `MaxRetries = 0`, claim it, `Nack` it, return the id.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/broker/ -run 'TestNackDead|TestNackRetryDoesNot' -v` +Expected: FAIL — `q:emails:dead = 0, want 1` for the dead case. + +- [ ] **Step 3: Edit `nack.lua`** — add the dead-counter key as `KEYS[4]`, INCR only on the dead branch: + +```lua +-- nack.lua — handle a failed delivery. +-- +-- KEYS[1] = inflight set q:{name}:inflight +-- KEYS[2] = delayed set q:{name}:delayed +-- KEYS[3] = dead-letter q:{name}:dlq +-- KEYS[4] = dead counter q:{name}:dead (cluster-wide; INCR only when dead-lettered) +-- ARGV[1] = job id +-- ARGV[2] = job hash key prefix ("job:") +-- ARGV[3] = retry ready-at in unix milliseconds (precomputed backoff) +-- +-- Returns 'retry' or 'dead'. + +local id = ARGV[1] +local job_key = ARGV[2] .. id +local ready_at = tonumber(ARGV[3]) + +redis.call('ZREM', KEYS[1], id) + +local attempts = tonumber(redis.call('HGET', job_key, 'attempts')) or 0 +local max_retries = tonumber(redis.call('HGET', job_key, 'max_retries')) or 0 + +if attempts < max_retries then + redis.call('HSET', job_key, 'state', 'delayed') + redis.call('ZADD', KEYS[2], ready_at, id) + return 'retry' +end + +redis.call('HSET', job_key, 'state', 'dead') +redis.call('RPUSH', KEYS[3], id) +redis.call('INCR', KEYS[4]) +return 'dead' +``` + +- [ ] **Step 4: Update `Nack` in `broker.go`** — add the `deadKey` helper and pass it: + +```go +// deadKey is the Redis key for a queue's cumulative dead-letter counter: +// `q:{name}:dead`, INCR'd by nack.lua on the dead branch. +func deadKey(queue string) string { return "q:" + queue + ":dead" } +``` + +Change the `nackScript.Run` KEYS slice: + +```go + outcome, err := nackScript.Run(ctx, b.rdb, + []string{inflightKey(j.Queue), delayedKey(j.Queue), dlqKey(j.Queue), deadKey(j.Queue)}, + j.ID, jobKeyPrefix, readyAt, + ).Text() +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/broker/ -run 'TestNack' -v` +Expected: PASS (new tests + existing nack tests). Then `gofmt -l internal/broker/`, `go build ./...`, `go vet ./internal/broker/` clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/broker/scripts/nack.lua internal/broker/broker.go internal/broker/broker_test.go +git commit -m "Increment a cluster-wide dead counter when a job is dead-lettered" +``` + +--- + +## Task 3: `broker.Counters` + +**Files:** `internal/broker/broker.go`, `internal/broker/broker_test.go` + +- [ ] **Step 1: Write the failing test** — append to `internal/broker/broker_test.go`: + +```go +func TestCountersReadsProcessedAndDead(t *testing.T) { + b, _ := newTestBroker(t) + ctx := context.Background() + + // process one (ack) and dead-letter one + if err := b.Enqueue(ctx, job.New("emails", []byte("ok"))); err != nil { + t.Fatalf("Enqueue: %v", err) + } + j, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: %v %v", ok, err) + } + if err := b.Ack(ctx, j); err != nil { + t.Fatalf("Ack: %v", err) + } + _ = deadLetter(t, b, ctx, "emails", "bad") + + c, err := b.Counters(ctx, "emails") + if err != nil { + t.Fatalf("Counters: %v", err) + } + if c.Processed != 1 || c.Dead != 1 { + t.Errorf("Counters = %+v, want {Processed:1 Dead:1}", c) + } +} + +func TestCountersUntouchedQueueIsZero(t *testing.T) { + b, _ := newTestBroker(t) + c, err := b.Counters(context.Background(), "emails") + if err != nil { + t.Fatalf("Counters: %v", err) + } + if c.Processed != 0 || c.Dead != 0 { + t.Errorf("Counters = %+v, want zeros", c) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/broker/ -run TestCounters -v` +Expected: FAIL — `b.Counters` undefined. + +- [ ] **Step 3: Implement `Counters` in `broker.go`** (`errors` and `redis` are already imported): + +```go +// Counters is a queue's cumulative, monotonic lifetime totals — distinct from the +// point-in-time depths in Stats. They back the dashboard's throughput rate. +type Counters struct { + Processed int64 `json:"processed_total"` + Dead int64 `json:"dead_total"` +} + +// Counters reads a queue's processed/dead counters in one pipeline. A missing +// key (queue never acked/dead-lettered) reads as 0, not an error. +func (b *Broker) Counters(ctx context.Context, queue string) (Counters, error) { + pipe := b.rdb.Pipeline() + pCmd := pipe.Get(ctx, processedKey(queue)) + dCmd := pipe.Get(ctx, deadKey(queue)) + // A GET on a missing key yields redis.Nil, which Exec surfaces as an error; + // that is expected here, so only a non-Nil error is a real failure. + if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + processed, err := getInt64OrZero(pCmd) + if err != nil { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + dead, err := getInt64OrZero(dCmd) + if err != nil { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + return Counters{Processed: processed, Dead: dead}, nil +} + +// getInt64OrZero reads a GET result as int64, treating a missing key as 0. +func getInt64OrZero(cmd *redis.StringCmd) (int64, error) { + v, err := cmd.Int64() + if errors.Is(err, redis.Nil) { + return 0, nil + } + return v, err +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/broker/ -run TestCounters -v` → PASS (2). Then full suite under race: `go test -race ./internal/broker/`. `gofmt -l internal/broker/`, `go build ./...`, `go vet ./internal/broker/` clean. + +- [ ] **Step 5: Commit** + +```bash +git add internal/broker/broker.go internal/broker/broker_test.go +git commit -m "Add broker Counters: cumulative processed/dead per queue" +``` + +--- + +## Task 4: SSE stream endpoint + +**Files:** `internal/api/stream.go` (new), `internal/api/api.go`, `internal/api/api_test.go` + +- [ ] **Step 1: Write the failing test** — append to `internal/api/api_test.go`. Add imports `"bufio"` and `"strings"` to the test file if missing: + +```go +func TestStreamEmitsSnapshot(t *testing.T) { + h, b, _ := newTestAPI(t) + if err := b.Enqueue(context.Background(), mustJob("emails", "x")); err != nil { + t.Fatalf("Enqueue: %v", err) + } + + srv := httptest.NewServer(h) + defer srv.Close() + + reqCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, srv.URL+"/api/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } + + // Read until the first "data: " line (the immediate initial snapshot). + reader := bufio.NewReader(resp.Body) + var payload string + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("reading stream: %v", err) + } + if strings.HasPrefix(line, "data: ") { + payload = strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + break + } + } + cancel() // stop the stream server-side + + var snaps []map[string]any + if err := json.Unmarshal([]byte(payload), &snaps); err != nil { + t.Fatalf("decode snapshot %q: %v", payload, err) + } + if len(snaps) != 1 || snaps[0]["queue"] != "emails" { + t.Fatalf("snaps = %v, want one for emails", snaps) + } + if snaps[0]["ready"].(float64) != 1 { + t.Errorf("ready = %v, want 1", snaps[0]["ready"]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestStreamEmitsSnapshot -v` +Expected: FAIL — `/api/stream` route not registered (the request 404s, so Content-Type assertion fails). (Needs Redis; note if SKIP.) + +- [ ] **Step 3: Create `internal/api/stream.go`**: + +```go +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// streamInterval is how often the SSE stream pushes a fresh snapshot. +const streamInterval = time.Second + +// queueSnapshot is one queue's line in an SSE snapshot: point-in-time depths plus +// the cumulative counters the client rate-computes into throughput. +type queueSnapshot struct { + Queue string `json:"queue"` + Ready int64 `json:"ready"` + Inflight int64 `json:"inflight"` + Delayed int64 `json:"delayed"` + DLQ int64 `json:"dlq"` + ProcessedTotal int64 `json:"processed_total"` + DeadTotal int64 `json:"dead_total"` +} + +// stream handles GET /api/stream: a text/event-stream that pushes a snapshot of +// every queue immediately and then once per streamInterval until the client +// disconnects. A Redis hiccup skips a tick rather than tearing down the stream. +func (a *API) stream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + a.writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ctx := r.Context() + // Immediate first snapshot so the UI populates without waiting a tick. + if !a.writeSnapshot(ctx, w, flusher) { + return + } + ticker := time.NewTicker(streamInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !a.writeSnapshot(ctx, w, flusher) { + return + } + } + } +} + +// writeSnapshot composes and writes one SSE event. It returns false when the +// client connection is gone (write failed), signalling the caller to stop. +func (a *API) writeSnapshot(ctx context.Context, w http.ResponseWriter, flusher http.Flusher) bool { + queues, err := a.broker.Queues(ctx) + if err != nil { + a.logger.Error("api: stream listing queues", "err", err) + return true // skip this tick, keep the stream open + } + snaps := make([]queueSnapshot, 0, len(queues)) + for _, q := range queues { + st, err := a.broker.Stats(ctx, q) + if err != nil { + a.logger.Error("api: stream stats", "queue", q, "err", err) + continue + } + ct, err := a.broker.Counters(ctx, q) + if err != nil { + a.logger.Error("api: stream counters", "queue", q, "err", err) + continue + } + snaps = append(snaps, queueSnapshot{ + Queue: q, Ready: st.Ready, Inflight: st.Inflight, Delayed: st.Delayed, + DLQ: st.DLQ, ProcessedTotal: ct.Processed, DeadTotal: ct.Dead, + }) + } + buf, err := json.Marshal(snaps) + if err != nil { + a.logger.Error("api: stream marshal", "err", err) + return true + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", buf); err != nil { + return false // client disconnected + } + flusher.Flush() + return true +} +``` + +- [ ] **Step 4: Register the route in `api.go`** — add to the `New` mux: + +```go + mux.HandleFunc("GET /api/stream", a.stream) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/api/ -run TestStreamEmitsSnapshot -v` → PASS. Then `go test -race ./internal/api/`, `gofmt -l internal/api/`, `go build ./...`, `go vet ./internal/api/` clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/stream.go internal/api/api.go internal/api/api_test.go +git commit -m "Add SSE /api/stream pushing per-queue depth and counters" +``` + +--- + +## Task 5: Scaffold the Vite + React + TS app + +**Files:** `web/package.json`, `web/vite.config.ts`, `web/tsconfig.json`, `web/tsconfig.node.json`, `web/index.html`, `web/src/main.tsx`, `web/src/vite-env.d.ts`, plus the committed build `web/dist/`. + +> Requires Node 20+ and npm registry access. If unavailable, report BLOCKED. + +- [ ] **Step 1: Create `web/package.json`**: + +```json +{ + "name": "relay-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/fraunces": "^5.0.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "@fontsource/ibm-plex-sans": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Create `web/vite.config.ts`**: + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// base: "./" keeps asset URLs relative so the bundle works under go:embed. +// outDir: "dist" is committed and embedded by web/embed.go. +export default defineConfig({ + plugins: [react()], + base: "./", + build: { outDir: "dist", emptyOutDir: true }, +}); +``` + +- [ ] **Step 3: Create `web/tsconfig.json`** and `web/tsconfig.node.json`: + +`web/tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +`web/tsconfig.node.json`: +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Step 4: Create `web/index.html`**: + +```html + + + + + + Relay + + +
+ + + +``` + +- [ ] **Step 5: Create `web/src/vite-env.d.ts`** and a minimal `web/src/main.tsx`: + +`web/src/vite-env.d.ts`: +```ts +/// +``` + +`web/src/main.tsx` (minimal placeholder; the real App lands in Task 8): +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +createRoot(document.getElementById("root")!).render( + +
Relay dashboard
+
, +); +``` + +- [ ] **Step 6: Install, build, verify** + +Run: +```bash +cd web +npm install +npm run typecheck +npm run build +ls dist/index.html +``` +Expected: `npm install` writes `web/package-lock.json`; typecheck clean; `vite build` writes `web/dist/` containing `index.html` and `assets/`. (If `npm` is unavailable, report BLOCKED.) + +- [ ] **Step 7: Commit (including the lockfile and built dist)** + +```bash +cd .. +git add web/package.json web/package-lock.json web/vite.config.ts web/tsconfig.json web/tsconfig.node.json web/index.html web/src web/dist +git commit -m "Scaffold Vite+React+TS dashboard app (builds to committed web/dist)" +``` + +(Do NOT add `web/node_modules`. Add a `web/.gitignore` containing `node_modules/` in this commit.) + +--- + +## Task 6: Frontend pure logic (`format.ts`, `series.ts`) with Vitest + +**Files:** `web/src/lib/format.ts`, `web/src/lib/series.ts`, `web/src/lib/format.test.ts`, `web/src/lib/series.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`web/src/lib/format.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { formatCount, formatAge } from "./format"; + +describe("formatCount", () => { + it("passes small numbers through", () => { + expect(formatCount(0)).toBe("0"); + expect(formatCount(942)).toBe("942"); + }); + it("abbreviates thousands and millions", () => { + expect(formatCount(1240)).toBe("1.2k"); + expect(formatCount(2_500_000)).toBe("2.5M"); + }); +}); + +describe("formatAge", () => { + it("renders seconds, minutes, hours", () => { + expect(formatAge(5_000)).toBe("5s"); + expect(formatAge(90_000)).toBe("1m"); + expect(formatAge(3_660_000)).toBe("1h"); + }); +}); +``` + +`web/src/lib/series.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { ratePerSecond, pushSample } from "./series"; + +describe("ratePerSecond", () => { + it("computes delta over elapsed seconds", () => { + const prev = { value: 100, t: 1000 }; + const cur = { value: 130, t: 4000 }; // +30 over 3s + expect(ratePerSecond(prev, cur)).toBe(10); + }); + it("returns 0 for a non-positive interval", () => { + expect(ratePerSecond({ value: 1, t: 5 }, { value: 9, t: 5 })).toBe(0); + }); + it("never returns negative (counter reset / flush)", () => { + expect(ratePerSecond({ value: 100, t: 0 }, { value: 5, t: 1000 })).toBe(0); + }); +}); + +describe("pushSample", () => { + it("appends and caps the window length", () => { + let s: number[] = []; + for (let i = 0; i < 5; i++) s = pushSample(s, i, 3); + expect(s).toEqual([2, 3, 4]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run` +Expected: FAIL — `./format` and `./series` modules not found. + +- [ ] **Step 3: Implement the modules** + +`web/src/lib/format.ts`: +```ts +// formatCount abbreviates large counts (1240 -> "1.2k", 2_500_000 -> "2.5M"). +export function formatCount(n: number): string { + if (n < 1000) return String(n); + if (n < 1_000_000) return trim(n / 1000) + "k"; + return trim(n / 1_000_000) + "M"; +} + +function trim(x: number): string { + return x.toFixed(1).replace(/\.0$/, ""); +} + +// formatAge renders an elapsed duration in ms as a coarse age ("5s", "1m", "1h"). +export function formatAge(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + return `${h}h`; +} +``` + +`web/src/lib/series.ts`: +```ts +// A timestamped cumulative-counter sample. +export interface Sample { + value: number; + t: number; // unix ms +} + +// ratePerSecond returns the per-second delta between two cumulative samples. +// Non-positive intervals and counter resets (decreases) yield 0, never negative. +export function ratePerSecond(prev: Sample, cur: Sample): number { + const dt = (cur.t - prev.t) / 1000; + if (dt <= 0) return 0; + const dv = cur.value - prev.value; + if (dv < 0) return 0; + return dv / dt; +} + +// pushSample appends v to a rolling window, keeping at most `cap` newest values. +export function pushSample(window: number[], v: number, cap: number): number[] { + const next = [...window, v]; + return next.length > cap ? next.slice(next.length - cap) : next; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npx vitest run` → all pass. `npm run typecheck` clean. + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web/src/lib +git commit -m "Add dashboard pure logic (format, series) with unit tests" +``` + +--- + +## Task 7: Data layer — `api.ts` types/calls and `useStream` hook + +**Files:** `web/src/api.ts`, `web/src/hooks/useStream.ts`, `web/src/lib/snapshot.ts`, `web/src/lib/snapshot.test.ts` + +- [ ] **Step 1: Write the failing test** — `web/src/lib/snapshot.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { indexByQueue, type QueueSnapshot } from "./snapshot"; + +const snap = (queue: string, ready: number): QueueSnapshot => ({ + queue, ready, inflight: 0, delayed: 0, dlq: 0, processed_total: 0, dead_total: 0, +}); + +describe("indexByQueue", () => { + it("maps a snapshot array by queue name", () => { + const m = indexByQueue([snap("emails", 2), snap("sms", 5)]); + expect(m.emails.ready).toBe(2); + expect(m.sms.ready).toBe(5); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run snapshot` +Expected: FAIL — `./snapshot` not found. + +- [ ] **Step 3: Implement the data layer** + +`web/src/lib/snapshot.ts`: +```ts +// QueueSnapshot is one queue's line in an /api/stream event (matches the Go +// queueSnapshot JSON). +export interface QueueSnapshot { + queue: string; + ready: number; + inflight: number; + delayed: number; + dlq: number; + processed_total: number; + dead_total: number; +} + +// indexByQueue turns a snapshot array into a name->snapshot map. +export function indexByQueue(snaps: QueueSnapshot[]): Record { + const out: Record = {}; + for (const s of snaps) out[s.queue] = s; + return out; +} +``` + +`web/src/api.ts`: +```ts +// REST helpers for the Relay API. The dashboard is served by the same origin as +// the API, so all paths are relative. + +export interface DlqJob { + id: string; + queue: string; + payload: string; + state: string; + attempts: number; + max_retries: number; + priority: number; + created_at: string; + idempotency_key?: string; +} + +export interface EnqueueRequest { + payload: string; + delay_ms?: number; + priority?: number; + idempotency_key?: string; +} + +export async function listDlq(queue: string, limit = 50, offset = 0): Promise { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/dlq?limit=${limit}&offset=${offset}`); + if (!r.ok) throw new Error(`list dlq: ${r.status}`); + return r.json(); +} + +export async function requeue(queue: string, id: string): Promise { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/dlq/${encodeURIComponent(id)}/requeue`, { + method: "POST", + }); + if (!r.ok) throw new Error(`requeue: ${r.status}`); +} + +export async function enqueue(queue: string, body: EnqueueRequest): Promise<{ id: string; state: string }> { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`enqueue: ${r.status}`); + return r.json(); +} +``` + +`web/src/hooks/useStream.ts`: +```ts +import { useEffect, useState } from "react"; +import { indexByQueue, type QueueSnapshot } from "../lib/snapshot"; + +export interface StreamState { + byQueue: Record; + queues: string[]; + connected: boolean; +} + +// useStream subscribes to /api/stream (SSE) and exposes the latest per-queue +// snapshot. It reconnects automatically (EventSource does this for us). +export function useStream(): StreamState { + const [state, setState] = useState({ byQueue: {}, queues: [], connected: false }); + + useEffect(() => { + const es = new EventSource("/api/stream"); + es.onopen = () => setState((s) => ({ ...s, connected: true })); + es.onerror = () => setState((s) => ({ ...s, connected: false })); + es.onmessage = (e) => { + const snaps = JSON.parse(e.data) as QueueSnapshot[]; + setState({ byQueue: indexByQueue(snaps), queues: snaps.map((s) => s.queue), connected: true }); + }; + return () => es.close(); + }, []); + + return state; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run snapshot` → PASS. `npm run typecheck` clean. + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web/src/api.ts web/src/hooks web/src/lib/snapshot.ts web/src/lib/snapshot.test.ts +git commit -m "Add dashboard data layer: REST client and SSE stream hook" +``` + +--- + +## Task 8: Dashboard UI (dark editorial) + build + +**Files:** `web/src/theme.css`, `web/src/App.tsx`, `web/src/main.tsx` (update), and `web/src/components/`: `Sidebar.tsx`, `StatTiles.tsx`, `Sparkline.tsx`, `Charts.tsx`, `DlqTable.tsx`, `EnqueueForm.tsx`. Rebuild `web/dist`. + +This task is a UI translation of the approved dark-editorial mockup. There is no Go-style RED/GREEN; the gate is `npm run typecheck` + `npm run build` + visual correctness against the tokens below. + +- [ ] **Step 1: Create `web/src/theme.css`** (the locked design tokens + base styles): + +```css +@import "@fontsource/fraunces/400.css"; +@import "@fontsource/fraunces/500.css"; +@import "@fontsource/fraunces/600.css"; +@import "@fontsource/ibm-plex-sans/400.css"; +@import "@fontsource/ibm-plex-sans/500.css"; +@import "@fontsource/ibm-plex-sans/600.css"; +@import "@fontsource/ibm-plex-mono/400.css"; +@import "@fontsource/ibm-plex-mono/500.css"; + +:root { + color-scheme: dark; + --bg: #15120e; + --panel: #1c1813; + --panel-2: #211c16; + --line: #2e271e; + --ink: #ece3d4; + --muted: #9a8f7c; + --faint: #6f6757; + --accent: #d2603f; + --accent-soft: rgba(210, 96, 63, 0.14); + --serif: "Fraunces", Georgia, serif; + --sans: "IBM Plex Sans", system-ui, sans-serif; + --mono: "IBM Plex Mono", monospace; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; } +.app { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; max-width: 1180px; margin: 0 auto; } +/* (Carry over the sidebar/main/tile/panel/table/button rules from the approved + mockup; match the token names above. Keep them in this single theme.css.) */ +``` + +Translate the full mockup styling (sidebar, stat tiles, chart panels, DLQ table, requeue button, enqueue form) into `theme.css` using these exact tokens. The mockup's structure: a `.app` grid (236px sidebar + main), hairline (`--line`) borders, Fraunces for the wordmark/stat numbers/section headings, mono for labels/IDs/counts, terracotta (`--accent`) for the active queue marker, the DLQ tile, and primary actions. Match colors precisely. + +- [ ] **Step 2: Implement `Sparkline.tsx`** (dependency-free SVG): + +```tsx +interface SparklineProps { + data: number[]; + stroke: string; + fill?: string; + height?: number; +} + +// Sparkline draws a normalized polyline (and optional area) from data points. +export function Sparkline({ data, stroke, fill, height = 86 }: SparklineProps) { + const w = 320; + if (data.length < 2) { + return ; + } + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const span = max - min || 1; + const stepX = w / (data.length - 1); + const pts = data.map((v, i) => { + const x = i * stepX; + const y = height - ((v - min) / span) * (height - 6) - 3; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const line = pts.join(" "); + return ( + + {fill && } + + + ); +} +``` + +- [ ] **Step 3: Implement the remaining components and `App.tsx`** + +Build these components (each one focused; props match the data layer): +- `Sidebar.tsx` — props `{ queues: string[]; byQueue: Record; selected: string; onSelect(q): void; onEnqueueClick(): void; connected: boolean }`. Renders the `Relay.` wordmark, the queue list (name + `formatCount(ready)`, active marker on `selected`), the `+ Enqueue a job` button, and the live footer. +- `StatTiles.tsx` — props `{ snap?: QueueSnapshot }`. Four tiles (Ready/In-flight/Delayed/Dead-letter); DLQ tile uses the accent style. Numbers via `formatCount`. +- `Charts.tsx` — props `{ depth: number[]; throughput: number[] }`. Two panels ("Queue depth", "Throughput") each wrapping a `Sparkline` (depth uses accent stroke + soft fill; throughput uses a muted gold stroke `#cbb48e`). +- `DlqTable.tsx` — props `{ jobs: DlqJob[]; onRequeue(id): void }`. Columns: Job ID (mono, shortened), Attempts (`{attempts}/{max_retries}`), Payload (preview), Age (`formatAge(Date.now() - Date.parse(created_at))`), and a Requeue button per row. +- `EnqueueForm.tsx` — props `{ queue: string; onClose(): void; onEnqueued(): void }`. A small modal/inline form: payload (textarea), optional priority (number), delay_ms (number), idempotency_key (text); submits via `enqueue(...)`. + +`App.tsx` wiring: +- `const stream = useStream();` derive `queues = stream.queues`. +- Local state: `selected` (default first queue), `depthWindow`/`throughputWindow` (`number[]`, capped via `pushSample`, e.g. cap 60), `prevSample` (for `ratePerSecond` on `processed_total`), `dlqJobs`, `showEnqueue`. +- On each new snapshot for `selected`: push `ready` into the depth window; compute throughput via `ratePerSecond(prevSample, {value: processed_total, t: Date.now()})` and push into the throughput window; update `prevSample`. +- Fetch the DLQ list (`listDlq(selected)`) when `selected` changes and after a requeue/enqueue, and on a slow timer (e.g. every 5s). +- Render `.app` → `` + main (``, ``, ``), plus `` when `showEnqueue`. + +Update `web/src/main.tsx` to import `./theme.css` and render ``: +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./theme.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` + +- [ ] **Step 4: Typecheck, build, rebuild dist** + +Run: +```bash +cd web +npm run typecheck +npm run build +``` +Expected: typecheck clean; `web/dist` regenerated with the real UI. Optionally `npm run dev` and eyeball it against a running `cmd/server` (Task 10) if a local Redis is up. + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web/src web/dist +git commit -m "Implement dark-editorial dashboard UI and rebuild dist" +``` + +--- + +## Task 9: Embed package (`web/embed.go`) + serving handler + +**Files:** `web/embed.go`, `web/handler_test.go` + +- [ ] **Step 1: Write the failing test** — `web/handler_test.go`: + +```go +package web_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/StrangeNoob/relay/web" +) + +func TestHandlerServesIndex(t *testing.T) { + srv := httptest.NewServer(web.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } +} + +func TestHandlerSpaFallback(t *testing.T) { + srv := httptest.NewServer(web.Handler()) + defer srv.Close() + + // A client-side route that is not a real asset must still return index.html (200). + resp, err := http.Get(srv.URL + "/queues/emails") + if err != nil { + t.Fatalf("GET /queues/emails: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 (SPA fallback)", resp.StatusCode) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./web/ -run TestHandler -v` +Expected: FAIL — package `web` has no `Handler` (and no `dist` embed yet). (`web/dist` must exist from Task 5/8; if it does not, complete those first.) + +- [ ] **Step 3: Create `web/embed.go`**: + +```go +// Package web embeds the built dashboard (web/dist) and serves it with an SPA +// fallback. The Vite build output is committed so `go build` needs no Node step. +package web + +import ( + "embed" + "io/fs" + "net/http" + "path" +) + +//go:embed all:dist +var dist embed.FS + +// assets returns the embedded files rooted at dist/. +func assets() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + panic("web: embed dist subtree: " + err.Error()) + } + return sub +} + +// Handler serves the dashboard. Real asset paths are served directly; any other +// path falls back to index.html so client-side routing works (single-page app). +func Handler() http.Handler { + root := assets() + fileServer := http.FileServerFS(root) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clean := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if clean == "." || clean == "" { + clean = "index.html" + } + if _, err := fs.Stat(root, clean); err != nil { + // Not a real asset — serve the SPA shell. + r = r.Clone(r.Context()) + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) +} +``` + +Add `"strings"` to the import block (used by `Handler`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./web/ -run TestHandler -v` → PASS (2). `gofmt -l web/`, `go build ./...`, `go vet ./web/` clean. + +- [ ] **Step 5: Commit** + +```bash +git add web/embed.go web/handler_test.go +git commit -m "Embed and serve the dashboard SPA with index.html fallback" +``` + +--- + +## Task 10: Serve the dashboard from `cmd/server` + +**Files:** `cmd/server/main.go` + +- [ ] **Step 1: Wire the SPA route** — add the import and the `/` handler. Add to imports: + +```go + "github.com/StrangeNoob/relay/web" +``` + +After the `/healthz` registration, add: + +```go + // Serve the embedded dashboard at / (SPA fallback). Registered last and at the + // root, so the more specific /api/, /metrics, /healthz patterns take priority. + mux.Handle("/", web.Handler()) +``` + +- [ ] **Step 2: Build, vet, format** + +Run: +```bash +go build ./... +go vet ./... +gofmt -l cmd/ internal/ web/ +``` +Expected: all clean. + +- [ ] **Step 3: Smoke check (optional, needs local Redis)** + +Run, then Ctrl-C: +```bash +go run ./cmd/server -addr :8080 & +sleep 1 +curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/ # 200 (index.html) +curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/queues/emails # 200 (SPA fallback) +curl -s localhost:8080/healthz; echo # ok +curl -s -N localhost:8080/api/stream & sleep 2; kill %2 # streams "data: [...]" +kill %1 +``` +Expected: `200`, `200`, `ok`, and at least one `data:` SSE line. Skip if no Redis. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/server/main.go +git commit -m "Serve the embedded dashboard from cmd/server at /" +``` + +--- + +## Task 11: CI frontend job, CLAUDE.md, final verification + +**Files:** `.github/workflows/ci.yml`, `CLAUDE.md` + +- [ ] **Step 1: Add a frontend CI job** — append to `.github/workflows/ci.yml` under `jobs:`: + +```yaml + web: + name: dashboard (build & test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + # Fail if the committed web/dist is stale vs a fresh build of the source. + - name: Verify committed dist is in sync + run: git diff --exit-code -- dist +``` + +- [ ] **Step 2: Update `CLAUDE.md`** + +Make these edits (match the file's wording): +1. **Status line** — note 3b (dashboard) is done: Phase 3 in progress — 3a HTTP API ✅, 3b dashboard ✅; 3c SDK, 3d packaging remain. +2. **"What exists today" list** — add: `web/` (Vite+React+TS dark-editorial dashboard, embedded via `web/embed.go`, served at `/` by `cmd/server`); the SSE endpoint `GET /api/stream`; the new broker `Counters` method; and note `ack.lua`/`nack.lua` now `INCR` the `processed`/`dead` counters. +3. **Redis data model table** — add rows `q:{name}:processed` (string counter, INCR on ack) and `q:{name}:dead` (string counter, INCR on dead-letter); note they back dashboard throughput. +4. **Layout (✅/◻)** — mark `web/` ✅ and add `web/embed.go`; leave `internal/client`, `deployments/` as ◻. +5. **Build order** — Phase 3: 3a ✅, 3b ✅; 3c SDK, 3d packaging remain. +6. **Known limitations** — add: dashboard charts are in-memory (reset on reload); `processed`/`dead` counters are monotonic (no reset); SSE is per-connection; committed `web/dist` must be rebuilt on UI change (CI verifies). +7. **Build & dependencies** — note the `web/` workspace builds with Node/Vite but the Go module gains no dependency; `go build ./...` uses the committed `web/dist`. +8. **Run commands** — add `go run ./cmd/server` then open `http://localhost:8080`. + +- [ ] **Step 3: Full verification** + +Run: +```bash +go build ./... +go test -race ./... +go vet ./... +gofmt -l internal/ cmd/ web/ +( cd web && npm run typecheck && npm run test && npm run build && git diff --exit-code -- dist ) +``` +Expected: Go build/tests/vet/fmt clean (broker DB 15, worker DB 14, metrics DB 13, api DB 12, web no-Redis — all pass); frontend typecheck/test/build clean and dist in sync. Tests need Redis at localhost:6379. + +If anything fails, STOP and report. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml CLAUDE.md +git commit -m "Document Phase 3b and add the dashboard CI job" +``` + +--- + +## Self-Review (completed during planning) + +- **Spec coverage:** processed counter (Task 1), dead counter (Task 2), `Counters` (Task 3), SSE `/api/stream` (Task 4), Vite+React scaffold + committed dist (Task 5), pure logic + tests (Task 6), REST client + SSE hook + snapshot reducer (Task 7), dark-editorial UI + sparklines (Task 8), `web/embed.go` + SPA fallback (Task 9), `cmd/server` serving (Task 10), CI frontend job + CLAUDE.md (Task 11). Maps to every spec section (frontend stack, aesthetic, SSE, throughput-via-Redis-counters, embed, testing, CI, data model, known limitations). +- **Type consistency:** Go `Counters{Processed,Dead int64}` (json `processed_total`/`dead_total`) matches the SSE `queueSnapshot` fields and the TS `QueueSnapshot` interface. `processedKey`/`deadKey` match the Lua `KEYS` they are passed into (ack KEYS[2]; nack KEYS[4]). `web.Handler()` matches `cmd/server` and `web/handler_test.go`. Test DBs unchanged (broker 15, worker 14, metrics 13, api 12; web needs no Redis). +- **No placeholders:** Go and pure-logic steps carry complete code. Task 8 (UI) is an explicit translation of the locked tokens/mockup with full component contracts, `Sparkline`/`theme.css`/`main.tsx` code given and the remaining small components specified by props + behavior — appropriate for a mockup-driven UI build. +- **Known soft spots:** frontend tasks require Node + npm registry (flagged BLOCKED-if-unavailable). `http.FileServerFS`/`http.FileServerFS` and `fs.Sub` require Go 1.22+ (the module is on 1.24/1.25). The dist-in-sync CI check assumes a deterministic Vite build; if hashing differs across environments, relax the check to building (not diffing) and note it. diff --git a/docs/superpowers/specs/2026-06-08-relay-phase3b-dashboard-design.md b/docs/superpowers/specs/2026-06-08-relay-phase3b-dashboard-design.md new file mode 100644 index 0000000..93c2180 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-relay-phase3b-dashboard-design.md @@ -0,0 +1,205 @@ +# Relay — Phase 3b: Live Dashboard + +**Status:** Approved design · **Date:** 2026-06-08 +**Parent spec:** [`2026-06-07-relay-distributed-task-queue-design.md`](2026-06-07-relay-distributed-task-queue-design.md) +**Depends on:** [`2026-06-08-relay-phase3a-http-api-design.md`](2026-06-08-relay-phase3a-http-api-design.md) +**Phase:** 3 (polish) — second sub-project (3a HTTP API ✅; 3b this; 3c producer SDK; 3d packaging/deploy/README). + +## Purpose + +Give Relay a live, visual control surface: a single-page dashboard that shows each queue's depth +(ready/inflight/delayed/dlq), throughput over time, and the dead-letter queue with one-click +requeue, plus a small enqueue form for driving the demo. It consumes the Phase 3a HTTP API and a +new server-sent-events stream, and ships embedded in `cmd/server` so the whole thing is one Go +binary. + +## Scope + +In scope: + +- A Vite + React + TypeScript app in `web/`, built to `web/dist` (committed) and embedded into + `cmd/server` via `go:embed`, served at `/`. +- A dark-editorial visual design (fonts/colors/layout locked below). +- An SSE endpoint (`GET /api/stream`) that pushes per-queue depth + cumulative counters every ~1s. +- Cluster-wide throughput: additive `INCR` counters in `ack.lua` (processed) and `nack.lua` (dead), + read by the server and streamed; the client derives the rate. +- DLQ inspection + requeue and an enqueue form, both over the existing 3a REST endpoints. + +Out of scope: authentication; historical persistence (charts are in-memory rolling windows that +reset on reload); per-job drill-down beyond the DLQ payload preview; Grafana/Prometheus dashboards +(that is the 3d stack); a counter-reset endpoint (the new Redis counters are monotonic); the +producer SDK (3c) and packaging/deploy (3d). + +## Key decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Frontend stack | **Vite + React + TypeScript**, static build embedded via `go:embed` | Component model + types + good DX for maintainability, while keeping the single Go-binary deploy. No SSR/server features needed (the Go server is the backend), so Next.js would be used only as a static exporter — Vite fits better. | +| Aesthetic | **Dark editorial** (Fraunces serif + IBM Plex, terracotta accent) | Chosen by the user from three directions; distinctive and high-craft, not a generic dev-tool look. | +| Layout | **Left sidebar (queue list) + main panel** | Chosen by the user; scales to many queues, control-room feel. | +| Charts | **Hand-rolled SVG sparklines** | Dependency-free, matches the mockup, keeps frontend deps to just React (no chart lib, router, or state library). | +| Realtime | **SSE (`GET /api/stream`)**, server composes snapshots | Lower latency than polling and the server already has Redis; the client just listens and renders. | +| Throughput source | **Redis-resident processed/dead counters** (`INCR` in ack/nack), rate derived client-side | `relay_jobs_processed_total` lives only on each worker's `/metrics`; the dashboard server has no processed count. A shared Redis counter is cluster-wide, atomic (one script each), and readable by the server. Client derives Δ/Δt from the counter stream so the server stays stateless per tick. | +| Build/embed | **Commit `web/dist`, `go:embed` it** | `go build ./...` stays self-contained (no Node needed for the Go build/CI/contributors); CI rebuilds the frontend to verify it is in sync. | + +## Visual design (locked) + +Reference mockup: `.superpowers/brainstorm/*/content/dark-editorial.html` (this session). Design tokens: + +``` +--bg:#15120e warm espresso near-black +--panel:#1c1813 surface +--panel-2:#211c16 raised surface +--line:#2e271e hairline border +--ink:#ece3d4 primary text (warm cream) +--muted:#9a8f7c secondary text +--faint:#6f6757 tertiary / labels +--accent:#d2603f terracotta (single accent; DLQ + active markers + primary actions) +fonts: Fraunces (display: wordmark, stat numbers, headings), + IBM Plex Sans (UI text), IBM Plex Mono (labels, job IDs, counts) +``` + +- **Sidebar:** `Relay.` wordmark (Fraunces, the `.` in accent) + `task queue` mono sub-label; a + `Queues` section listing each queue (name in Fraunces, count in mono) with the active one marked + by an inset accent bar; a `+ Enqueue a job` button and a `host · live 1s` footer with a pulsing + accent dot. +- **Main:** breadcrumb + queue name (Fraunces, large) + `updated …` line; a hairline rule; four + stat tiles (Ready / In-flight / Delayed / Dead-letter, the DLQ tile in the accent); two chart + panels (Queue depth, Throughput) with hairline framing and SVG sparklines; the Dead-letter table + (Job ID mono, Attempts, Payload preview, Age, Requeue button). +- Fonts loaded locally (self-host the woff2 in `web/` or via a build-time font step) so the embedded + binary has no external font dependency at runtime; a Google Fonts `` is acceptable for the + mockup but the shipped app should vendor the fonts to keep `/` self-contained offline. (Implementer + may use `@fontsource/*` packages, which Vite bundles.) + +## Architecture + +``` +browser (React SPA) + ├── EventSource("/api/stream") ──► live depth + counters every ~1s (all queues) + ├── GET /api/queues/{q}/dlq ──► DLQ table (on select / refresh) + ├── POST /api/queues/{q}/jobs ──► enqueue form + └── POST /api/queues/{q}/dlq/{id}/requeue ──► requeue button +cmd/server (Go) + ├── / ──► embedded SPA (web/dist via go:embed), index.html fallback + ├── /api/ ──► api.New(broker) (3a endpoints + new /api/stream) + ├── /metrics ──► promhttp (unchanged) + └── /healthz ──► 200 (unchanged) +redis + └── q:{name}:processed / q:{name}:dead (new monotonic counters, INCR by ack/nack) +``` + +## Components & changes + +### `internal/broker/scripts/ack.lua` + +Add one line after the inflight removal / job delete: `redis.call('INCR', KEYS[?] /* processed key */)`. +The processed counter key `q:{name}:processed` is passed in as an extra `KEYS`/`ARGV` entry from Go +(the script does not derive key names). Still one atomic script; the increment only happens on a +successful ack. + +### `internal/broker/scripts/nack.lua` + +On the **dead** branch only (`return 'dead'`), add `redis.call('INCR', /* dead key */)` before +returning. The dead counter key `q:{name}:dead` is passed from Go. Retry branch is unchanged. + +### `internal/broker` (`broker.go`) + +- `Ack` passes the processed-counter key to `ack.lua`; `Nack` passes the dead-counter key to + `nack.lua`. Add key helpers `processedKey(queue)` → `"q:"+queue+":processed"` and + `deadKey(queue)` → `"q:"+queue+":dead"`. +- `type Counters struct { Processed, Dead int64 }` and + `Counters(ctx, queue) (Counters, error)` — `GET` both keys (missing → 0), in one pipeline. +- These are additive; `Ack`/`Nack` signatures and the existing metric instrumentation are unchanged. + +### `internal/api` — `GET /api/stream` (SSE) + +- Sets `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; + flushes after each event (requires `http.Flusher`). +- Loops on a `time.Ticker` (~1s) until `r.Context().Done()`: calls `broker.Queues`, then for each + queue `Stats` + `Counters`, and writes one event + `data: [{"queue":…,"ready":…,"inflight":…,"delayed":…,"dlq":…,"processed_total":…,"dead_total":…}, …]\n\n`. + An initial snapshot is sent immediately (not after the first tick) so the UI populates at once. +- On a Redis error for a tick it skips that tick (logs) rather than tearing down the stream. + +### `web/embed.go` (embed package) + +`go:embed` cannot reach across directories (no `../`), so the embed lives next to the assets: a +tiny `package web` file with `//go:embed all:dist` exposing `var Assets embed.FS` (and a helper +returning an `fs.FS` rooted at `dist`). `cmd/server` imports this package. (`all:` so dotfiles/ +nested assets are included.) + +### `cmd/server` + +- Import `web` and serve `web.Assets` at `/` via `http.FileServerFS`, with an SPA fallback (any + non-`/api`, non-`/metrics`, non-`/healthz` path that isn't a real asset returns `index.html`). + Keep `/api/`, `/metrics`, `/healthz`. The `/api/stream` route is registered by `api.New`. + +### `web/` (new Vite + React + TS app) + +- Deps: `react`, `react-dom`, `@fontsource/fraunces`, `@fontsource/ibm-plex-sans`, + `@fontsource/ibm-plex-mono`; dev: `vite`, `typescript`, `@vitejs/plugin-react`, `vitest`. No chart, + router, or state-management libraries. +- Structure (focused files): `main.tsx`, `App.tsx` (layout + selected-queue state), `api.ts` (REST + calls + types), `useStream.ts` (EventSource hook → snapshot state), `lib/series.ts` (rolling + window + throughput rate, pure + unit-tested), `lib/format.ts` (age/number/bytes, pure + + tested), and components `Sidebar.tsx`, `StatTiles.tsx`, `Sparkline.tsx`, `Charts.tsx`, + `DlqTable.tsx`, `EnqueueForm.tsx`, plus `theme.css` with the tokens above. +- Vite `base: './'` and `build.outDir: 'dist'`, so the build writes `web/dist/` (committed and + embedded by `web/embed.go`) with relative asset URLs. + +## Data model additions + +| Key | Type | Written by | Read by | +|---|---|---|---| +| `q:{name}:processed` | string counter | `ack.lua` (`INCR` on ack) | `broker.Counters` → SSE throughput | +| `q:{name}:dead` | string counter | `nack.lua` (`INCR` on dead) | `broker.Counters` → SSE | + +Both are monotonic and cluster-wide (every worker increments the same key). No TTL, no reset. + +## Testing + +### Go (real Redis where needed; skip-not-fail) + +- **broker (DB 15):** `ack` increments `q:{name}:processed`; `nack`→dead increments `q:{name}:dead` + while `nack`→retry does **not**; `Counters` returns the values (and 0 for an untouched queue). + Existing ack/nack tests still pass (increment is additive). +- **api (DB 12):** `GET /api/stream` returns `text/event-stream`, emits at least one parseable + `data:` snapshot containing a seeded queue with correct fields, then returns promptly when the + request context is cancelled. The SPA fallback handler serves `index.html` for an unknown path. +- **cmd/server:** build/vet only. + +### Frontend + +- `vitest` unit tests for the pure logic: throughput rate from successive cumulative samples + (`lib/series.ts`), rolling-window cap, and `lib/format.ts` (age, counts). The SSE-snapshot reducer + (merge snapshot → per-queue state) is unit-tested with sample payloads. +- Gates: `tsc --noEmit` (strict) and `vite build` must pass. No browser/E2E tests. + +### CI + +- New frontend job: Node setup → `npm ci` (in `web/`) → `tsc --noEmit` → `vitest run` → + `vite build`. The job also fails if `vite build` produces a `web/dist` that differs from the + committed one (keeps the embedded bundle in sync). +- The existing Go job is unchanged and builds against the committed `web/dist`. + +## Invariants preserved + +- At-least-once delivery — the new counters are observational `INCR`s; no job movement changes. +- The atomic claim is sacred — `claim.lua` is untouched; `ack.lua`/`nack.lua` each remain a single + atomic script, now with one additive `INCR`. +- Crash safety via the reaper — untouched. +- Build the queue from scratch on Redis primitives — the dashboard is a separate `web/` workspace; it + adds no Go queue dependency. The Go module still depends only on go-redis + prometheus. + +## Known limitations + +- **Charts are in-memory.** Depth/throughput history is a client-side rolling window; a reload starts + fresh. Long-term history is Prometheus/Grafana's job (3d). +- **Counters are monotonic and never reset.** `processed`/`dead` grow forever (until the Redis DB is + flushed). Throughput is a rate over deltas, so this is fine; absolute totals just keep climbing. +- **SSE fan-out is per-connection.** Each open dashboard runs its own ticker reading Redis; fine for + a handful of viewers, not tuned for many concurrent dashboards. +- **No auth.** Same as 3a — demo-grade. +- **Committed `web/dist`.** The built bundle is in git; it must be rebuilt and committed when the UI + changes (CI verifies it matches source). diff --git a/internal/api/api.go b/internal/api/api.go index 2e0615d..36beac7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,6 +35,7 @@ func New(b *broker.Broker, logger *slog.Logger) http.Handler { mux.HandleFunc("GET /api/queues/{queue}/dlq", a.listDLQ) mux.HandleFunc("POST /api/queues/{queue}/dlq/{id}/requeue", a.requeueDLQ) mux.HandleFunc("GET /api/queues", a.queues) + mux.HandleFunc("GET /api/stream", a.stream) return mux } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 347ad32..eccc331 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,6 +1,7 @@ package api_test import ( + "bufio" "bytes" "context" "encoding/json" @@ -9,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/redis/go-redis/v9" @@ -226,3 +228,50 @@ func TestQueuesEndpointListsNames(t *testing.T) { t.Errorf("names = %v, want [emails sms]", names) } } + +func TestStreamEmitsSnapshot(t *testing.T) { + h, b, _ := newTestAPI(t) + if err := b.Enqueue(context.Background(), mustJob("emails", "x")); err != nil { + t.Fatalf("Enqueue: %v", err) + } + + srv := httptest.NewServer(h) + defer srv.Close() + + reqCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, srv.URL+"/api/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET stream: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } + + reader := bufio.NewReader(resp.Body) + var payload string + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("reading stream: %v", err) + } + if strings.HasPrefix(line, "data: ") { + payload = strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + break + } + } + cancel() + + var snaps []map[string]any + if err := json.Unmarshal([]byte(payload), &snaps); err != nil { + t.Fatalf("decode snapshot %q: %v", payload, err) + } + if len(snaps) != 1 || snaps[0]["queue"] != "emails" { + t.Fatalf("snaps = %v, want one for emails", snaps) + } + if snaps[0]["ready"].(float64) != 1 { + t.Errorf("ready = %v, want 1", snaps[0]["ready"]) + } +} diff --git a/internal/api/stream.go b/internal/api/stream.go new file mode 100644 index 0000000..d6e19d3 --- /dev/null +++ b/internal/api/stream.go @@ -0,0 +1,93 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// streamInterval is how often the SSE stream pushes a fresh snapshot. +const streamInterval = time.Second + +// queueSnapshot is one queue's line in an SSE snapshot: point-in-time depths plus +// the cumulative counters the client rate-computes into throughput. +type queueSnapshot struct { + Queue string `json:"queue"` + Ready int64 `json:"ready"` + Inflight int64 `json:"inflight"` + Delayed int64 `json:"delayed"` + DLQ int64 `json:"dlq"` + ProcessedTotal int64 `json:"processed_total"` + DeadTotal int64 `json:"dead_total"` +} + +// stream handles GET /api/stream: a text/event-stream that pushes a snapshot of +// every queue immediately and then once per streamInterval until the client +// disconnects. A Redis hiccup skips a tick rather than tearing down the stream. +func (a *API) stream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + a.writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ctx := r.Context() + // Immediate first snapshot so the UI populates without waiting a tick. + if !a.writeSnapshot(ctx, w, flusher) { + return + } + ticker := time.NewTicker(streamInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !a.writeSnapshot(ctx, w, flusher) { + return + } + } + } +} + +// writeSnapshot composes and writes one SSE event. It returns false when the +// client connection is gone (write failed), signalling the caller to stop. +func (a *API) writeSnapshot(ctx context.Context, w http.ResponseWriter, flusher http.Flusher) bool { + queues, err := a.broker.Queues(ctx) + if err != nil { + a.logger.Error("api: stream listing queues", "err", err) + return true // skip this tick, keep the stream open + } + snaps := make([]queueSnapshot, 0, len(queues)) + for _, q := range queues { + st, err := a.broker.Stats(ctx, q) + if err != nil { + a.logger.Error("api: stream stats", "queue", q, "err", err) + continue + } + ct, err := a.broker.Counters(ctx, q) + if err != nil { + a.logger.Error("api: stream counters", "queue", q, "err", err) + continue + } + snaps = append(snaps, queueSnapshot{ + Queue: q, Ready: st.Ready, Inflight: st.Inflight, Delayed: st.Delayed, + DLQ: st.DLQ, ProcessedTotal: ct.Processed, DeadTotal: ct.Dead, + }) + } + buf, err := json.Marshal(snaps) + if err != nil { + a.logger.Error("api: stream marshal", "err", err) + return true + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", buf); err != nil { + return false // client disconnected + } + flusher.Flush() + return true +} diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 6cdf44c..f1954b5 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -88,6 +88,15 @@ func dlqKey(queue string) string { return "q:" + queue + ":dlq" } // ZSET scored by each job's ready-at time. The promoter scans it. func delayedKey(queue string) string { return "q:" + queue + ":delayed" } +// processedKey is the Redis key for a queue's cumulative processed counter: +// `q:{name}:processed`, INCR'd by ack.lua. Read by the dashboard for throughput. +func processedKey(queue string) string { return "q:" + queue + ":processed" } + +// deadKey is the Redis key for a queue's cumulative dead-letter counter: +// `q:{name}:dead`, INCR'd by nack.lua on the dead branch. Read by the dashboard +// to show total dead-lettered jobs without scanning the DLQ list. +func deadKey(queue string) string { return "q:" + queue + ":dead" } + // enqueueConfig holds resolved enqueue options. A zero readyAt means "now". type enqueueConfig struct { readyAt time.Time @@ -251,7 +260,7 @@ func (b *Broker) Claim(ctx context.Context, queue string, visibility time.Durati // as one Lua script (ack.lua). func (b *Broker) Ack(ctx context.Context, j job.Job) error { if err := ackScript.Run(ctx, b.rdb, - []string{inflightKey(j.Queue)}, + []string{inflightKey(j.Queue), processedKey(j.Queue)}, j.ID, jobKeyPrefix, ).Err(); err != nil { return fmt.Errorf("broker: acking job %s: %w", j.ID, err) @@ -273,7 +282,7 @@ func (b *Broker) Nack(ctx context.Context, j job.Job) error { readyAt := time.Now().Add(delay).UnixMilli() outcome, err := nackScript.Run(ctx, b.rdb, - []string{inflightKey(j.Queue), delayedKey(j.Queue), dlqKey(j.Queue)}, + []string{inflightKey(j.Queue), delayedKey(j.Queue), dlqKey(j.Queue), deadKey(j.Queue)}, j.ID, jobKeyPrefix, readyAt, ).Text() if err != nil { @@ -383,6 +392,44 @@ func (b *Broker) Stats(ctx context.Context, queue string) (Stats, error) { }, nil } +// Counters is a queue's cumulative, monotonic lifetime totals — distinct from the +// point-in-time depths in Stats. They back the dashboard's throughput rate. +type Counters struct { + Processed int64 `json:"processed_total"` + Dead int64 `json:"dead_total"` +} + +// Counters reads a queue's processed/dead counters in one pipeline. A missing +// key (queue never acked/dead-lettered) reads as 0, not an error. +func (b *Broker) Counters(ctx context.Context, queue string) (Counters, error) { + pipe := b.rdb.Pipeline() + pCmd := pipe.Get(ctx, processedKey(queue)) + dCmd := pipe.Get(ctx, deadKey(queue)) + // A GET on a missing key yields redis.Nil, which Exec surfaces as an error; + // that is expected here, so only a non-Nil error is a real failure. + if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + processed, err := getInt64OrZero(pCmd) + if err != nil { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + dead, err := getInt64OrZero(dCmd) + if err != nil { + return Counters{}, fmt.Errorf("broker: counters for %q: %w", queue, err) + } + return Counters{Processed: processed, Dead: dead}, nil +} + +// getInt64OrZero reads a GET result as int64, treating a missing key as 0. +func getInt64OrZero(cmd *redis.StringCmd) (int64, error) { + v, err := cmd.Int64() + if errors.Is(err, redis.Nil) { + return 0, nil + } + return v, err +} + // DLQ listing bounds: an unset/zero limit uses the default; the max caps a single // page so a huge DLQ cannot be slurped in one request. const ( diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go index a992471..6e4d9ee 100644 --- a/internal/broker/broker_test.go +++ b/internal/broker/broker_test.go @@ -1408,3 +1408,89 @@ func TestQueuesEmpty(t *testing.T) { t.Errorf("names = %v, want empty", names) } } + +func TestAckIncrementsProcessedCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + if err := b.Enqueue(ctx, job.New("emails", []byte("x"))); err != nil { + t.Fatalf("Enqueue: %v", err) + } + j, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: ok=%v err=%v", ok, err) + } + if err := b.Ack(ctx, j); err != nil { + t.Fatalf("Ack: %v", err) + } + if n, _ := rdb.Get(ctx, "q:emails:processed").Int64(); n != 1 { + t.Errorf("q:emails:processed = %d, want 1", n) + } +} + +func TestNackDeadIncrementsDeadCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + _ = deadLetter(t, b, ctx, "emails", "x") + if n, _ := rdb.Get(ctx, "q:emails:dead").Int64(); n != 1 { + t.Errorf("q:emails:dead = %d, want 1", n) + } +} + +func TestNackRetryDoesNotIncrementDeadCounter(t *testing.T) { + b, rdb := newTestBroker(t) + ctx := context.Background() + + j := job.New("emails", []byte("x")) // default MaxRetries=5 -> first nack retries + if err := b.Enqueue(ctx, j); err != nil { + t.Fatalf("Enqueue: %v", err) + } + claimed, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: ok=%v err=%v", ok, err) + } + if err := b.Nack(ctx, claimed); err != nil { + t.Fatalf("Nack: %v", err) + } + if n, _ := rdb.Get(ctx, "q:emails:dead").Int64(); n != 0 { + t.Errorf("q:emails:dead = %d, want 0 (retry must not increment)", n) + } +} + +func TestCountersReadsProcessedAndDead(t *testing.T) { + b, _ := newTestBroker(t) + ctx := context.Background() + + // process one (ack) and dead-letter one + if err := b.Enqueue(ctx, job.New("emails", []byte("ok"))); err != nil { + t.Fatalf("Enqueue: %v", err) + } + j, ok, err := b.Claim(ctx, "emails", time.Minute) + if err != nil || !ok { + t.Fatalf("Claim: %v %v", ok, err) + } + if err := b.Ack(ctx, j); err != nil { + t.Fatalf("Ack: %v", err) + } + _ = deadLetter(t, b, ctx, "emails", "bad") + + c, err := b.Counters(ctx, "emails") + if err != nil { + t.Fatalf("Counters: %v", err) + } + if c.Processed != 1 || c.Dead != 1 { + t.Errorf("Counters = %+v, want {Processed:1 Dead:1}", c) + } +} + +func TestCountersUntouchedQueueIsZero(t *testing.T) { + b, _ := newTestBroker(t) + c, err := b.Counters(context.Background(), "emails") + if err != nil { + t.Fatalf("Counters: %v", err) + } + if c.Processed != 0 || c.Dead != 0 { + t.Errorf("Counters = %+v, want zeros", c) + } +} diff --git a/internal/broker/scripts/ack.lua b/internal/broker/scripts/ack.lua index 1c0f9cf..16ee990 100644 --- a/internal/broker/scripts/ack.lua +++ b/internal/broker/scripts/ack.lua @@ -1,10 +1,12 @@ -- ack.lua — acknowledge that a job was processed successfully. -- --- KEYS[1] = inflight set q:{name}:inflight +-- KEYS[1] = inflight set q:{name}:inflight +-- KEYS[2] = processed counter q:{name}:processed (cluster-wide throughput counter) -- ARGV[1] = job id -- ARGV[2] = job hash key prefix ("job:") local id = ARGV[1] redis.call('ZREM', KEYS[1], id) redis.call('DEL', ARGV[2] .. id) +redis.call('INCR', KEYS[2]) return 1 diff --git a/internal/broker/scripts/nack.lua b/internal/broker/scripts/nack.lua index 122bbea..d0d68b8 100644 --- a/internal/broker/scripts/nack.lua +++ b/internal/broker/scripts/nack.lua @@ -3,12 +3,14 @@ -- Always removes the job from the inflight set, then decides its fate from the -- attempt count on the job hash (claim bumps it): retries left -> requeue to the -- delayed set at a caller-computed ready-at (the backoff), so the retry waits; --- budget spent -> move to the dead-letter queue. Reading the counts from the --- hash here keeps the decision atomic with the move. +-- budget spent -> move to the dead-letter queue and INCR the dead counter so +-- the dashboard can show a cluster-wide total without scanning the DLQ list. +-- Reading the counts from the hash here keeps the decision atomic with the move. -- -- KEYS[1] = inflight set q:{name}:inflight -- KEYS[2] = delayed set q:{name}:delayed -- KEYS[3] = dead-letter q:{name}:dlq +-- KEYS[4] = dead counter q:{name}:dead (cluster-wide; INCR only when dead-lettered) -- ARGV[1] = job id -- ARGV[2] = job hash key prefix ("job:") -- ARGV[3] = retry ready-at in unix milliseconds (precomputed backoff) @@ -32,4 +34,5 @@ end redis.call('HSET', job_key, 'state', 'dead') redis.call('RPUSH', KEYS[3], id) +redis.call('INCR', KEYS[4]) return 'dead' diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..00f7330 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/web/dist/assets/fraunces-latin-400-normal-6IfK1voy.woff2 b/web/dist/assets/fraunces-latin-400-normal-6IfK1voy.woff2 new file mode 100644 index 0000000..83cbd8f Binary files /dev/null and b/web/dist/assets/fraunces-latin-400-normal-6IfK1voy.woff2 differ diff --git a/web/dist/assets/fraunces-latin-400-normal-NUPT2cO8.woff b/web/dist/assets/fraunces-latin-400-normal-NUPT2cO8.woff new file mode 100644 index 0000000..cf1dc85 Binary files /dev/null and b/web/dist/assets/fraunces-latin-400-normal-NUPT2cO8.woff differ diff --git a/web/dist/assets/fraunces-latin-500-normal-BTR4KCeb.woff b/web/dist/assets/fraunces-latin-500-normal-BTR4KCeb.woff new file mode 100644 index 0000000..c0c22d6 Binary files /dev/null and b/web/dist/assets/fraunces-latin-500-normal-BTR4KCeb.woff differ diff --git a/web/dist/assets/fraunces-latin-500-normal-DnGCNyPD.woff2 b/web/dist/assets/fraunces-latin-500-normal-DnGCNyPD.woff2 new file mode 100644 index 0000000..969f099 Binary files /dev/null and b/web/dist/assets/fraunces-latin-500-normal-DnGCNyPD.woff2 differ diff --git a/web/dist/assets/fraunces-latin-600-normal-BFCDtZfi.woff2 b/web/dist/assets/fraunces-latin-600-normal-BFCDtZfi.woff2 new file mode 100644 index 0000000..58cee1f Binary files /dev/null and b/web/dist/assets/fraunces-latin-600-normal-BFCDtZfi.woff2 differ diff --git a/web/dist/assets/fraunces-latin-600-normal-DL5QCzvS.woff b/web/dist/assets/fraunces-latin-600-normal-DL5QCzvS.woff new file mode 100644 index 0000000..13fb14c Binary files /dev/null and b/web/dist/assets/fraunces-latin-600-normal-DL5QCzvS.woff differ diff --git a/web/dist/assets/fraunces-latin-ext-400-normal-D8gbi3Gu.woff2 b/web/dist/assets/fraunces-latin-ext-400-normal-D8gbi3Gu.woff2 new file mode 100644 index 0000000..fa99356 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-400-normal-D8gbi3Gu.woff2 differ diff --git a/web/dist/assets/fraunces-latin-ext-400-normal-UihxqfOe.woff b/web/dist/assets/fraunces-latin-ext-400-normal-UihxqfOe.woff new file mode 100644 index 0000000..0de2143 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-400-normal-UihxqfOe.woff differ diff --git a/web/dist/assets/fraunces-latin-ext-500-normal-BMcFk1Xs.woff b/web/dist/assets/fraunces-latin-ext-500-normal-BMcFk1Xs.woff new file mode 100644 index 0000000..60d0d76 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-500-normal-BMcFk1Xs.woff differ diff --git a/web/dist/assets/fraunces-latin-ext-500-normal-Z5DV8IzT.woff2 b/web/dist/assets/fraunces-latin-ext-500-normal-Z5DV8IzT.woff2 new file mode 100644 index 0000000..c0a7961 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-500-normal-Z5DV8IzT.woff2 differ diff --git a/web/dist/assets/fraunces-latin-ext-600-normal-B0Dy4lqi.woff b/web/dist/assets/fraunces-latin-ext-600-normal-B0Dy4lqi.woff new file mode 100644 index 0000000..2cc72e4 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-600-normal-B0Dy4lqi.woff differ diff --git a/web/dist/assets/fraunces-latin-ext-600-normal-BtzmzP0X.woff2 b/web/dist/assets/fraunces-latin-ext-600-normal-BtzmzP0X.woff2 new file mode 100644 index 0000000..0fb44d3 Binary files /dev/null and b/web/dist/assets/fraunces-latin-ext-600-normal-BtzmzP0X.woff2 differ diff --git a/web/dist/assets/fraunces-vietnamese-400-normal-B65MOf9T.woff b/web/dist/assets/fraunces-vietnamese-400-normal-B65MOf9T.woff new file mode 100644 index 0000000..77b91db Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-400-normal-B65MOf9T.woff differ diff --git a/web/dist/assets/fraunces-vietnamese-400-normal-CvGt0Ybw.woff2 b/web/dist/assets/fraunces-vietnamese-400-normal-CvGt0Ybw.woff2 new file mode 100644 index 0000000..1042bc7 Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-400-normal-CvGt0Ybw.woff2 differ diff --git a/web/dist/assets/fraunces-vietnamese-500-normal-B-KbxExq.woff b/web/dist/assets/fraunces-vietnamese-500-normal-B-KbxExq.woff new file mode 100644 index 0000000..6ffaf00 Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-500-normal-B-KbxExq.woff differ diff --git a/web/dist/assets/fraunces-vietnamese-500-normal-GOH_-EGq.woff2 b/web/dist/assets/fraunces-vietnamese-500-normal-GOH_-EGq.woff2 new file mode 100644 index 0000000..06db759 Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-500-normal-GOH_-EGq.woff2 differ diff --git a/web/dist/assets/fraunces-vietnamese-600-normal-BjlAJixd.woff2 b/web/dist/assets/fraunces-vietnamese-600-normal-BjlAJixd.woff2 new file mode 100644 index 0000000..6e2b764 Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-600-normal-BjlAJixd.woff2 differ diff --git a/web/dist/assets/fraunces-vietnamese-600-normal-DlAl5EAR.woff b/web/dist/assets/fraunces-vietnamese-600-normal-DlAl5EAR.woff new file mode 100644 index 0000000..2cbadd6 Binary files /dev/null and b/web/dist/assets/fraunces-vietnamese-600-normal-DlAl5EAR.woff differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 new file mode 100644 index 0000000..20e72b0 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff b/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff new file mode 100644 index 0000000..1487d93 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff b/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff new file mode 100644 index 0000000..7e53f59 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 b/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 new file mode 100644 index 0000000..328efe3 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff new file mode 100644 index 0000000..eff6edd Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 new file mode 100644 index 0000000..b54e556 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff b/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff new file mode 100644 index 0000000..626c775 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff differ diff --git a/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 b/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 new file mode 100644 index 0000000..7d066e9 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/web/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff new file mode 100644 index 0000000..8c83fdc Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff differ diff --git a/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff b/web/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff new file mode 100644 index 0000000..780de71 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff differ diff --git a/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 b/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 b/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 new file mode 100644 index 0000000..732c64d Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff new file mode 100644 index 0000000..338fc76 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff differ diff --git a/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 b/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 new file mode 100644 index 0000000..6f33f8c Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff b/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff new file mode 100644 index 0000000..370da9c Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff differ diff --git a/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 new file mode 100644 index 0000000..21612f4 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 differ diff --git a/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff new file mode 100644 index 0000000..dfa68b0 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff differ diff --git a/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff b/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff new file mode 100644 index 0000000..b1a1d12 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff differ diff --git a/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 b/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 new file mode 100644 index 0000000..3920890 Binary files /dev/null and b/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff b/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff new file mode 100644 index 0000000..7ea5d2f Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 new file mode 100644 index 0000000..5ad40b4 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff b/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff new file mode 100644 index 0000000..5ecd4be Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 new file mode 100644 index 0000000..189f9dc Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 new file mode 100644 index 0000000..2c315c4 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff b/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff new file mode 100644 index 0000000..b2d5754 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff b/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff new file mode 100644 index 0000000..ccff0fd Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 new file mode 100644 index 0000000..a9aff11 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 new file mode 100644 index 0000000..d867293 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff b/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff new file mode 100644 index 0000000..c43bb48 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff b/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff new file mode 100644 index 0000000..188fae5 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff differ diff --git a/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 b/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 new file mode 100644 index 0000000..69122c8 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff b/web/dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff new file mode 100644 index 0000000..1284c3f Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff differ diff --git a/web/dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 b/web/dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 new file mode 100644 index 0000000..e8de644 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff b/web/dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff new file mode 100644 index 0000000..6039fb4 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff differ diff --git a/web/dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 b/web/dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 new file mode 100644 index 0000000..ae71565 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff b/web/dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff new file mode 100644 index 0000000..c11439f Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff differ diff --git a/web/dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 b/web/dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 new file mode 100644 index 0000000..97756a7 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 b/web/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff b/web/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff new file mode 100644 index 0000000..cf5e2bb Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 b/web/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff b/web/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff new file mode 100644 index 0000000..9ee6144 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff b/web/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff new file mode 100644 index 0000000..0ccb1f1 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 b/web/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 b/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 new file mode 100644 index 0000000..ef4be2d Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff b/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff new file mode 100644 index 0000000..64f060c Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff b/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff new file mode 100644 index 0000000..e4256e2 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 b/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 new file mode 100644 index 0000000..6b51734 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff b/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff new file mode 100644 index 0000000..8051392 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff differ diff --git a/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 b/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 new file mode 100644 index 0000000..c19b3c6 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 b/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 new file mode 100644 index 0000000..4fb6455 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff b/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff new file mode 100644 index 0000000..b139b97 Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff b/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff new file mode 100644 index 0000000..776292e Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 b/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 new file mode 100644 index 0000000..13d454e Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff b/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff new file mode 100644 index 0000000..6a1609c Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff differ diff --git a/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 b/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 new file mode 100644 index 0000000..6a1e2be Binary files /dev/null and b/web/dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 differ diff --git a/web/dist/assets/index-C3ZkpPi7.js b/web/dist/assets/index-C3ZkpPi7.js new file mode 100644 index 0000000..9f4bcbc --- /dev/null +++ b/web/dist/assets/index-C3ZkpPi7.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const o of u.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();var Yi={exports:{}},el={},Xi={exports:{}},L={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Xn=Symbol.for("react.element"),sc=Symbol.for("react.portal"),ac=Symbol.for("react.fragment"),cc=Symbol.for("react.strict_mode"),fc=Symbol.for("react.profiler"),dc=Symbol.for("react.provider"),pc=Symbol.for("react.context"),mc=Symbol.for("react.forward_ref"),hc=Symbol.for("react.suspense"),vc=Symbol.for("react.memo"),yc=Symbol.for("react.lazy"),Do=Symbol.iterator;function gc(e){return e===null||typeof e!="object"?null:(e=Do&&e[Do]||e["@@iterator"],typeof e=="function"?e:null)}var Gi={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},qi=Object.assign,Zi={};function ln(e,t,n){this.props=e,this.context=t,this.refs=Zi,this.updater=n||Gi}ln.prototype.isReactComponent={};ln.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};ln.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Ji(){}Ji.prototype=ln.prototype;function $u(e,t,n){this.props=e,this.context=t,this.refs=Zi,this.updater=n||Gi}var Uu=$u.prototype=new Ji;Uu.constructor=$u;qi(Uu,ln.prototype);Uu.isPureReactComponent=!0;var Mo=Array.isArray,bi=Object.prototype.hasOwnProperty,Au={current:null},es={key:!0,ref:!0,__self:!0,__source:!0};function ts(e,t,n){var r,l={},u=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(u=""+t.key),t)bi.call(t,r)&&!es.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,q=C[H];if(0>>1;Hl(gl,T))ytl(er,gl)?(C[H]=er,C[yt]=T,H=yt):(C[H]=gl,C[vt]=T,H=vt);else if(ytl(er,T))C[H]=er,C[yt]=T,H=yt;else break e}}return z}function l(C,z){var T=C.sortIndex-z.sortIndex;return T!==0?T:C.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var o=Date,i=o.now();e.unstable_now=function(){return o.now()-i}}var s=[],f=[],h=1,m=null,p=3,w=!1,S=!1,k=!1,j=typeof setTimeout=="function"?setTimeout:null,c=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var z=n(f);z!==null;){if(z.callback===null)r(f);else if(z.startTime<=C)r(f),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(f)}}function v(C){if(k=!1,d(C),!S)if(n(s)!==null)S=!0,vl(E);else{var z=n(f);z!==null&&yl(v,z.startTime-C)}}function E(C,z){S=!1,k&&(k=!1,c(P),P=-1),w=!0;var T=p;try{for(d(z),m=n(s);m!==null&&(!(m.expirationTime>z)||C&&!Pe());){var H=m.callback;if(typeof H=="function"){m.callback=null,p=m.priorityLevel;var q=H(m.expirationTime<=z);z=e.unstable_now(),typeof q=="function"?m.callback=q:m===n(s)&&r(s),d(z)}else r(s);m=n(s)}if(m!==null)var bn=!0;else{var vt=n(f);vt!==null&&yl(v,vt.startTime-z),bn=!1}return bn}finally{m=null,p=T,w=!1}}var _=!1,N=null,P=-1,Q=5,R=-1;function Pe(){return!(e.unstable_now()-RC||125H?(C.sortIndex=T,t(f,C),n(s)===null&&C===n(f)&&(k?(c(P),P=-1):k=!0,yl(v,T-H))):(C.sortIndex=q,t(s,C),S||w||(S=!0,vl(E))),C},e.unstable_shouldYield=Pe,e.unstable_wrapCallback=function(C){var z=p;return function(){var T=p;p=z;try{return C.apply(this,arguments)}finally{p=T}}}})(os);us.exports=os;var jc=us.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Tc=W,ge=jc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Kl=Object.prototype.hasOwnProperty,Lc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Fo={},$o={};function Rc(e){return Kl.call($o,e)?!0:Kl.call(Fo,e)?!1:Lc.test(e)?$o[e]=!0:(Fo[e]=!0,!1)}function Oc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Dc(e,t,n,r){if(t===null||typeof t>"u"||Oc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var Bu=/[\-:]([a-z])/g;function Qu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Bu,Qu);te[t]=new ae(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Bu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Bu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function Hu(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{kl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?gn(e):""}function Mc(e){switch(e.tag){case 5:return gn(e.type);case 16:return gn("Lazy");case 13:return gn("Suspense");case 19:return gn("SuspenseList");case 0:case 2:case 15:return e=xl(e.type,!1),e;case 11:return e=xl(e.type.render,!1),e;case 1:return e=xl(e.type,!0),e;default:return""}}function ql(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Dt:return"Fragment";case Ot:return"Portal";case Yl:return"Profiler";case Wu:return"StrictMode";case Xl:return"Suspense";case Gl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case as:return(e.displayName||"Context")+".Consumer";case ss:return(e._context.displayName||"Context")+".Provider";case Ku:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Yu:return t=e.displayName||null,t!==null?t:ql(e.type)||"Memo";case Ze:t=e._payload,e=e._init;try{return ql(e(t))}catch{}}return null}function Ic(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ql(t);case 8:return t===Wu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function fs(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Fc(e){var t=fs(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function rr(e){e._valueTracker||(e._valueTracker=Fc(e))}function ds(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=fs(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Tr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Zl(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ao(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ps(e,t){t=t.checked,t!=null&&Hu(e,"checked",t,!1)}function Jl(e,t){ps(e,t);var n=ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?bl(e,t.type,n):t.hasOwnProperty("defaultValue")&&bl(e,t.type,ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Vo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function bl(e,t,n){(t!=="number"||Tr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var wn=Array.isArray;function Wt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=lr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Rn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var xn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$c=["Webkit","ms","Moz","O"];Object.keys(xn).forEach(function(e){$c.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),xn[t]=xn[e]})});function ys(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||xn.hasOwnProperty(e)&&xn[e]?(""+t).trim():t+"px"}function gs(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=ys(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Uc=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function nu(e,t){if(t){if(Uc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function ru(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var lu=null;function Xu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var uu=null,Kt=null,Yt=null;function Ho(e){if(e=Zn(e)){if(typeof uu!="function")throw Error(y(280));var t=e.stateNode;t&&(t=ul(t),uu(e.stateNode,e.type,t))}}function ws(e){Kt?Yt?Yt.push(e):Yt=[e]:Kt=e}function Ss(){if(Kt){var e=Kt,t=Yt;if(Yt=Kt=null,Ho(e),t)for(e=0;e>>=0,e===0?32:31-(qc(e)/Zc|0)|0}var ur=64,or=4194304;function Sn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=Sn(i):(u&=o,u!==0&&(r=Sn(u)))}else o=n&~l,o!==0?r=Sn(o):u!==0&&(r=Sn(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Gn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function tf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Cn),bo=" ",ei=!1;function As(e,t){switch(e){case"keyup":return Tf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Vs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Mt=!1;function Rf(e,t){switch(e){case"compositionend":return Vs(t);case"keypress":return t.which!==32?null:(ei=!0,bo);case"textInput":return e=t.data,e===bo&&ei?null:e;default:return null}}function Of(e,t){if(Mt)return e==="compositionend"||!no&&As(e,t)?(e=$s(),kr=bu=tt=null,Mt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=li(n)}}function Ws(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ws(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ks(){for(var e=window,t=Tr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Tr(e.document)}return t}function ro(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Bf(e){var t=Ks(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ws(n.ownerDocument.documentElement,n)){if(r!==null&&ro(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=ui(n,u);var o=ui(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,It=null,fu=null,Nn=null,du=!1;function oi(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;du||It==null||It!==Tr(r)||(r=It,"selectionStart"in r&&ro(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Nn&&$n(Nn,r)||(Nn=r,r=Fr(fu,"onSelect"),0Ut||(e.current=gu[Ut],gu[Ut]=null,Ut--)}function M(e,t){Ut++,gu[Ut]=e.current,e.current=t}var dt={},ue=mt(dt),de=mt(!1),_t=dt;function Jt(e,t){var n=e.type.contextTypes;if(!n)return dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Ur(){F(de),F(ue)}function pi(e,t,n){if(ue.current!==dt)throw Error(y(168));M(ue,t),M(de,n)}function ta(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,Ic(e)||"Unknown",l));return V({},n,r)}function Ar(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||dt,_t=ue.current,M(ue,e),M(de,de.current),!0}function mi(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=ta(e,t,_t),r.__reactInternalMemoizedMergedChildContext=e,F(de),F(ue),M(ue,e)):F(de),M(de,n)}var Ve=null,ol=!1,Il=!1;function na(e){Ve===null?Ve=[e]:Ve.push(e)}function ed(e){ol=!0,na(e)}function ht(){if(!Il&&Ve!==null){Il=!0;var e=0,t=D;try{var n=Ve;for(D=1;e>=o,l-=o,Be=1<<32-Re(t)+l|n<P?(Q=N,N=null):Q=N.sibling;var R=p(c,N,d[P],v);if(R===null){N===null&&(N=Q);break}e&&N&&R.alternate===null&&t(c,N),a=u(R,a,P),_===null?E=R:_.sibling=R,_=R,N=Q}if(P===d.length)return n(c,N),$&>(c,P),E;if(N===null){for(;PP?(Q=N,N=null):Q=N.sibling;var Pe=p(c,N,R.value,v);if(Pe===null){N===null&&(N=Q);break}e&&N&&Pe.alternate===null&&t(c,N),a=u(Pe,a,P),_===null?E=Pe:_.sibling=Pe,_=Pe,N=Q}if(R.done)return n(c,N),$&>(c,P),E;if(N===null){for(;!R.done;P++,R=d.next())R=m(c,R.value,v),R!==null&&(a=u(R,a,P),_===null?E=R:_.sibling=R,_=R);return $&>(c,P),E}for(N=r(c,N);!R.done;P++,R=d.next())R=w(N,c,P,R.value,v),R!==null&&(e&&R.alternate!==null&&N.delete(R.key===null?P:R.key),a=u(R,a,P),_===null?E=R:_.sibling=R,_=R);return e&&N.forEach(function(sn){return t(c,sn)}),$&>(c,P),E}function j(c,a,d,v){if(typeof d=="object"&&d!==null&&d.type===Dt&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case nr:e:{for(var E=d.key,_=a;_!==null;){if(_.key===E){if(E=d.type,E===Dt){if(_.tag===7){n(c,_.sibling),a=l(_,d.props.children),a.return=c,c=a;break e}}else if(_.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===Ze&&yi(E)===_.type){n(c,_.sibling),a=l(_,d.props),a.ref=hn(c,_,d),a.return=c,c=a;break e}n(c,_);break}else t(c,_);_=_.sibling}d.type===Dt?(a=Ct(d.props.children,c.mode,v,d.key),a.return=c,c=a):(v=jr(d.type,d.key,d.props,null,c.mode,v),v.ref=hn(c,a,d),v.return=c,c=v)}return o(c);case Ot:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(c,a.sibling),a=l(a,d.children||[]),a.return=c,c=a;break e}else{n(c,a);break}else t(c,a);a=a.sibling}a=Hl(d,c.mode,v),a.return=c,c=a}return o(c);case Ze:return _=d._init,j(c,a,_(d._payload),v)}if(wn(d))return S(c,a,d,v);if(cn(d))return k(c,a,d,v);pr(c,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(c,a.sibling),a=l(a,d),a.return=c,c=a):(n(c,a),a=Ql(d,c.mode,v),a.return=c,c=a),o(c)):n(c,a)}return j}var en=oa(!0),ia=oa(!1),Qr=mt(null),Hr=null,Bt=null,io=null;function so(){io=Bt=Hr=null}function ao(e){var t=Qr.current;F(Qr),e._currentValue=t}function ku(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Gt(e,t){Hr=e,io=Bt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function _e(e){var t=e._currentValue;if(io!==e)if(e={context:e,memoizedValue:t,next:null},Bt===null){if(Hr===null)throw Error(y(308));Bt=e,Hr.dependencies={lanes:0,firstContext:e}}else Bt=Bt.next=e;return t}var kt=null;function co(e){kt===null?kt=[e]:kt.push(e)}function sa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,co(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Je=!1;function fo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function aa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function He(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function it(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,O&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,co(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function Er(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}function gi(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Wr(e,t,n,r){var l=e.updateQueue;Je=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,f=s.next;s.next=null,o===null?u=f:o.next=f,o=s;var h=e.alternate;h!==null&&(h=h.updateQueue,i=h.lastBaseUpdate,i!==o&&(i===null?h.firstBaseUpdate=f:i.next=f,h.lastBaseUpdate=s))}if(u!==null){var m=l.baseState;o=0,h=f=s=null,i=u;do{var p=i.lane,w=i.eventTime;if((r&p)===p){h!==null&&(h=h.next={eventTime:w,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var S=e,k=i;switch(p=t,w=n,k.tag){case 1:if(S=k.payload,typeof S=="function"){m=S.call(w,m,p);break e}m=S;break e;case 3:S.flags=S.flags&-65537|128;case 0:if(S=k.payload,p=typeof S=="function"?S.call(w,m,p):S,p==null)break e;m=V({},m,p);break e;case 2:Je=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[i]:p.push(i))}else w={eventTime:w,lane:p,tag:i.tag,payload:i.payload,callback:i.callback,next:null},h===null?(f=h=w,s=m):h=h.next=w,o|=p;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;p=i,i=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(h===null&&(s=m),l.baseState=s,l.firstBaseUpdate=f,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);zt|=o,e.lanes=o,e.memoizedState=m}}function wi(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=$l.transition;$l.transition={};try{e(!1),t()}finally{D=n,$l.transition=r}}function Na(){return Ne().memoizedState}function ld(e,t,n){var r=at(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Pa(e))za(t,n);else if(n=sa(e,t,n,r),n!==null){var l=ie();Oe(n,e,r,l),ja(n,t,r)}}function ud(e,t,n){var r=at(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Pa(e))za(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,De(i,o)){var s=t.interleaved;s===null?(l.next=l,co(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=sa(e,t,l,r),n!==null&&(l=ie(),Oe(n,e,r,l),ja(n,t,r))}}function Pa(e){var t=e.alternate;return e===A||t!==null&&t===A}function za(e,t){Pn=Yr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ja(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}var Xr={readContext:_e,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},od={readContext:_e,useCallback:function(e,t){return Ie().memoizedState=[e,t===void 0?null:t],e},useContext:_e,useEffect:ki,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,_r(4194308,4,ka.bind(null,t,e),n)},useLayoutEffect:function(e,t){return _r(4194308,4,e,t)},useInsertionEffect:function(e,t){return _r(4,2,e,t)},useMemo:function(e,t){var n=Ie();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ie();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=ld.bind(null,A,e),[r.memoizedState,e]},useRef:function(e){var t=Ie();return e={current:e},t.memoizedState=e},useState:Si,useDebugValue:So,useDeferredValue:function(e){return Ie().memoizedState=e},useTransition:function(){var e=Si(!1),t=e[0];return e=rd.bind(null,e[1]),Ie().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=A,l=Ie();if($){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),J===null)throw Error(y(349));Pt&30||pa(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,ki(ha.bind(null,r,u,e),[e]),r.flags|=2048,Kn(9,ma.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=Ie(),t=J.identifierPrefix;if($){var n=Qe,r=Be;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Hn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Fe]=t,e[Vn]=r,Ua(e,t,!1,!1),t.stateNode=e;e:{switch(o=ru(n,r),n){case"dialog":I("cancel",e),I("close",e),l=r;break;case"iframe":case"object":case"embed":I("load",e),l=r;break;case"video":case"audio":for(l=0;lrn&&(t.flags|=128,r=!0,vn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Kr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),vn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!$)return re(t),null}else 2*K()-u.renderingStartTime>rn&&n!==1073741824&&(t.flags|=128,r=!0,vn(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=K(),t.sibling=null,n=U.current,M(U,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return No(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?he&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function md(e,t){switch(uo(t),t.tag){case 1:return pe(t.type)&&Ur(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return tn(),F(de),F(ue),ho(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return mo(t),null;case 13:if(F(U),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));bt()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F(U),null;case 4:return tn(),null;case 10:return ao(t.type._context),null;case 22:case 23:return No(),null;case 24:return null;default:return null}}var hr=!1,le=!1,hd=typeof WeakSet=="function"?WeakSet:Set,x=null;function Qt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){B(e,t,r)}else n.current=null}function Tu(e,t,n){try{n()}catch(r){B(e,t,r)}}var Ri=!1;function vd(e,t){if(pu=Mr,e=Ks(),ro(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,f=0,h=0,m=e,p=null;t:for(;;){for(var w;m!==n||l!==0&&m.nodeType!==3||(i=o+l),m!==u||r!==0&&m.nodeType!==3||(s=o+r),m.nodeType===3&&(o+=m.nodeValue.length),(w=m.firstChild)!==null;)p=m,m=w;for(;;){if(m===e)break t;if(p===n&&++f===l&&(i=o),p===u&&++h===r&&(s=o),(w=m.nextSibling)!==null)break;m=p,p=m.parentNode}m=w}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(mu={focusedElem:e,selectionRange:n},Mr=!1,x=t;x!==null;)if(t=x,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,x=e;else for(;x!==null;){t=x;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var k=S.memoizedProps,j=S.memoizedState,c=t.stateNode,a=c.getSnapshotBeforeUpdate(t.elementType===t.type?k:je(t.type,k),j);c.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(v){B(t,t.return,v)}if(e=t.sibling,e!==null){e.return=t.return,x=e;break}x=t.return}return S=Ri,Ri=!1,S}function zn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&Tu(t,n,u)}l=l.next}while(l!==r)}}function al(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Lu(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ba(e){var t=e.alternate;t!==null&&(e.alternate=null,Ba(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Vn],delete t[yu],delete t[Jf],delete t[bf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Qa(e){return e.tag===5||e.tag===3||e.tag===4}function Oi(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Qa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ru(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=$r));else if(r!==4&&(e=e.child,e!==null))for(Ru(e,t,n),e=e.sibling;e!==null;)Ru(e,t,n),e=e.sibling}function Ou(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ou(e,t,n),e=e.sibling;e!==null;)Ou(e,t,n),e=e.sibling}var b=null,Te=!1;function qe(e,t,n){for(n=n.child;n!==null;)Ha(e,t,n),n=n.sibling}function Ha(e,t,n){if($e&&typeof $e.onCommitFiberUnmount=="function")try{$e.onCommitFiberUnmount(tl,n)}catch{}switch(n.tag){case 5:le||Qt(n,t);case 6:var r=b,l=Te;b=null,qe(e,t,n),b=r,Te=l,b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?Ml(e.parentNode,n):e.nodeType===1&&Ml(e,n),In(e)):Ml(b,n.stateNode));break;case 4:r=b,l=Te,b=n.stateNode.containerInfo,Te=!0,qe(e,t,n),b=r,Te=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&(u&2||u&4)&&Tu(n,t,o),l=l.next}while(l!==r)}qe(e,t,n);break;case 1:if(!le&&(Qt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){B(n,t,i)}qe(e,t,n);break;case 21:qe(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,qe(e,t,n),le=r):qe(e,t,n);break;default:qe(e,t,n)}}function Di(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new hd),t.forEach(function(r){var l=_d.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=K()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*gd(r/1960))-r,10e?16:e,nt===null)var r=!1;else{if(e=nt,nt=null,Zr=0,O&6)throw Error(y(331));var l=O;for(O|=4,x=e.current;x!==null;){var u=x,o=u.child;if(x.flags&16){var i=u.deletions;if(i!==null){for(var s=0;sK()-Co?Et(e,0):Eo|=n),me(e,t)}function Ja(e,t){t===0&&(e.mode&1?(t=or,or<<=1,!(or&130023424)&&(or=4194304)):t=1);var n=ie();e=Ye(e,t),e!==null&&(Gn(e,t,n),me(e,n))}function Cd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ja(e,n)}function _d(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),Ja(e,n)}var ba;ba=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,dd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,$&&t.flags&1048576&&ra(t,Br,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Nr(e,t),e=t.pendingProps;var l=Jt(t,ue.current);Gt(t,n),l=yo(null,t,r,e,l,n);var u=go();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(u=!0,Ar(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,fo(t),l.updater=sl,t.stateNode=l,l._reactInternals=t,Eu(t,r,e,n),t=Nu(null,t,r,!0,u,n)):(t.tag=0,$&&u&&lo(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Nr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Pd(r),e=je(r,e),l){case 0:t=_u(null,t,r,e,n);break e;case 1:t=ji(null,t,r,e,n);break e;case 11:t=Pi(null,t,r,e,n);break e;case 14:t=zi(null,t,r,je(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),_u(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),ji(e,t,r,l,n);case 3:e:{if(Ia(t),e===null)throw Error(y(387));r=t.pendingProps,u=t.memoizedState,l=u.element,aa(e,t),Wr(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=nn(Error(y(423)),t),t=Ti(e,t,r,n,l);break e}else if(r!==l){l=nn(Error(y(424)),t),t=Ti(e,t,r,n,l);break e}else for(ve=ot(t.stateNode.containerInfo.firstChild),ye=t,$=!0,Le=null,n=ia(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(bt(),r===l){t=Xe(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return ca(t),e===null&&Su(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,hu(r,l)?o=null:u!==null&&hu(r,u)&&(t.flags|=32),Ma(e,t),oe(e,t,o,n),t.child;case 6:return e===null&&Su(t),null;case 13:return Fa(e,t,n);case 4:return po(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=en(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),Pi(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,M(Qr,r._currentValue),r._currentValue=o,u!==null)if(De(u.value,o)){if(u.children===l.children&&!de.current){t=Xe(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=He(-1,n&-n),s.tag=2;var f=u.updateQueue;if(f!==null){f=f.shared;var h=f.pending;h===null?s.next=s:(s.next=h.next,h.next=s),f.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),ku(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(y(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),ku(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Gt(t,n),l=_e(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=je(r,t.pendingProps),l=je(r.type,l),zi(e,t,r,l,n);case 15:return Oa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),Nr(e,t),t.tag=1,pe(r)?(e=!0,Ar(t)):e=!1,Gt(t,n),Ta(t,r,l),Eu(t,r,l,n),Nu(null,t,r,!0,e,n);case 19:return $a(e,t,n);case 22:return Da(e,t,n)}throw Error(y(156,t.tag))};function ec(e,t){return Ps(e,t)}function Nd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Nd(e,t,n,r)}function zo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Pd(e){if(typeof e=="function")return zo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ku)return 11;if(e===Yu)return 14}return 2}function ct(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function jr(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")zo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Dt:return Ct(n.children,l,u,t);case Wu:o=8,l|=8;break;case Yl:return e=Ee(12,n,t,l|2),e.elementType=Yl,e.lanes=u,e;case Xl:return e=Ee(13,n,t,l),e.elementType=Xl,e.lanes=u,e;case Gl:return e=Ee(19,n,t,l),e.elementType=Gl,e.lanes=u,e;case cs:return fl(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ss:o=10;break e;case as:o=9;break e;case Ku:o=11;break e;case Yu:o=14;break e;case Ze:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function Ct(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function fl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=cs,e.lanes=n,e.stateNode={isHidden:!1},e}function Ql(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Hl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function zd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Cl(0),this.expirationTimes=Cl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Cl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function jo(e,t,n,r,l,u,o,i,s){return e=new zd(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ee(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},fo(u),e}function jd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(lc)}catch(e){console.error(e)}}lc(),ls.exports=we;var Dd=ls.exports,uc,Bi=Dd;uc=Bi.createRoot,Bi.hydrateRoot;function Md(e){const t={};for(const n of e)t[n.queue]=n;return t}function Id(){const[e,t]=W.useState({byQueue:{},queues:[],connected:!1});return W.useEffect(()=>{const n=new EventSource("/api/stream");return n.onopen=()=>t(r=>({...r,connected:!0})),n.onerror=()=>t(r=>({...r,connected:!1})),n.onmessage=r=>{const l=JSON.parse(r.data);t({byQueue:Md(l),queues:l.map(u=>u.queue),connected:!0})},()=>n.close()},[]),e}function Fd(e,t){const n=(t.t-e.t)/1e3;if(n<=0)return 0;const r=t.value-e.value;return r<0?0:r/n}function Qi(e,t,n){const r=[...e,t];return r.length>n?r.slice(r.length-n):r}async function Wl(e,t=50,n=0){const r=await fetch(`/api/queues/${encodeURIComponent(e)}/dlq?limit=${t}&offset=${n}`);if(!r.ok)throw new Error(`list dlq: ${r.status}`);return r.json()}async function $d(e,t){const n=await fetch(`/api/queues/${encodeURIComponent(e)}/dlq/${encodeURIComponent(t)}/requeue`,{method:"POST"});if(!n.ok)throw new Error(`requeue: ${n.status}`)}async function Ud(e,t){const n=await fetch(`/api/queues/${encodeURIComponent(e)}/jobs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok)throw new Error(`enqueue: ${n.status}`);return n.json()}function oc(e){return e<1e3?String(e):e<1e6?Hi(e/1e3)+"k":Hi(e/1e6)+"M"}function Hi(e){return e.toFixed(1).replace(/\.0$/,"")}function Ad(e){const t=Math.floor(e/1e3);if(t<60)return`${t}s`;const n=Math.floor(t/60);return n<60?`${n}m`:`${Math.floor(n/60)}h`}function Vd({queues:e,byQueue:t,selected:n,connected:r,onSelect:l,onEnqueueClick:u}){return g.jsxs("aside",{className:"side",children:[g.jsxs("div",{children:[g.jsxs("div",{className:"brand",children:["Relay",g.jsx("span",{className:"dotacc",children:"."})]}),g.jsx("div",{className:"brand-sub",children:"task queue"})]}),g.jsx("div",{className:"qlabel",children:"Queues"}),e.length===0&&g.jsx("div",{className:"q-empty",children:"no queues yet"}),e.map(o=>{var i;return g.jsxs("div",{className:"q"+(o===n?" active":""),onClick:()=>l(o),children:[g.jsx("span",{className:"nm",children:o}),g.jsx("span",{className:"ct",children:oc(((i=t[o])==null?void 0:i.ready)??0)})]},o)}),g.jsxs("div",{className:"side-foot",children:[g.jsx("button",{className:"enq",onClick:u,children:"+ Enqueue a job"}),g.jsxs("div",{className:"conn",children:[g.jsx("span",{className:"live"+(r?"":" off")})," ",r?"live · 1s":"offline"]})]})]})}const Bd=[{key:"ready",label:"Ready",desc:"claimable now",alert:!1},{key:"inflight",label:"In-flight",desc:"being processed",alert:!1},{key:"delayed",label:"Delayed",desc:"scheduled / backoff",alert:!1},{key:"dlq",label:"Dead-letter",desc:"needs attention",alert:!0}];function Qd({snap:e}){return g.jsx("div",{className:"tiles",children:Bd.map(t=>g.jsxs("div",{className:"tile"+(t.alert?" alert":""),children:[g.jsx("div",{className:"k",children:t.label}),g.jsx("div",{className:"v",children:oc(e?e[t.key]:0)}),g.jsx("div",{className:"d",children:t.desc})]},t.key))})}function Wi({data:e,stroke:t,fill:n,height:r=86}){if(e.length<2)return g.jsx("svg",{className:"spark",viewBox:`0 0 320 ${r}`,preserveAspectRatio:"none"});const u=Math.max(...e,1),o=Math.min(...e,0),i=u-o||1,s=320/(e.length-1),h=e.map((m,p)=>{const w=p*s,S=r-(m-o)/i*(r-6)-3;return`${w.toFixed(1)},${S.toFixed(1)}`}).join(" ");return g.jsxs("svg",{className:"spark",viewBox:`0 0 320 ${r}`,preserveAspectRatio:"none",children:[n&&g.jsx("polyline",{fill:n,stroke:"none",points:`0,${r} ${h} 320,${r}`}),g.jsx("polyline",{fill:"none",stroke:t,strokeWidth:2,points:h})]})}function Hd({depth:e,throughput:t}){return g.jsxs("div",{className:"charts",children:[g.jsxs("div",{className:"panel",children:[g.jsxs("div",{className:"cap",children:[g.jsx("h3",{children:"Queue depth"}),g.jsx("span",{className:"now",children:"ready · last 60s"})]}),g.jsx(Wi,{data:e,stroke:"#d2603f",fill:"rgba(210,96,63,.12)"})]}),g.jsxs("div",{className:"panel",children:[g.jsxs("div",{className:"cap",children:[g.jsx("h3",{children:"Throughput"}),g.jsx("span",{className:"now",children:"processed / s"})]}),g.jsx(Wi,{data:t,stroke:"#cbb48e"})]})]})}function Wd(e){return e.length>12?e.slice(0,4)+"…"+e.slice(-4):e}function Kd(e){return e.length>48?e.slice(0,48)+"…":e}function Yd({jobs:e,onRequeue:t}){return g.jsxs("div",{className:"dlq",children:[g.jsxs("div",{className:"cap",children:[g.jsx("h3",{children:"Dead-letter queue"}),g.jsxs("span",{className:"sub",children:[e.length," job",e.length===1?"":"s"," · exhausted retries"]})]}),g.jsxs("table",{children:[g.jsx("thead",{children:g.jsxs("tr",{children:[g.jsx("th",{children:"Job ID"}),g.jsx("th",{children:"Attempts"}),g.jsx("th",{children:"Payload"}),g.jsx("th",{children:"Age"}),g.jsx("th",{})]})}),g.jsxs("tbody",{children:[e.length===0&&g.jsx("tr",{children:g.jsx("td",{colSpan:5,className:"empty",children:"No dead-lettered jobs"})}),e.map(n=>g.jsxs("tr",{children:[g.jsx("td",{className:"id",children:Wd(n.id)}),g.jsxs("td",{className:"att",children:[n.attempts,"/",n.max_retries]}),g.jsx("td",{children:Kd(n.payload)}),g.jsx("td",{className:"age",children:Ad(Date.now()-Date.parse(n.created_at))}),g.jsx("td",{children:g.jsx("button",{className:"requeue",onClick:()=>t(n.id),children:"Requeue"})})]},n.id))]})]})]})}function Xd({queue:e,onClose:t,onEnqueued:n}){const[r,l]=W.useState('{"hello":"world"}'),[u,o]=W.useState(""),[i,s]=W.useState(""),[f,h]=W.useState(""),[m,p]=W.useState(!1),[w,S]=W.useState(""),k=async j=>{j.preventDefault(),p(!0),S("");const c={payload:r};u.trim()!==""&&(c.priority=Number(u)),i.trim()!==""&&(c.delay_ms=Number(i)),f.trim()!==""&&(c.idempotency_key=f.trim());try{await Ud(e,c),n(),t()}catch(a){S(String(a)),p(!1)}};return g.jsx("div",{className:"modal-backdrop",onClick:t,children:g.jsxs("form",{className:"modal",onClick:j=>j.stopPropagation(),onSubmit:k,children:[g.jsxs("h3",{children:["Enqueue to ",g.jsx("span",{className:"modal-q",children:e})]}),g.jsxs("label",{children:["Payload",g.jsx("textarea",{value:r,onChange:j=>l(j.target.value),rows:3})]}),g.jsxs("div",{className:"modal-row",children:[g.jsxs("label",{children:["Priority",g.jsx("input",{value:u,onChange:j=>o(j.target.value),placeholder:"0",inputMode:"numeric"})]}),g.jsxs("label",{children:["Delay (ms)",g.jsx("input",{value:i,onChange:j=>s(j.target.value),placeholder:"0",inputMode:"numeric"})]})]}),g.jsxs("label",{children:["Idempotency key",g.jsx("input",{value:f,onChange:j=>h(j.target.value),placeholder:"(optional)"})]}),w&&g.jsx("div",{className:"modal-err",children:w}),g.jsxs("div",{className:"modal-actions",children:[g.jsx("button",{type:"button",className:"btn-ghost",onClick:t,children:"Cancel"}),g.jsx("button",{type:"submit",className:"btn-accent",disabled:m,children:m?"Enqueuing…":"Enqueue"})]})]})})}const Ki=60;function Gd(){const{byQueue:e,queues:t,connected:n}=Id(),[r,l]=W.useState(""),[u,o]=W.useState([]),[i,s]=W.useState([]),[f,h]=W.useState([]),[m,p]=W.useState(!1),w=W.useRef(null);W.useEffect(()=>{r===""&&t.length>0&&l(t[0])},[t,r]),W.useEffect(()=>{o([]),s([]),w.current=null},[r]);const S=r?e[r]:void 0;W.useEffect(()=>{if(!S)return;o(a=>Qi(a,S.ready,Ki));const c={value:S.processed_total,t:Date.now()};if(w.current){const a=Fd(w.current,c);s(d=>Qi(d,a,Ki))}w.current=c},[S]),W.useEffect(()=>{if(!r)return;let c=!0;const a=()=>{Wl(r).then(v=>{c&&h(v)}).catch(()=>{c&&h([])})};a();const d=setInterval(a,5e3);return()=>{c=!1,clearInterval(d)}},[r]);const k=async c=>{r&&(await $d(r,c),Wl(r).then(h).catch(()=>{}))},j=()=>{r&&Wl(r).then(h).catch(()=>{})};return g.jsxs("div",{className:"app",children:[g.jsx(Vd,{queues:t,byQueue:e,selected:r,connected:n,onSelect:l,onEnqueueClick:()=>p(!0)}),g.jsxs("main",{className:"main",children:[g.jsx("div",{className:"crumb",children:"queue"}),g.jsx("div",{className:"h1",children:r||"—"}),g.jsx("div",{className:"updated",children:n?"live · auto every 1s":"reconnecting…"}),g.jsx("div",{className:"hr"}),g.jsx(Qd,{snap:S}),g.jsx(Hd,{depth:u,throughput:i}),g.jsx(Yd,{jobs:f,onRequeue:k})]}),m&&r&&g.jsx(Xd,{queue:r,onClose:()=>p(!1),onEnqueued:j})]})}uc(document.getElementById("root")).render(g.jsx(W.StrictMode,{children:g.jsx(Gd,{})})); diff --git a/web/dist/assets/index-IEVfHwCz.css b/web/dist/assets/index-IEVfHwCz.css new file mode 100644 index 0000000..3b1eb3b --- /dev/null +++ b/web/dist/assets/index-IEVfHwCz.css @@ -0,0 +1 @@ +@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:400;src:url(./fraunces-vietnamese-400-normal-CvGt0Ybw.woff2) format("woff2"),url(./fraunces-vietnamese-400-normal-B65MOf9T.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:400;src:url(./fraunces-latin-ext-400-normal-D8gbi3Gu.woff2) format("woff2"),url(./fraunces-latin-ext-400-normal-UihxqfOe.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:400;src:url(./fraunces-latin-400-normal-6IfK1voy.woff2) format("woff2"),url(./fraunces-latin-400-normal-NUPT2cO8.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:500;src:url(./fraunces-vietnamese-500-normal-GOH_-EGq.woff2) format("woff2"),url(./fraunces-vietnamese-500-normal-B-KbxExq.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:500;src:url(./fraunces-latin-ext-500-normal-Z5DV8IzT.woff2) format("woff2"),url(./fraunces-latin-ext-500-normal-BMcFk1Xs.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:500;src:url(./fraunces-latin-500-normal-DnGCNyPD.woff2) format("woff2"),url(./fraunces-latin-500-normal-BTR4KCeb.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:600;src:url(./fraunces-vietnamese-600-normal-BjlAJixd.woff2) format("woff2"),url(./fraunces-vietnamese-600-normal-DlAl5EAR.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:600;src:url(./fraunces-latin-ext-600-normal-BtzmzP0X.woff2) format("woff2"),url(./fraunces-latin-ext-600-normal-B0Dy4lqi.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Fraunces;font-style:normal;font-display:swap;font-weight:600;src:url(./fraunces-latin-600-normal-BFCDtZfi.woff2) format("woff2"),url(./fraunces-latin-600-normal-DL5QCzvS.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-greek-400-normal-_efipK4i.woff2) format("woff2"),url(./ibm-plex-sans-greek-400-normal-D9ESIMu3.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2) format("woff2"),url(./ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2) format("woff2"),url(./ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-sans-latin-400-normal-CDDApCn2.woff2) format("woff2"),url(./ibm-plex-sans-latin-400-normal-CYLoc0-x.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-greek-500-normal-JMMifIXV.woff2) format("woff2"),url(./ibm-plex-sans-greek-500-normal-CuWXN6rf.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2) format("woff2"),url(./ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2) format("woff2"),url(./ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-sans-latin-500-normal-6ng42L7E.woff2) format("woff2"),url(./ibm-plex-sans-latin-500-normal-BgVn5rGT.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2) format("woff2"),url(./ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2) format("woff2"),url(./ibm-plex-sans-greek-600-normal-D-CqTdkO.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2) format("woff2"),url(./ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2) format("woff2"),url(./ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2) format("woff2"),url(./ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2) format("woff2"),url(./ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2) format("woff2"),url(./ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2) format("woff2"),url(./ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2) format("woff2"),url(./ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(./ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2) format("woff2"),url(./ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2) format("woff2"),url(./ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2) format("woff2"),url(./ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2) format("woff2"),url(./ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2) format("woff2"),url(./ibm-plex-mono-latin-500-normal-CB9ihrfo.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--bg: #15120e;--panel: #1c1813;--panel-2: #211c16;--line: #2e271e;--ink: #ece3d4;--muted: #9a8f7c;--faint: #6f6757;--accent: #d2603f;--accent-soft: rgba(210, 96, 63, .14);--serif: "Fraunces", Georgia, serif;--sans: "IBM Plex Sans", system-ui, sans-serif;--mono: "IBM Plex Mono", monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px}.app{display:grid;grid-template-columns:236px 1fr;min-height:100vh;max-width:1180px;margin:0 auto}.side{border-right:1px solid var(--line);padding:26px 20px;display:flex;flex-direction:column}.brand{font-family:var(--serif);font-weight:600;font-size:26px;letter-spacing:-.01em}.brand .dotacc{color:var(--accent)}.brand-sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint);margin-top:4px}.qlabel{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);margin:32px 0 10px}.q{display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-radius:9px;color:var(--muted);cursor:pointer;margin-bottom:2px}.q:hover{background:var(--panel)}.q.active{background:var(--panel-2);color:var(--ink);box-shadow:inset 2px 0 0 var(--accent)}.q .nm{font-family:var(--serif);font-size:16px}.q .ct{font-family:var(--mono);font-size:11px;color:var(--faint)}.q.active .ct{color:var(--accent)}.q-empty{color:var(--faint);font-size:12px;padding:8px 12px}.side-foot{margin-top:auto}.enq{width:100%;font-family:var(--sans);font-weight:500;font-size:13px;color:var(--ink);background:transparent;border:1px solid var(--line);border-radius:9px;padding:10px;cursor:pointer}.enq:hover{border-color:var(--accent);color:var(--accent)}.conn{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-top:16px;display:flex;align-items:center;gap:7px}.live{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent)}.live.off{background:var(--faint);box-shadow:none}.main{padding:28px 34px}.crumb{font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--faint)}.h1{font-family:var(--serif);font-weight:500;font-size:34px;letter-spacing:-.015em;margin:6px 0 2px}.updated{font-family:var(--mono);font-size:11px;color:var(--muted)}.hr{height:1px;background:var(--line);margin:20px 0 22px}.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.tile{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 16px 14px}.tile .k{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}.tile .v{font-family:var(--serif);font-weight:600;font-size:34px;line-height:1.05;margin-top:8px}.tile .d{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-top:4px}.tile.alert{border-color:#d2603f66;background:linear-gradient(var(--accent-soft),transparent),var(--panel)}.tile.alert .v{color:var(--accent)}.charts{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px}.panel .cap{display:flex;justify-content:space-between;align-items:baseline}.panel .cap h3{font-family:var(--serif);font-weight:500;font-size:17px;margin:0}.panel .cap .now{font-family:var(--mono);font-size:11px;color:var(--muted)}.spark{width:100%;height:86px;display:block;margin-top:10px}.dlq{margin-top:18px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}.dlq .cap{display:flex;justify-content:space-between;align-items:baseline;padding:15px 18px 12px}.dlq .cap h3{font-family:var(--serif);font-weight:500;font-size:17px;margin:0}.dlq .cap .sub{font-family:var(--mono);font-size:11px;color:var(--muted)}table{width:100%;border-collapse:collapse}th{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);text-align:left;padding:8px 18px;border-top:1px solid var(--line);font-weight:400}td{padding:12px 18px;border-top:1px solid var(--line);font-size:13px;color:var(--ink)}td.id{font-family:var(--mono);font-size:12px;color:var(--muted)}td.att{font-family:var(--mono)}td.age{font-family:var(--mono);color:var(--muted)}td.empty{color:var(--faint);text-align:center;padding:22px;font-family:var(--mono);font-size:12px}.requeue{font-family:var(--sans);font-weight:500;font-size:12px;color:var(--accent);background:transparent;border:1px solid rgba(210,96,63,.45);border-radius:7px;padding:5px 12px;cursor:pointer}.requeue:hover{background:var(--accent);color:#1b120e}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#0000008c;display:flex;align-items:center;justify-content:center}.modal{background:var(--panel-2);border:1px solid var(--line);border-radius:14px;padding:22px 24px;width:420px;max-width:92vw}.modal h3{font-family:var(--serif);font-weight:500;font-size:20px;margin:0 0 16px}.modal .modal-q{color:var(--accent)}.modal label{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:14px}.modal textarea,.modal input{width:100%;margin-top:6px;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:var(--mono);font-size:13px;padding:9px 10px}.modal textarea:focus,.modal input:focus{outline:none;border-color:var(--accent)}.modal-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.modal-err{color:var(--accent);font-size:12px;margin-bottom:12px}.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}.btn-ghost{background:transparent;border:1px solid var(--line);color:var(--muted);border-radius:8px;padding:8px 14px;cursor:pointer;font-family:var(--sans);font-size:13px}.btn-accent{background:var(--accent);border:1px solid var(--accent);color:#1b120e;border-radius:8px;padding:8px 16px;cursor:pointer;font-family:var(--sans);font-weight:600;font-size:13px}.btn-accent:disabled{opacity:.6;cursor:default} diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..0212413 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Relay + + + + +
+ + diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..1be6031 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,42 @@ +// Package web embeds the built dashboard (web/dist) and serves it with an SPA +// fallback. The Vite build output is committed so `go build` needs no Node step. +package web + +import ( + "embed" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed all:dist +var dist embed.FS + +// assets returns the embedded files rooted at dist/. +func assets() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + panic("web: embed dist subtree: " + err.Error()) + } + return sub +} + +// Handler serves the dashboard. Real asset paths are served directly; any other +// path falls back to index.html so client-side routing works (single-page app). +func Handler() http.Handler { + root := assets() + fileServer := http.FileServerFS(root) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clean := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if clean == "." || clean == "" { + clean = "index.html" + } + if _, err := fs.Stat(root, clean); err != nil { + // Not a real asset — serve the SPA shell. + r = r.Clone(r.Context()) + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/web/handler_test.go b/web/handler_test.go new file mode 100644 index 0000000..45a1749 --- /dev/null +++ b/web/handler_test.go @@ -0,0 +1,42 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/StrangeNoob/relay/web" +) + +func TestHandlerServesIndex(t *testing.T) { + srv := httptest.NewServer(web.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } +} + +func TestHandlerSpaFallback(t *testing.T) { + srv := httptest.NewServer(web.Handler()) + defer srv.Close() + + // A client-side route that is not a real asset must still return index.html (200). + resp, err := http.Get(srv.URL + "/queues/emails") + if err != nil { + t.Fatalf("GET /queues/emails: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 (SPA fallback)", resp.StatusCode) + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f717541 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Relay + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..c882832 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2165 @@ +{ + "name": "relay-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "relay-dashboard", + "version": "0.0.0", + "dependencies": { + "@fontsource/fraunces": "^5.0.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "@fontsource/ibm-plex-sans": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/fraunces": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.2.9.tgz", + "integrity": "sha512-XDzuddBtoC7BZgZdBn6b7hsFZY2+V1hgN7yca5fBTKuHjb/lOd45a0Ji8dTUgFhPoL7RdGupo+bC2BFSt6UH8Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..69d6adb --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "relay-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/fraunces": "^5.0.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "@fontsource/ibm-plex-sans": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..7a951ac --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import { useStream } from "./hooks/useStream"; +import { ratePerSecond, pushSample, type Sample } from "./lib/series"; +import { listDlq, requeue, type DlqJob } from "./api"; +import { Sidebar } from "./components/Sidebar"; +import { StatTiles } from "./components/StatTiles"; +import { Charts } from "./components/Charts"; +import { DlqTable } from "./components/DlqTable"; +import { EnqueueForm } from "./components/EnqueueForm"; + +const WINDOW = 60; + +export function App() { + const { byQueue, queues, connected } = useStream(); + const [selected, setSelected] = useState(""); + const [depth, setDepth] = useState([]); + const [throughput, setThroughput] = useState([]); + const [dlq, setDlq] = useState([]); + const [showEnqueue, setShowEnqueue] = useState(false); + const prevSample = useRef(null); + + // Default the selection to the first queue once queues arrive. + useEffect(() => { + if (selected === "" && queues.length > 0) setSelected(queues[0]); + }, [queues, selected]); + + // Reset the rolling windows when switching queues (defined BEFORE the append + // effect so on a queue-change render it clears before the new sample is added). + useEffect(() => { + setDepth([]); + setThroughput([]); + prevSample.current = null; + }, [selected]); + + const snap = selected ? byQueue[selected] : undefined; + + // On each new snapshot for the selected queue, extend the rolling windows. + useEffect(() => { + if (!snap) return; + setDepth((d) => pushSample(d, snap.ready, WINDOW)); + const cur: Sample = { value: snap.processed_total, t: Date.now() }; + if (prevSample.current) { + const rate = ratePerSecond(prevSample.current, cur); + setThroughput((t) => pushSample(t, rate, WINDOW)); + } + prevSample.current = cur; + }, [snap]); + + // Load the DLQ for the selected queue, refreshed on a slow timer. + useEffect(() => { + if (!selected) return; + let alive = true; + const refresh = () => { + listDlq(selected).then((j) => { if (alive) setDlq(j); }).catch(() => { if (alive) setDlq([]); }); + }; + refresh(); + const id = setInterval(refresh, 5000); + return () => { alive = false; clearInterval(id); }; + }, [selected]); + + const onRequeue = async (id: string) => { + if (!selected) return; + await requeue(selected, id); + listDlq(selected).then(setDlq).catch(() => {}); + }; + + const onEnqueued = () => { + if (selected) listDlq(selected).then(setDlq).catch(() => {}); + }; + + return ( +
+ setShowEnqueue(true)} + /> +
+
queue
+
{selected || "—"}
+
{connected ? "live · auto every 1s" : "reconnecting…"}
+
+ + + +
+ {showEnqueue && selected && ( + setShowEnqueue(false)} onEnqueued={onEnqueued} /> + )} +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..0249b5a --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,44 @@ +// REST helpers for the Relay API. The dashboard is served by the same origin as +// the API, so all paths are relative. + +export interface DlqJob { + id: string; + queue: string; + payload: string; + state: string; + attempts: number; + max_retries: number; + priority: number; + created_at: string; + idempotency_key?: string; +} + +export interface EnqueueRequest { + payload: string; + delay_ms?: number; + priority?: number; + idempotency_key?: string; +} + +export async function listDlq(queue: string, limit = 50, offset = 0): Promise { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/dlq?limit=${limit}&offset=${offset}`); + if (!r.ok) throw new Error(`list dlq: ${r.status}`); + return r.json() as Promise; +} + +export async function requeue(queue: string, id: string): Promise { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/dlq/${encodeURIComponent(id)}/requeue`, { + method: "POST", + }); + if (!r.ok) throw new Error(`requeue: ${r.status}`); +} + +export async function enqueue(queue: string, body: EnqueueRequest): Promise<{ id: string; state: string }> { + const r = await fetch(`/api/queues/${encodeURIComponent(queue)}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`enqueue: ${r.status}`); + return r.json() as Promise<{ id: string; state: string }>; +} diff --git a/web/src/components/Charts.tsx b/web/src/components/Charts.tsx new file mode 100644 index 0000000..5c3da1a --- /dev/null +++ b/web/src/components/Charts.tsx @@ -0,0 +1,18 @@ +import { Sparkline } from "./Sparkline"; + +interface ChartsProps { depth: number[]; throughput: number[]; } + +export function Charts({ depth, throughput }: ChartsProps) { + return ( +
+
+

Queue depth

ready · last 60s
+ +
+
+

Throughput

processed / s
+ +
+
+ ); +} diff --git a/web/src/components/DlqTable.tsx b/web/src/components/DlqTable.tsx new file mode 100644 index 0000000..bb17794 --- /dev/null +++ b/web/src/components/DlqTable.tsx @@ -0,0 +1,37 @@ +import { type DlqJob } from "../api"; +import { formatAge } from "../lib/format"; + +interface DlqTableProps { jobs: DlqJob[]; onRequeue: (id: string) => void; } + +function shortId(id: string): string { + return id.length > 12 ? id.slice(0, 4) + "…" + id.slice(-4) : id; +} +function preview(payload: string): string { + return payload.length > 48 ? payload.slice(0, 48) + "…" : payload; +} + +export function DlqTable({ jobs, onRequeue }: DlqTableProps) { + return ( +
+
+

Dead-letter queue

+ {jobs.length} job{jobs.length === 1 ? "" : "s"} · exhausted retries +
+ + + + {jobs.length === 0 && ()} + {jobs.map((j) => ( + + + + + + + + ))} + +
Job IDAttemptsPayloadAge
No dead-lettered jobs
{shortId(j.id)}{j.attempts}/{j.max_retries}{preview(j.payload)}{formatAge(Date.now() - Date.parse(j.created_at))}
+
+ ); +} diff --git a/web/src/components/EnqueueForm.tsx b/web/src/components/EnqueueForm.tsx new file mode 100644 index 0000000..c88ee26 --- /dev/null +++ b/web/src/components/EnqueueForm.tsx @@ -0,0 +1,54 @@ +import { useState, type FormEvent } from "react"; +import { enqueue, type EnqueueRequest } from "../api"; + +interface EnqueueFormProps { + queue: string; + onClose: () => void; + onEnqueued: () => void; +} + +export function EnqueueForm({ queue, onClose, onEnqueued }: EnqueueFormProps) { + const [payload, setPayload] = useState('{"hello":"world"}'); + const [priority, setPriority] = useState(""); + const [delayMs, setDelayMs] = useState(""); + const [key, setKey] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setBusy(true); + setErr(""); + const body: EnqueueRequest = { payload }; + if (priority.trim() !== "") body.priority = Number(priority); + if (delayMs.trim() !== "") body.delay_ms = Number(delayMs); + if (key.trim() !== "") body.idempotency_key = key.trim(); + try { + await enqueue(queue, body); + onEnqueued(); + onClose(); + } catch (e2) { + setErr(String(e2)); + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} onSubmit={submit}> +

Enqueue to {queue}

+