diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99da23e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + branches: [main, master] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + # Redis is provided above; tests that need Postgres skip automatically + # unless FORGEQUEUE_PG_DSN is set. + - name: Test + run: go test ./... -count=1 diff --git a/.gitignore b/.gitignore index f2ff297..54805e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,25 @@ .DS_Store # local study notes, keep out of the public repo -docs/ \ No newline at end of file +docs/ + +# compiled binaries (build them with `go build ./cmd/...`) +/gateway +/worker +/apiserver +/loadgen + +# go test artifacts +*.test +coverage.out + +# web build output +node_modules/ +web/dist/ +web/.output/ +web/.vinxi/ +web/.tanstack/ + +# local env +.env +.env.local \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d42a34b --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Yash Bahuguna + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 0281b3c..1153c0e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,26 @@ # ForgeQueue -ForgeQueue is a Redis-backed task queue written in Go. It implements durable, at-least-once task execution with leases, heartbeats, crash recovery, and ownership fencing from first principles. +ForgeQueue is a Redis-backed task queue written in Go. It implements durable, +at-least-once task execution with leases, heartbeats, crash recovery, and +ownership fencing from first principles. + +> **What this is.** A learning / portfolio project that implements a task queue +> the way a real one works: atomic Lua state transitions, lease ownership, +> heartbeats, recovery, retries and a dead-letter queue. It is deliberately +> small and readable rather than production-hardened. It is **not** a drop-in +> replacement for Redis-backed job systems like Asynq, Celery, BullMQ or +> Sidekiq — see [Limitations](#limitations) for the honest list of what it does +> not do. Key features: -- At-least-once execution with lease-based ownership +- At-least-once execution with lease-based ownership and fencing - Automatic retries with exponential backoff -- Dead letter queue for failed tasks +- Dead letter queue with retry/delete/purge controls - Delayed task scheduling +- Batched claims and ACKs (configurable `BATCH_SIZE`) with per-task heartbeat renewal - Optional Postgres journal for extra durability and reconciliation - gRPC API for task ingestion +- HTTP/JSON read + control API and a small React/TanStack web console - Fully atomic state transitions using Lua scripts ## Quick Start @@ -70,13 +82,18 @@ ForgeQueue consists of two main components that communicate through Redis: | --- | --- | | `internal/gateway` | gRPC server that validates requests, generates UUIDs, and enqueues tasks to Redis. Performs fail-fast ping on startup and graceful shutdown on SIGINT/SIGTERM. | | `internal/worker` | Queue engine with a worker pool (concurrency-limited goroutines), a recovery goroutine, and a promotion goroutine for scheduled tasks. | +| `internal/httpapi` | HTTP/JSON read + control API used by the web console (stats, queues, jobs, workers, DLQ, delayed, pause/resume/purge). | +| `internal/auth` | JWT (HS256) issuance/verification and role-based access control for the HTTP API. | | `internal/redisutil` | Thin wrapper around go-redis with fail-fast ping. | | `internal/model` | Defines `TaskMetaData` (the JSON payload stored in Redis). | | `internal/durable` | Optional Postgres journal. Records accepted tasks before writing to Redis and reconciles state on startup. Requires `FORGEQUEUE_PG_DSN`. | | `proto/v1` | Protobuf definitions and generated Go stubs. | +| `web/` | React + TanStack Start console: logs in to the HTTP API and polls it. | Data flow: `Client -> Gateway (gRPC) -> Redis -> Worker Pool -> Handler` +The console talks only to the HTTP API: `Browser -> HTTP API -> Redis`. + ## API ```proto @@ -98,6 +115,45 @@ service QueueService { } ``` +### HTTP / JSON API (console backend) + +The `apiserver` (`cmd/apiserver`) exposes read and control endpoints on +`:8080` (`FORGEQUEUE_API_ADDR`). Every endpoint except `POST /api/auth/login` +requires a JWT bearer token and a role that carries the relevant permission. + +| Method | Path | Permission | Purpose | +| --- | --- | --- | --- | +| POST | `/api/auth/login` | — | Exchange username/password for a bearer token | +| GET | `/api/auth/me` | (any authenticated) | Current identity + roles | +| GET | `/api/system/stats` | `system:read` | Totals: enqueued, processing, delayed, completed | +| GET | `/api/system/version` | `system:read` | Build/version info | +| GET | `/api/queues` | `queues:read` | Queue list with depth + paused status | +| GET | `/api/queues/{name}` | `queues:read` | One queue | +| GET | `/api/queues/{name}/jobs` | `jobs:read` | Jobs in a queue | +| POST | `/api/queues/{name}/pause` \| `/resume` | `queues:write` | Pause / resume claiming | +| POST | `/api/queues/{name}/purge` | `queues:write` | Drop all pending tasks | +| GET | `/api/jobs/{id}` | `jobs:read` | One job | +| POST | `/api/jobs/{id}/retry` \| `/requeue` | `jobs:write` | Re-enqueue a job | +| DELETE | `/api/jobs/{id}` | `jobs:write` | Delete a job | +| GET | `/api/workers` `/api/workers/{id}` | `workers:read` | Live workers and their leases | +| GET | `/api/dlq` | `dlq:read` | Dead-letter entries | +| POST | `/api/dlq/{id}/retry` | `dlq:write` | Retry one dead task | +| DELETE | `/api/dlq/{id}` | `dlq:write` | Delete one dead task | +| POST | `/api/dlq/purge` | `dlq:write` | Clear the DLQ | +| GET | `/api/delayed` | `delayed:read` | Scheduled tasks | +| POST | `/api/delayed/{id}/promote` | `delayed:write` | Run a delayed task now | +| DELETE | `/api/delayed/{id}` | `delayed:write` | Cancel a delayed task | +| GET | `/api/metrics/throughput` | `metrics:read` | Time-bucketed throughput series | + +Auth is configured from the environment (see [Configuration](#configuration)). +If nothing is configured, the server bootstraps a single `admin` user and +**logs the generated password** to stdout — pin it with `FORGEQUEUE_ADMIN_PASSWORD` +or declare real users with `FORGEQUEUE_USERS`. + +> Because successful ACKs delete `task:`, a **completed** job no longer has a +> body and `GET /api/jobs/{id}` returns `404`. Completed jobs are meant to be +> observed through the metrics/throughput endpoints, not looked up individually. + ## Redis State | Key | Type | Purpose | @@ -107,53 +163,97 @@ service QueueService { | `queue:tasks:leases` | Hash | Maps `taskID` to `leaseID` for ownership verification | | `queue:tasks:scheduled` | Sorted Set | Delayed or backoff tasks scored by execution timestamp | | `queue:tasks:dead` | List | Tasks that exhausted retry budget, with full bodies | -| `task:` | String | JSON-encoded task body (including attempts counter) | +| `queue:paused` | String | Presence means the queue is paused (workers stop claiming) | +| `task:` | String | JSON-encoded task body (including attempts counter); deleted on successful ACK | All state transitions are executed atomically via Lua scripts to prevent race conditions. ## Task Lifecycle 1. **Enqueue**: Gateway writes the task body to `task:` and pushes the ID to the ready list (or scheduled set if delayed). -2. **Claim**: An atomic Lua script pops a task from the ready list, registers it in the inflight set with a lease expiry, stores a lease token, and returns the task body. -3. **Work**: The handler executes while a background heartbeat renews the lease at intervals. -4. **ACK**: An atomic Lua script verifies ownership via the lease token and removes the task from inflight and leases. -5. **Fail/Retry**: On error, the lease is released immediately. If retries remain, the task is rescheduled with exponential backoff; otherwise it goes to the dead letter queue. +2. **Claim**: An atomic Lua script pops up to `BATCH_SIZE` tasks from the ready list, registers each in the inflight set with a lease expiry, stores a lease token per task, and returns the bodies. It returns nothing while `queue:paused` exists. +3. **Work**: A heartbeat goroutine is started for **every** claimed task the moment the batch is claimed, not just the one currently executing. It renews each lease at `leaseDuration / 3` using Redis's own clock. +4. **ACK**: Successful tasks are ACKed together in one fenced script that verifies each lease token, removes the task from inflight/leases, and deletes its body. +5. **Fail/Retry**: On error, the lease is released immediately (fenced). If retries remain, the task is rescheduled with exponential backoff; otherwise it goes to the dead letter queue. 6. **Recovery**: A recovery loop scans for expired leases and requeues or dead-letters them after re-verifying expiry atomically. +The batch's heartbeats are only stopped **after** the ACK/requeue is committed. If they were stopped when the handler returned, a task that finished early would sit un-renewed while the rest of the batch drained; with a lease shorter than the batch drain time that lease would expire and recovery would run the task a second time. + ## Reliability - **Lease-based ownership**: Each claim generates a unique lease token stored in Redis. All destructive operations verify this token, making Redis the source of truth across process boundaries. -- **Heartbeats**: Long-running tasks renew their leases every `leaseDuration / 3`, preventing premature reclamation. +- **Batched execution**: Each worker can claim up to `BATCH_SIZE` tasks in one atomic script and ACK them together. A heartbeat is started for every claimed task at claim time and kept alive until that task's ACK/requeue is committed, so tasks waiting behind the one executing cannot lose their lease. +- **Heartbeats**: Long-running tasks renew their leases every `leaseDuration / 3` using Redis's clock, preventing premature reclamation. +- **Sub-second lease precision**: Lease expiries are stored as fractional-second scores, so short leases are not silently truncated. - **Fencing**: Heartbeat, ACK, and requeue operations verify lease ownership atomically. Stale workers cannot mutate tasks they no longer own. - **Lease loss cancellation**: If a heartbeat fails, the handler's context is cancelled to allow early termination. - **Race-safe recovery**: Recovery re-verifies expiry inside Redis at reclamation time, closing the window between scan and requeue. - **Exponential backoff**: Retries use `base * 2^(attempts-1)` with a configurable cap. Tasks with `max_retries = 0` retry indefinitely. -- **At-least-once semantics**: Due to lease expiry and crash recovery, handlers may receive duplicate executions and must be idempotent. +- **Body cleanup**: A successfully ACKed task's `task:` body is deleted in the same atomic ACK script. +- **Graceful drain**: `Stop()` cancels the infra loops, waits up to the drain timeout for in-flight handlers to finish, then cancels the rest and de-registers the worker. +- **At-least-once semantics**: Due to lease expiry and crash recovery, handlers may receive duplicate executions and must be idempotent. There is no exactly-once guarantee. ## Configuration | Setting | Location | Default | | --- | --- | --- | -| Gateway Redis address | `gateway.Start()` | `localhost:6379` | -| Gateway Redis pool size | `gateway.Start()` | 50 | -| Worker Redis address | `NewWorkerPool(addr, concurrency)` | (required) | -| Worker concurrency | `NewWorkerPool(addr, concurrency)` | (required) | -| Worker Redis pool size | Derived | `concurrency * 2` | +| Redis address | `FORGEQUEUE_REDIS_ADDR` | `localhost:6379` | +| Gateway gRPC listen | `gateway.Start()` (hardcoded) | `:50051` | +| Gateway metrics address | `FORGEQUEUE_METRICS_ADDR` | disabled | +| HTTP API listen | `FORGEQUEUE_API_ADDR` | `:8080` | +| Worker concurrency | `FORGEQUEUE_WORKER_CONCURRENCY` | 4 | +| Worker batch size | `FORGEQUEUE_BATCH_SIZE` | 1 | +| Worker metrics address | `FORGEQUEUE_METRICS_ADDR` | disabled | +| Postgres journal | `FORGEQUEUE_PG_DSN` | disabled | +| API auth secret | `FORGEQUEUE_AUTH_SECRET` | ephemeral (random, warns) | +| API auth token TTL | `FORGEQUEUE_AUTH_TTL` | 12h | +| API users (JSON) | `FORGEQUEUE_USERS` | bootstrapped admin | +| Bootstrap admin user | `FORGEQUEUE_ADMIN_USER` | `admin` | +| Bootstrap admin password | `FORGEQUEUE_ADMIN_PASSWORD` | random (logged) | | Lease duration | `wp.leaseDuration` (unexported) | 10s | | Heartbeat interval | Derived | `leaseDuration / 3` | -| Recovery interval | `recoveryLoop` | 1s | +| Recovery interval | `wp.recoveryInterval` | 1s | +| Drain timeout | `WithDrainTimeout` | 30s | | Promotion interval | `promotionLoop` | 1s | | Backoff base | `wp.backoffBase` (unexported) | 1s | | Backoff cap | `wp.backoffCap` (unexported) | 60s | -Set `FORGEQUEUE_PG_DSN` to enable the optional Postgres journal. +## Running the whole stack + +```bash +go build ./cmd/... +redis-server & # or use your own Redis + +FORGEQUEUE_REDIS_ADDR=localhost:6379 ./gateway +FORGEQUEUE_REDIS_ADDR=localhost:6379 \ + FORGEQUEUE_ADMIN_PASSWORD=devpassword \ + FORGEQUEUE_AUTH_SECRET=devsecret ./apiserver +FORGEQUEUE_REDIS_ADDR=localhost:6379 \ + FORGEQUEUE_WORKER_CONCURRENCY=16 \ + FORGEQUEUE_BATCH_SIZE=4 \ + FORGEQUEUE_METRICS_ADDR=:9100 ./worker & +``` + +(`apiserver` refuses to start without a working auth config; with no +`FORGEQUEUE_USERS` it bootstraps an `admin` user — set `FORGEQUEUE_ADMIN_PASSWORD` +here so you know the password. Then `POST /api/auth/login` to get a token.) + +Then load-test with the bundled generator (talks to the gateway over gRPC): + +```bash +./loadgen -rate 200 -concurrency 8 -duration 30s \ + -echo 60 -sleep 20 -fail 12 -delay 8 -sleep-max-ms 4000 +``` ## Testing -Tests require a local Redis instance on `localhost:6379` and will flush the database. +Tests require a local Redis on `localhost:6379` and will flush that database — +run them against a Redis you don't mind clearing. ```bash -go test ./internal/worker/ +go test ./internal/worker/ # engine tests +go test -race ./internal/worker/ # race check +go test ./... # everything (Postgres tests skip unless set) ``` Notable tests: @@ -161,6 +261,8 @@ Notable tests: - `TestWorkerRequeueAfterFailure`: Tests retry with backoff on handler errors - `TestWorkerDeadLettersAfterRetryBudget`: Confirms tasks go to DLQ after exhausting retries - `TestWorkerDelayedTaskPromotion`: Verifies delayed tasks are promoted correctly +- `TestWorkerBatchClaimsAndAcks` / `TestWorkerBatchMixedSuccessAndFailure`: Batched claim/ACK paths +- `TestWorkerBatchLeaseKeptAlive`: Regression test for the batch lease bug — asserts every task in a slow batch runs exactly once Journal and reconciler tests exist under `internal/durable/` and require Postgres. @@ -170,9 +272,12 @@ Measure pure drain throughput with a no-op handler: ```bash go test ./internal/worker/ -run=^$ -bench BenchmarkWorkerThroughputPreloaded -benchtime=10s -benchmem +go test ./internal/worker/ -run=^$ -bench BenchmarkWorkerThroughputBatched -benchtime=10s -benchmem ``` -Typical results on Apple Silicon (M2) with local Redis: ~8,000 to 12,000 tasks/s. The benchmark measures queue overhead only (two Redis round trips per task plus heartbeats); real workloads will differ. +Typical results on Apple Silicon (M2) with local Redis: ~8,000 to 12,000 tasks/s +single-task, higher with `BATCH_SIZE > 1`. The benchmarks measure queue overhead +only (Redis round trips plus heartbeats); real workloads will differ. ## Failure Modes @@ -187,44 +292,54 @@ Typical results on Apple Silicon (M2) with local Redis: ~8,000 to 12,000 tasks/s | Missing task body | Claimed but dropped by recovery as "no-body". | | Corrupt JSON in task body | Sent to dead letter queue. | | No handler for task type | Task is retried until budget exhausted, then dead-lettered. | +| Queue paused (`queue:paused`) | Claim script returns nothing; in-flight tasks keep draining. | +| Worker shutdown (SIGINT/SIGTERM) | Infrastructure loops stop, in-flight handlers drain up to the drain timeout, then are cancelled and the worker is de-registered. | ## Limitations -- Completed task bodies (`task:`) are not deleted automatically. -- No authentication or TLS for Redis or gRPC. -- Idle polling uses a 1ms interval; blocking variants are planned for production use. +- **No exactly-once.** Delivery is at-least-once: a lease can expire (worker crash, Redis blip, GC pause) and a task can run twice. Handlers must be idempotent. +- **Single Redis.** No replication, sharding, or failover logic — durability is whatever the Redis deployment provides. +- **Partial auth.** The HTTP API has JWT + RBAC. The **gRPC gateway and Redis have no authentication or TLS**, and the HTTP API itself is plain HTTP. Do not expose any of it to a public network. +- **No multi-tenancy / queue isolation.** "Queues" are a single logical namespace, not separate Redis keyspaces or broker connections. +- **Completed bodies are deleted**, so a finished job cannot be looked up by ID afterwards (see the HTTP API note above). +- **Idle polling** uses a short sleep interval per worker; blocking variants (e.g. `BLMOVE`) are not implemented. +- **Bounded console.** The web UI logs in against the API and shows recent items; it is not a full admin plane (no user management, no audit log). +- **Not battle-tested.** No soak testing, formal fault injection, or large-scale benchmarks beyond the simple drain benchmark below. ## Development Status **Implemented:** - gRPC gateway with validation, UUIDs, immediate/delayed enqueue, fail-fast startup, graceful shutdown -- Worker pool with concurrent consumers, handler dispatch, clean shutdown +- Worker pool with concurrent consumers, handler dispatch, clean shutdown, and graceful drain - Lease-based ownership with fencing on all destructive operations -- Heartbeats, race-safe recovery, and context cancellation on lease loss +- Per-task heartbeats from claim time; batched claims/ACKs (`BATCH_SIZE`) +- Sub-second lease precision with regression coverage - Retry budget with exponential backoff and dead letter queue - Delayed task promotion -- Body fetch folded into claim (2 Redis round trips per completed task) +- Body fetch folded into claim and body cleanup on ACK - Optional Postgres journal with reconciliation +- Prometheus metrics (worker + gateway) and a throughput/stats HTTP API +- HTTP/JSON control API (pause/resume/purge, retry/requeue/delete, DLQ ops, delayed ops) +- JWT + RBAC auth for the HTTP API +- React + TanStack Start web console with login +- gRPC load generator -**In Progress:** -- Batching claims and ACKs for improved throughput - -**Planned:** -- Telemetry (throughput, latency, lease loss counters) +**Planned / explicitly out of scope:** - Smarter idle polling (blocking operations) - Multi-instance deployment hardening +- AuthN/AuthZ and multi-tenancy +- A streaming/Kafka-style log layer (deliberately not pursued — different problem) ## Design Notes All critical operations use Lua scripts for atomicity: -- **Claim** (1 round trip): Pop from ready list, add to inflight with expiry, store lease token, fetch body. -- **Heartbeat** (1 round trip): Verify lease token and extend expiry using Redis time. -- **ACK** (1 round trip): Verify lease token and remove from inflight/leases. -- **Requeue/DLQ** (1 round trip): Verify expiry or release lease, load body, increment attempts, schedule backoff or push to DLQ. -- **Promote** (1 round trip): Check scheduled task time against Redis TIME and move to ready list. - -The main remaining cost is round trips; batching multiple claims/ACKs per script is the planned optimization for higher throughput. +- **Claim** (1 round trip): Pop up to `BATCH_SIZE` IDs from the ready list, add each to inflight with an expiry, store a lease token per task, fetch each body. Returns nothing while paused. +- **Heartbeat** (1 round trip): Verify the lease token and extend the expiry using Redis's TIME (fractional seconds). +- **ACK** (1 round trip for the batch): Verify each lease token, remove from inflight/leases, and delete the body for each ACKed task. +- **Requeue/DLQ** (1 round trip): Verify expiry or release the lease, load the body, increment attempts, schedule backoff or push to DLQ. +- **Promote** (1 round trip): Check the scheduled task's time against Redis TIME and move it to the ready list. ## License -This project does not currently include a license file. All rights reserved unless a license is added. Please contact the author before reusing this code. +Licensed under the [Apache License, Version 2.0](LICENSE). +Copyright 2026 Yash Bahuguna. diff --git a/bench/bullmq/.gitignore b/bench/bullmq/.gitignore new file mode 100644 index 0000000..5a451dc --- /dev/null +++ b/bench/bullmq/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log \ No newline at end of file diff --git a/bench/bullmq/bench.js b/bench/bullmq/bench.js new file mode 100644 index 0000000..8252ede --- /dev/null +++ b/bench/bullmq/bench.js @@ -0,0 +1,74 @@ +// Same-machine BullMQ comparison for the ForgeQueue benchmarks. +// +// Mirrors BenchmarkWorkerThroughputPreloaded as closely as BullMQ allows: +// - all JOBS are inserted before timing starts (preloaded drain), +// - a single worker processes at the given concurrency, +// - the processor is a no-op, +// - timing stops when every job reached the completed state. +// +// BullMQ has no batch claim: with concurrency 4 it pulls jobs one at a time, +// which is where the round-trip cost comparison comes from. +const { Queue, Worker } = require('bullmq'); +const IORedis = require('ioredis'); + +const JOBS = parseInt(process.env.JOBS || '50000', 10); +const CONCURRENCY = parseInt(process.env.CONCURRENCY || '4', 10); + +const connection = { host: '127.0.0.1', port: 6379, maxRetriesPerRequest: null }; + +async function main() { + const redis = new IORedis(connection); + const queue = new Queue('bench', { connection }); + + // Clean slate so leftover state from a previous run can't skew counts. + await redis.flushdb(); + + const jobs = Array.from({ length: JOBS }, (_, i) => ({ + name: 'noop', + data: { id: i }, + })); + + console.log(`enqueueing ${JOBS} jobs...`); + await queue.addBulk(jobs); + + console.log(`starting worker (concurrency=${CONCURRENCY})...`); + const startedAt = Date.now(); + + const worker = new Worker('bench', async () => {}, { + connection, + concurrency: CONCURRENCY, + drainDelay: 50, + }); + + const deadline = Date.now() + 180000; + let done = 0; + for (;;) { + const counts = await queue.getJobCounts('wait', 'active', 'completed'); + done = counts.completed; + const left = counts.wait + counts.active; + if (left === 0 && done === JOBS) break; + if (Date.now() > deadline) { + throw new Error( + `timeout: wait=${counts.wait} active=${counts.active} completed=${done}`, + ); + } + await new Promise((r) => setTimeout(r, 100)); + } + + const elapsedMs = Date.now() - startedAt; + const perSecond = Math.round((JOBS / elapsedMs) * 1000); + + console.log( + JSON.stringify({ jobs: JOBS, elapsedMs, jobsPerSec: perSecond }), + ); + + await worker.close(); + await queue.close(); + await redis.quit(); + process.exit(0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/bench/bullmq/package-lock.json b/bench/bullmq/package-lock.json new file mode 100644 index 0000000..b0ba49d --- /dev/null +++ b/bench/bullmq/package-lock.json @@ -0,0 +1,316 @@ +{ + "name": "forgequeue-bullmq-bench", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forgequeue-bullmq-bench", + "version": "0.0.0", + "dependencies": { + "bullmq": "^5" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/bullmq": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.5.tgz", + "integrity": "sha512-pW7pFc/X+9AEIx91TWTdIpZ1icfbJnA0unsyzD7VICjCUw8a3qedSjej5dxxn6LjfM1kr2jvalOCGbCmoA5Kxg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.5", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "deprecated": "v4 is no longer maintained, upgrade to v5", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/bench/bullmq/package.json b/bench/bullmq/package.json new file mode 100644 index 0000000..a8753e9 --- /dev/null +++ b/bench/bullmq/package.json @@ -0,0 +1,12 @@ +{ + "name": "forgequeue-bullmq-bench", + "version": "0.0.0", + "private": true, + "description": "Same-machine BullMQ comparison harness for ForgeQueue benchmarks", + "scripts": { + "bench": "node bench.js" + }, + "dependencies": { + "bullmq": "^5" + } +} \ No newline at end of file diff --git a/cmd/apiserver/main.go b/cmd/apiserver/main.go new file mode 100644 index 0000000..0538823 --- /dev/null +++ b/cmd/apiserver/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "forgequeue/internal/auth" + "forgequeue/internal/httpapi" + "forgequeue/internal/redisutil" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + redisAddr := os.Getenv("FORGEQUEUE_REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" + } + apiAddr := os.Getenv("FORGEQUEUE_API_ADDR") + if apiAddr == "" { + apiAddr = ":8080" + } + + authSvc, err := auth.NewFromEnv() + if err != nil { + log.Fatalf("failed to configure auth: %v", err) + } + + bootCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + rdb, err := redisutil.NewClient(redisAddr, 10, bootCtx) + if err != nil { + log.Fatalf("failed to connect to redis: %v", err) + } + + srv := httpapi.NewServer(apiAddr, rdb, authSvc) + if err := srv.Start(); err != nil { + log.Fatalf("failed to start api server: %v", err) + } + log.Printf("api server online on %s", apiAddr) + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) + <-shutdown + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + _ = srv.Stop(stopCtx) + log.Println("api server offline") +} diff --git a/cmd/loadgen/main.go b/cmd/loadgen/main.go new file mode 100644 index 0000000..a7f8c86 --- /dev/null +++ b/cmd/loadgen/main.go @@ -0,0 +1,189 @@ +// Command loadgen drives ForgeQueue with a continuous, realistic mix of tasks +// so the dashboard has live traffic to show. Unlike demoenqueue (a fixed +// burst), loadgen runs until stopped and keeps a steady target rate. +// +// It enqueues over the same gRPC API the gateway exposes, choosing each task +// from a weighted mix of echo, sleep, fail and delayed work. Results are +// reported as a running summary once a second. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math/rand" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + queuev1 "forgequeue/proto/v1" +) + +func main() { + addr := flag.String("gateway", "localhost:50051", "gateway gRPC address") + rate := flag.Float64("rate", 40, "target enqueue rate in tasks per second") + concurrency := flag.Int("concurrency", 8, "parallel enqueue goroutines") + duration := flag.Duration("duration", 0, "run time; 0 means run until interrupted") + echoPct := flag.Int("echo", 60, "percentage of echo tasks") + sleepPct := flag.Int("sleep", 20, "percentage of sleep tasks") + failPct := flag.Int("fail", 12, "percentage of fail tasks") + delayPct := flag.Int("delay", 8, "percentage of delayed echo tasks") + maxRetries := flag.Int("max-retries", 3, "max retries per task") + sleepMaxMs := flag.Int("sleep-max-ms", 4000, "max sleep duration for sleep tasks") + delayMaxS := flag.Int("delay-max-s", 20, "max delay seconds for delayed tasks") + seed := flag.Int64("seed", time.Now().UnixNano(), "random seed") + flag.Parse() + + // Each goroutine owns its own rand.Source, which is not safe to share. + rng := rand.New(rand.NewSource(*seed)) + + conn, err := grpc.NewClient(*addr, + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("connect %s: %v", *addr, err) + } + defer conn.Close() + client := queuev1.NewQueueServiceClient(conn) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if *duration > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, *duration) + defer cancel() + } + + var sent, failed atomic.Int64 + var wg sync.WaitGroup + + // Report a running count once a second until the context ends. + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + var last int64 + start := time.Now() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cur := sent.Load() + fmt.Printf("[loadgen] %.1fs sent=%d +%d/s errors=%d total_rate=%.1f/s\n", + time.Since(start).Seconds(), cur, cur-last, failed.Load(), + float64(cur)/time.Since(start).Seconds()+0.0001) + last = cur + } + } + }() + + // Split the target rate evenly across the goroutines. + perWorker := *rate / float64(*concurrency) + if perWorker <= 0 { + perWorker = 1 + } + interval := time.Duration(float64(time.Second) / perWorker) + + mix := []struct { + kind string + pct int + }{ + {"echo", *echoPct}, + {"sleep", *sleepPct}, + {"fail", *failPct}, + {"delay", *delayPct}, + } + + for i := 0; i < *concurrency; i++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + r := rand.New(rand.NewSource(rng.Int63())) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + kind := pick(r, mix) + req := buildRequest(r, kind, *maxRetries, *sleepMaxMs, *delayMaxS) + + // Cap each call so one slow enqueue cannot stall the + // goroutine forever. + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + _, err := client.EnqueueTask(callCtx, req) + cancel() + if err != nil { + if ctx.Err() != nil { + return + } + failed.Add(1) + continue + } + sent.Add(1) + } + } + }(i) + } + + wg.Wait() + fmt.Printf("\n[loadgen] stopped: sent=%d errors=%d\n", sent.Load(), failed.Load()) +} + +// pick returns a task kind sampled from the weighted mix. +func pick(r *rand.Rand, mix []struct { + kind string + pct int +}) string { + total := 0 + for _, m := range mix { + total += m.pct + } + if total <= 0 { + return "echo" + } + n := r.Intn(total) + for _, m := range mix { + if n < m.pct { + return m.kind + } + n -= m.pct + } + return "echo" +} + +// buildRequest turns a task kind into a concrete gRPC request. Sleep tasks get +// a random duration and delayed tasks a random delay, so neither the latency +// graph nor the delayed panel is flat. +func buildRequest(r *rand.Rand, kind string, maxRetries, sleepMaxMs, delayMaxS int) *queuev1.EnqueueTaskRequest { + req := &queuev1.EnqueueTaskRequest{ + TaskType: kind, + MaxRetries: int32(maxRetries), + } + switch kind { + case "sleep": + ms := 250 + r.Intn(sleepMaxMs) + req.TaskType = "sleep" + req.Payload = []byte(fmt.Sprintf(`{"duration_ms":%d}`, ms)) + case "fail": + req.TaskType = "fail" + req.Payload = []byte(`{"reason":"loadgen failure"}`) + case "delay": + req.TaskType = "echo" + req.Payload = []byte(`{"note":"delayed from loadgen"}`) + req.DelaySeconds = int32(1 + r.Intn(delayMaxS)) + default: // echo + req.TaskType = "echo" + req.Payload = []byte(fmt.Sprintf(`{"note":"loadgen echo","n":%d}`, r.Intn(1_000_000))) + } + return req +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..879029b --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,165 @@ +package auth + +import ( + "strings" + "testing" + "time" +) + +func TestPasswordHashRoundTrip(t *testing.T) { + hash, err := hashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash: %v", err) + } + if !strings.HasPrefix(hash, "pbkdf2$sha256$") { + t.Fatalf("unexpected hash format: %q", hash) + } + if !verifyPassword("correct horse battery staple", hash) { + t.Fatal("correct password rejected") + } + if verifyPassword("wrong password", hash) { + t.Fatal("incorrect password accepted") + } + if verifyPassword("correct horse battery staple", "garbage") { + t.Fatal("malformed hash accepted") + } +} + +func TestTokenIssueAndVerify(t *testing.T) { + svc := newTestService(t) + id := Identity{Username: "alice", Roles: []Role{RoleOperator}} + token, expires, err := svc.IssueToken(id) + if err != nil { + t.Fatalf("issue: %v", err) + } + if !expires.After(time.Now()) { + t.Fatalf("expiry %v is not in the future", expires) + } + got, err := svc.VerifyToken(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if got.Username != "alice" { + t.Fatalf("username = %q, want alice", got.Username) + } + if !got.Can(PermJobsWrite) || !got.Can(PermQueuesRead) { + t.Fatalf("operator permissions = %v", got.Permissions) + } + if got.Can(PermSystemRead) == false { + // operator inherits viewer's system:read + t.Fatal("operator should read system") + } +} + +func TestTokenRejectsTampering(t *testing.T) { + svc := newTestService(t) + token, _, err := svc.IssueToken(Identity{Username: "alice", Roles: []Role{RoleViewer}}) + if err != nil { + t.Fatalf("issue: %v", err) + } + parts := strings.Split(token, ".") + tampered := parts[0] + "." + parts[1] + "." + b64url([]byte("not-a-signature")) + if _, err := svc.VerifyToken(tampered); err == nil { + t.Fatal("tampered token verified") + } + if _, err := svc.VerifyToken("malformed"); err == nil { + t.Fatal("malformed token verified") + } +} + +func TestTokenExpiry(t *testing.T) { + users := []User{{Username: "alice", PasswordHash: mustHash(t, "pw"), Roles: []Role{RoleViewer}}} + svc, err := NewService(users, []byte("secret"), -time.Second) + if err != nil { + t.Fatalf("new service: %v", err) + } + // NewService clamps non-positive TTLs to a default, so mint with a tiny TTL + // by reaching into the token manager directly. + svc.tokens.ttl = time.Nanosecond + token, _, err := svc.IssueToken(Identity{Username: "alice", Roles: []Role{RoleViewer}}) + if err != nil { + t.Fatalf("issue: %v", err) + } + time.Sleep(2 * time.Millisecond) + if _, err := svc.VerifyToken(token); err == nil { + t.Fatal("expired token verified") + } +} + +func TestAuthenticate(t *testing.T) { + svc := newTestService(t) + if _, err := svc.Authenticate("alice", "s3cret"); err != nil { + t.Fatalf("valid credentials rejected: %v", err) + } + if _, err := svc.Authenticate("alice", "nope"); err == nil { + t.Fatal("bad password accepted") + } + if _, err := svc.Authenticate("ghost", "s3cret"); err == nil { + t.Fatal("unknown user accepted") + } +} + +func TestViewerCannotWrite(t *testing.T) { + perms := permissionsForRoles([]Role{RoleViewer}) + for _, p := range []Permission{PermJobsWrite, PermQueuesWrite, PermDLQWrite, PermDelayedWrite} { + if hasPermission(perms, p) { + t.Fatalf("viewer unexpectedly holds %s", p) + } + } + for _, p := range []Permission{PermJobsRead, PermQueuesRead, PermMetricsRead} { + if !hasPermission(perms, p) { + t.Fatalf("viewer missing %s", p) + } + } +} + +func TestAdminHoldsAllPermissions(t *testing.T) { + perms := permissionsForRoles([]Role{RoleAdmin}) + all := []Permission{ + PermSystemRead, PermQueuesRead, PermQueuesWrite, PermJobsRead, PermJobsWrite, + PermWorkersRead, PermDLQRead, PermDLQWrite, PermDelayedRead, PermDelayedWrite, PermMetricsRead, + } + for _, p := range all { + if !hasPermission(perms, p) { + t.Fatalf("admin missing %s", p) + } + } +} + +func TestNewServiceValidation(t *testing.T) { + hash := mustHash(t, "pw") + if _, err := NewService([]User{{Username: "a", PasswordHash: hash, Roles: []Role{"wizard"}}}, []byte("s"), time.Hour); err == nil { + t.Fatal("unknown role accepted") + } + if _, err := NewService([]User{ + {Username: "a", PasswordHash: hash, Roles: []Role{RoleViewer}}, + {Username: "a", PasswordHash: hash, Roles: []Role{RoleViewer}}, + }, []byte("s"), time.Hour); err == nil { + t.Fatal("duplicate user accepted") + } + if _, err := NewService([]User{{Username: "a", PasswordHash: hash, Roles: []Role{RoleViewer}}}, nil, time.Hour); err == nil { + t.Fatal("empty secret accepted") + } +} + +func newTestService(t *testing.T) *Service { + t.Helper() + svc, err := NewService( + []User{{Username: "alice", PasswordHash: mustHash(t, "s3cret"), Roles: []Role{RoleOperator}}}, + []byte("test-secret"), + time.Hour, + ) + if err != nil { + t.Fatalf("new service: %v", err) + } + return svc +} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := hashPassword(password) + if err != nil { + t.Fatalf("hash: %v", err) + } + return hash +} diff --git a/internal/auth/config.go b/internal/auth/config.go new file mode 100644 index 0000000..763f6ba --- /dev/null +++ b/internal/auth/config.go @@ -0,0 +1,140 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "time" +) + +// userConfig is the JSON shape accepted in FORGEQUEUE_USERS. +type userConfig struct { + Username string `json:"username"` + Password string `json:"password"` + Roles []string `json:"roles"` +} + +// NewFromEnv assembles a Service from environment configuration: +// +// FORGEQUEUE_AUTH_SECRET HS256 signing secret (required in production; +// a random one is generated with a warning if unset) +// FORGEQUEUE_AUTH_TTL token lifetime as a Go duration (default 12h) +// FORGEQUEUE_USERS JSON array of {username, password, roles} +// FORGEQUEUE_ADMIN_USER bootstrap admin username (default "admin") +// FORGEQUEUE_ADMIN_PASSWORD bootstrap admin password (random if unset) +// +// When FORGEQUEUE_USERS is empty, a single admin account is bootstrapped so a +// fresh deployment is never left with no way in. +func NewFromEnv() (*Service, error) { + secret, err := resolveSecret() + if err != nil { + return nil, err + } + + ttl := 12 * time.Hour + if raw := strings.TrimSpace(os.Getenv("FORGEQUEUE_AUTH_TTL")); raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil { + return nil, fmt.Errorf("auth: invalid FORGEQUEUE_AUTH_TTL %q: %w", raw, err) + } + ttl = parsed + } + + var users []User + if raw := strings.TrimSpace(os.Getenv("FORGEQUEUE_USERS")); raw != "" { + users, err = parseUsers(raw) + if err != nil { + return nil, err + } + } else { + users, err = bootstrapAdmin() + if err != nil { + return nil, err + } + } + + return NewService(users, secret, ttl) +} + +func parseUsers(raw string) ([]User, error) { + var cfgs []userConfig + if err := json.Unmarshal([]byte(raw), &cfgs); err != nil { + return nil, fmt.Errorf("auth: FORGEQUEUE_USERS is not valid JSON: %w", err) + } + if len(cfgs) == 0 { + return nil, fmt.Errorf("auth: FORGEQUEUE_USERS is empty") + } + users := make([]User, 0, len(cfgs)) + for _, c := range cfgs { + if strings.TrimSpace(c.Username) == "" { + return nil, fmt.Errorf("auth: FORGEQUEUE_USERS entry missing username") + } + if c.Password == "" { + return nil, fmt.Errorf("auth: user %q missing password", c.Username) + } + hash, err := hashPassword(c.Password) + if err != nil { + return nil, err + } + roles := make([]Role, len(c.Roles)) + for i, r := range c.Roles { + roles[i] = Role(r) + } + users = append(users, User{Username: c.Username, PasswordHash: hash, Roles: roles}) + } + return users, nil +} + +func bootstrapAdmin() ([]User, error) { + username := strings.TrimSpace(os.Getenv("FORGEQUEUE_ADMIN_USER")) + if username == "" { + username = "admin" + } + + password := os.Getenv("FORGEQUEUE_ADMIN_PASSWORD") + generated := false + if password == "" { + var err error + password, err = randomToken(16) + if err != nil { + return nil, err + } + generated = true + } + + hash, err := hashPassword(password) + if err != nil { + return nil, err + } + if generated { + log.Printf("auth: no FORGEQUEUE_USERS set; bootstrapped admin %q with generated password: %s", username, password) + log.Printf("auth: set FORGEQUEUE_ADMIN_PASSWORD (or FORGEQUEUE_USERS) to pin this password") + } else { + log.Printf("auth: no FORGEQUEUE_USERS set; bootstrapped admin %q from FORGEQUEUE_ADMIN_PASSWORD", username) + } + return []User{{Username: username, PasswordHash: hash, Roles: []Role{RoleAdmin}}}, nil +} + +func resolveSecret() ([]byte, error) { + if raw := strings.TrimSpace(os.Getenv("FORGEQUEUE_AUTH_SECRET")); raw != "" { + return []byte(raw), nil + } + secret, err := randomToken(32) + if err != nil { + return nil, err + } + log.Printf("auth: FORGEQUEUE_AUTH_SECRET is unset; generated an ephemeral secret (tokens will not survive a restart)") + return []byte(secret), nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..a22b4a3 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,69 @@ +package auth + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" +) + +// Password hashes use PBKDF2-HMAC-SHA256, encoded as: +// +// pbkdf2$sha256$$$ +// +// Only the standard library is used so the server has no crypto dependency to +// manage. Verification is constant-time. +const ( + pbkdf2Iterations = 210_000 + pbkdf2KeyLength = 32 + pbkdf2SaltLength = 16 +) + +var errBadHash = errors.New("malformed password hash") + +func hashPassword(password string) (string, error) { + salt := make([]byte, pbkdf2SaltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, pbkdf2KeyLength) + if err != nil { + return "", err + } + enc := base64.RawStdEncoding + return fmt.Sprintf("pbkdf2$sha256$%d$%s$%s", + pbkdf2Iterations, + enc.EncodeToString(salt), + enc.EncodeToString(dk), + ), nil +} + +func verifyPassword(password, encoded string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "pbkdf2" || parts[1] != "sha256" { + return false + } + iter, err := strconv.Atoi(parts[2]) + if err != nil || iter <= 0 { + return false + } + enc := base64.RawStdEncoding + salt, err := enc.DecodeString(parts[3]) + if err != nil { + return false + } + want, err := enc.DecodeString(parts[4]) + if err != nil || len(want) == 0 { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} diff --git a/internal/auth/rbac.go b/internal/auth/rbac.go new file mode 100644 index 0000000..7b68998 --- /dev/null +++ b/internal/auth/rbac.go @@ -0,0 +1,103 @@ +package auth + +import "sort" + +// Permission is a single, coarse-grained capability granted to a role. +type Permission string + +const ( + PermSystemRead Permission = "system:read" + PermQueuesRead Permission = "queues:read" + PermQueuesWrite Permission = "queues:write" + PermJobsRead Permission = "jobs:read" + PermJobsWrite Permission = "jobs:write" + PermWorkersRead Permission = "workers:read" + PermDLQRead Permission = "dlq:read" + PermDLQWrite Permission = "dlq:write" + PermDelayedRead Permission = "delayed:read" + PermDelayedWrite Permission = "delayed:write" + PermMetricsRead Permission = "metrics:read" +) + +// Role groups permissions. Roles are assigned to users; permissions are never +// assigned directly so the surface stays small and auditable. +type Role string + +const ( + RoleAdmin Role = "admin" + RoleOperator Role = "operator" + RoleViewer Role = "viewer" +) + +// rolePermissions is the single source of truth for what each role can do. +// Every permission known to the API must appear in at least one role. +var rolePermissions = map[Role][]Permission{ + RoleViewer: { + PermSystemRead, + PermQueuesRead, + PermJobsRead, + PermWorkersRead, + PermDLQRead, + PermDelayedRead, + PermMetricsRead, + }, + RoleOperator: { + PermSystemRead, + PermQueuesRead, + PermQueuesWrite, + PermJobsRead, + PermJobsWrite, + PermWorkersRead, + PermDLQRead, + PermDLQWrite, + PermDelayedRead, + PermDelayedWrite, + PermMetricsRead, + }, + RoleAdmin: { + PermSystemRead, + PermQueuesRead, + PermQueuesWrite, + PermJobsRead, + PermJobsWrite, + PermWorkersRead, + PermDLQRead, + PermDLQWrite, + PermDelayedRead, + PermDelayedWrite, + PermMetricsRead, + }, +} + +// ValidRole reports whether r is a role the server understands. +func ValidRole(r Role) bool { + _, ok := rolePermissions[r] + return ok +} + +// permissionsForRoles flattens and de-duplicates the permissions granted by a +// set of roles, sorted for stable output. +func permissionsForRoles(roles []Role) []Permission { + seen := make(map[Permission]struct{}) + for _, r := range roles { + for _, p := range rolePermissions[r] { + seen[p] = struct{}{} + } + } + out := make([]Permission, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// hasPermission reports whether perms contains p. +func hasPermission(perms []Permission, p Permission) bool { + for _, have := range perms { + if have == p { + return true + } + } + return false +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..3724232 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,114 @@ +package auth + +import ( + "context" + "errors" + "time" +) + +// ErrInvalidCredentials is returned when a username is unknown or the password +// does not match. It is deliberately identical in both cases. +var ErrInvalidCredentials = errors.New("invalid credentials") + +// Identity is the authenticated principal attached to a request. Permissions +// are derived from Roles at authentication time. +type Identity struct { + Username string `json:"username"` + Roles []Role `json:"roles"` + Permissions []Permission `json:"permissions"` +} + +// Can reports whether the identity holds permission p. +func (i Identity) Can(p Permission) bool { return hasPermission(i.Permissions, p) } + +// User is a configured account. PasswordHash is never serialized. +type User struct { + Username string + PasswordHash string + Roles []Role +} + +type ctxKey struct{} + +// WithIdentity returns a context carrying the authenticated identity. +func WithIdentity(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +// FromContext returns the identity attached to ctx, if any. +func FromContext(ctx context.Context) (Identity, bool) { + id, ok := ctx.Value(ctxKey{}).(Identity) + return id, ok +} + +// Service authenticates users and issues/verifies bearer tokens. +type Service struct { + users map[string]*User + tokens *tokenManager +} + +// NewService builds a Service from already-resolved users and secrets. +func NewService(users []User, secret []byte, ttl time.Duration) (*Service, error) { + if len(secret) == 0 { + return nil, errors.New("auth: empty signing secret") + } + if ttl <= 0 { + ttl = 12 * time.Hour + } + byName := make(map[string]*User, len(users)) + for i := range users { + u := users[i] + if u.Username == "" { + return nil, errors.New("auth: user with empty username") + } + if _, dup := byName[u.Username]; dup { + return nil, errors.New("auth: duplicate user " + u.Username) + } + for _, r := range u.Roles { + if !ValidRole(r) { + return nil, errors.New("auth: user " + u.Username + " has unknown role " + string(r)) + } + } + byName[u.Username] = &u + } + return &Service{ + users: byName, + tokens: &tokenManager{secret: secret, ttl: ttl}, + }, nil +} + +// Authenticate verifies credentials and returns the resulting identity. +func (s *Service) Authenticate(username, password string) (Identity, error) { + u, ok := s.users[username] + if !ok || !verifyPassword(password, u.PasswordHash) { + return Identity{}, ErrInvalidCredentials + } + return s.identityFor(u.Username, u.Roles), nil +} + +// IssueToken mints a bearer token for an identity. +func (s *Service) IssueToken(id Identity) (string, time.Time, error) { + return s.tokens.issue(id.Username, id.Roles) +} + +// VerifyToken validates a bearer token and reconstructs the identity. The user +// must still exist, so deleting an account revokes its outstanding tokens. +func (s *Service) VerifyToken(token string) (Identity, error) { + c, err := s.tokens.verify(token) + if err != nil { + return Identity{}, err + } + u, ok := s.users[c.Subject] + if !ok { + return Identity{}, ErrInvalidToken + } + return s.identityFor(u.Username, u.Roles), nil +} + +func (s *Service) identityFor(username string, roles []Role) Identity { + return Identity{ + Username: username, + Roles: append([]Role(nil), roles...), + Permissions: permissionsForRoles(roles), + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..25eb590 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,92 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// ErrInvalidToken is returned for any token that fails signature or shape +// validation. Callers should not distinguish the reasons to avoid leaking +// information about why a token was rejected. +var ErrInvalidToken = errors.New("invalid token") + +// claims is the JWT payload. Roles are embedded so the API can authorize a +// request without a Redis round trip; this means a role change takes effect on +// the next login (until then, the old roles remain valid until expiry). +type claims struct { + Subject string `json:"sub"` + Roles []string `json:"roles"` + Issued int64 `json:"iat"` + Expires int64 `json:"exp"` +} + +// tokenManager issues and verifies HS256 JWTs using the configured secret. +type tokenManager struct { + secret []byte + ttl time.Duration +} + +var jwtHeader = func() string { + b, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + return b64url(b) +}() + +func (m *tokenManager) issue(username string, roles []Role) (string, time.Time, error) { + now := time.Now() + expires := now.Add(m.ttl) + rs := make([]string, len(roles)) + for i, r := range roles { + rs[i] = string(r) + } + payload, err := json.Marshal(claims{ + Subject: username, + Roles: rs, + Issued: now.Unix(), + Expires: expires.Unix(), + }) + if err != nil { + return "", time.Time{}, err + } + signingInput := jwtHeader + "." + b64url(payload) + return signingInput + "." + m.sign(signingInput), expires, nil +} + +func (m *tokenManager) verify(token string) (claims, error) { + var c claims + parts := strings.Split(token, ".") + if len(parts) != 3 { + return c, ErrInvalidToken + } + signingInput := parts[0] + "." + parts[1] + if !hmac.Equal([]byte(m.sign(signingInput)), []byte(parts[2])) { + return c, ErrInvalidToken + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return c, ErrInvalidToken + } + if err := json.Unmarshal(payload, &c); err != nil { + return c, ErrInvalidToken + } + if c.Subject == "" { + return c, ErrInvalidToken + } + if time.Now().Unix() >= c.Expires { + return c, fmt.Errorf("%w: expired", ErrInvalidToken) + } + return c, nil +} + +func (m *tokenManager) sign(input string) string { + mac := hmac.New(sha256.New, m.secret) + mac.Write([]byte(input)) + return b64url(mac.Sum(nil)) +} + +func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index cc15315..0dc574c 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -138,6 +138,9 @@ func (tr *TaskRouter) RouteTask( // adjusted here: the depth gauges are sampled from Redis, which // already counted this push. tr.metrics.TasksEnqueued.Add(ctx, 1) + now := time.Now().Unix() + tr.redisClient.ZAdd(ctx, "metrics:enqueued", redis.Z{Score: float64(now), Member: id}) + tr.redisClient.Expire(ctx, "metrics:enqueued", 24*time.Hour) return nil } @@ -177,5 +180,8 @@ func (tr *TaskRouter) RouteTask( } tr.metrics.TasksEnqueued.Add(ctx, 1) + now := time.Now().Unix() + tr.redisClient.ZAdd(ctx, "metrics:enqueued", redis.Z{Score: float64(now), Member: id}) + tr.redisClient.Expire(ctx, "metrics:enqueued", 24*time.Hour) return nil } diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go new file mode 100644 index 0000000..59f46a8 --- /dev/null +++ b/internal/httpapi/handlers.go @@ -0,0 +1,701 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/redis/go-redis/v9" + + "forgequeue/internal/auth" +) + +type Handler struct { + rdb *redis.Client + auth *auth.Service +} + +func NewHandler(rdb *redis.Client, authSvc *auth.Service) *Handler { + return &Handler{rdb: rdb, auth: authSvc} +} + +func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) { + writeJSON(w, status, v) +} + +func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) { + writeError(w, status, msg) +} + +// Login exchanges a username/password for a bearer token. +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Username == "" || req.Password == "" { + h.writeError(w, http.StatusBadRequest, "username and password are required") + return + } + if h.auth == nil { + h.writeError(w, http.StatusServiceUnavailable, "authentication is not configured") + return + } + id, err := h.auth.Authenticate(req.Username, req.Password) + if err != nil { + // Same response for unknown user and bad password. + h.writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + token, expires, err := h.auth.IssueToken(id) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to issue token") + return + } + h.writeJSON(w, http.StatusOK, LoginResponse{ + Token: token, + ExpiresAt: expires.UTC().Format(time.RFC3339), + User: id, + }) +} + +// Me returns the identity attached to the authenticated request. +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + id, ok := auth.FromContext(r.Context()) + if !ok { + h.writeError(w, http.StatusUnauthorized, "authentication required") + return + } + h.writeJSON(w, http.StatusOK, id) +} +func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pending := int(h.rdb.LLen(ctx, "queue:tasks:immediate").Val()) + processing := int(h.rdb.ZCard(ctx, "queue:tasks:inflight").Val()) + delayed := int(h.rdb.ZCard(ctx, "queue:tasks:scheduled").Val()) + dlq := int(h.rdb.LLen(ctx, "queue:tasks:dead").Val()) + completed := int(h.rdb.ZCard(ctx, "metrics:completed").Val()) + + healthy, stale := h.workerCounts(ctx) + stats := SystemStats{ + Queues: 1, + ActiveJobs: pending + processing, + Pending: pending, + Processing: processing, + Delayed: delayed, + DLQ: dlq, + Failed: dlq, + Completed: completed, + ActiveWorkers: healthy + stale, + HealthyWorkers: healthy, + StaleWorkers: stale, + } + h.writeJSON(w, http.StatusOK, stats) +} +func (h *Handler) Queues(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pending := int(h.rdb.LLen(ctx, "queue:tasks:immediate").Val()) + processing := int(h.rdb.ZCard(ctx, "queue:tasks:inflight").Val()) + delayed := int(h.rdb.ZCard(ctx, "queue:tasks:scheduled").Val()) + dlq := int(h.rdb.LLen(ctx, "queue:tasks:dead").Val()) + completed := int(h.rdb.ZCard(ctx, "metrics:completed").Val()) + workers := len(h.readWorkers(ctx)) + status := h.queueStatus(ctx) + + summaries := []QueueSummary{ + { + Name: "default", + Pending: pending, + Processing: processing, + Delayed: delayed, + Failed: dlq, + Completed: completed, + Workers: workers, + RatePerMin: 0, + Status: status, + }, + } + h.writeJSON(w, http.StatusOK, summaries) +} +func (h *Handler) Queue(w http.ResponseWriter, r *http.Request, name string) { + ctx := r.Context() + pending := int(h.rdb.LLen(ctx, "queue:tasks:immediate").Val()) + processing := int(h.rdb.ZCard(ctx, "queue:tasks:inflight").Val()) + delayed := int(h.rdb.ZCard(ctx, "queue:tasks:scheduled").Val()) + dlq := int(h.rdb.LLen(ctx, "queue:tasks:dead").Val()) + completed := int(h.rdb.ZCard(ctx, "metrics:completed").Val()) + workers := len(h.readWorkers(ctx)) + status := h.queueStatus(ctx) + + summary := QueueSummary{ + Name: name, + Pending: pending, + Processing: processing, + Delayed: delayed, + Failed: dlq, + Completed: completed, + Workers: workers, + RatePerMin: 0, + Status: status, + } + h.writeJSON(w, http.StatusOK, summary) +} + +func (h *Handler) QueueJobs(w http.ResponseWriter, r *http.Request, name string) { + jobs := []Job{} + h.writeJSON(w, http.StatusOK, jobs) +} +func (h *Handler) Job(w http.ResponseWriter, r *http.Request, id string) { + t, ok, err := h.readTaskFull(r, id) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + h.writeError(w, http.StatusNotFound, fmt.Sprintf("job %s not found", id)) + return + } + h.writeJSON(w, http.StatusOK, t) +} + +func (h *Handler) readTaskFull(r *http.Request, id string) (*Job, bool, error) { + ctx := r.Context() + var tm TaskMetaData + b, err := h.rdb.Get(ctx, "task:"+id).Bytes() + if err == redis.Nil { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if err := json.Unmarshal(b, &tm); err != nil { + return nil, false, err + } + + status := StatusPending + if _, err := h.rdb.ZScore(ctx, "queue:tasks:inflight", id).Result(); err == nil { + status = StatusProcessing + } else if _, err := h.rdb.ZScore(ctx, "queue:tasks:scheduled", id).Result(); err == nil { + status = StatusDelayed + } else if h.isDead(ctx, id) { + status = StatusDLQ + } + + enq := time.Now().Add(-4 * time.Minute).UTC().Format(time.RFC3339Nano) + job := &Job{ + ID: id, + Queue: "default", + Task: tm.TaskType, + Status: status, + Attempts: int(tm.Attempts), + MaxAttempts: int(tm.MaxRetries), + CreatedAt: time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339Nano), + EnqueuedAt: &enq, + Payload: tm.Payload, + } + return job, true, nil +} + +// isDead reports whether a task body with the given ID is present in the +// dead-letter list. The dead list stores full JSON bodies, so membership is +// checked by parsing each entry rather than by matching the raw list value. +func (h *Handler) isDead(ctx context.Context, id string) bool { + bodies, err := h.rdb.LRange(ctx, "queue:tasks:dead", 0, -1).Result() + if err != nil { + return false + } + for _, body := range bodies { + var tm TaskMetaData + if err := json.Unmarshal([]byte(body), &tm); err == nil && tm.ID == id { + return true + } + } + return false +} + +// pausedKey is set while the queue is paused. The worker's claim script +// checks for its presence and stops handing out work until it is cleared. +const pausedKey = "queue:paused" + +// queueStatus reports whether the queue is currently accepting work. +func (h *Handler) queueStatus(ctx context.Context) QueueStatus { + if n, err := h.rdb.Exists(ctx, pausedKey).Result(); err == nil && n > 0 { + return QueuePaused + } + return QueueHealthy +} + +// takeDead removes and returns the stored body of a task parked in the +// dead-letter list. The dead list holds full JSON bodies, so the entry is +// located by parsing rather than by matching an ID. +func (h *Handler) takeDead(ctx context.Context, id string) ([]byte, bool, error) { + bodies, err := h.rdb.LRange(ctx, "queue:tasks:dead", 0, -1).Result() + if err != nil { + return nil, false, err + } + var found []byte + ok := false + for _, body := range bodies { + var tm TaskMetaData + if json.Unmarshal([]byte(body), &tm) == nil && tm.ID == id { + // Keep the first body we see, but clear every matching entry: the + // same task can appear more than once in the dead list. + if !ok { + found = []byte(body) + ok = true + } + if err := h.rdb.LRem(ctx, "queue:tasks:dead", 1, body).Err(); err != nil { + return nil, false, err + } + } + } + return found, ok, nil +} + +// requeue puts a task back on the ready list, clearing any stale membership +// in the scheduled or inflight sets first. resetAttempts restores the retry +// budget, which is what a manual "retry" should do. +// +// The body is taken from task: when present, otherwise recovered from the +// dead-letter list. When neither holds it, the task is reported as missing. +func (h *Handler) requeue(ctx context.Context, id string, resetAttempts bool) (bool, error) { + // Always clear the dead-letter list first, even if a body still exists in + // task:: a task can leave stale copies behind there. The recovered + // body is only needed when task: has already been dropped. + deadBody, wasDead, err := h.takeDead(ctx, id) + if err != nil { + return false, err + } + if _, err := h.rdb.Get(ctx, "task:"+id).Result(); err == redis.Nil { + if !wasDead { + return false, nil + } + if err := h.rdb.Set(ctx, "task:"+id, deadBody, 0).Err(); err != nil { + return false, err + } + } + + if resetAttempts { + if raw, err := h.rdb.Get(ctx, "task:"+id).Bytes(); err == nil { + var tm TaskMetaData + if json.Unmarshal(raw, &tm) == nil { + tm.Attempts = 0 + if b, err := json.Marshal(tm); err == nil { + h.rdb.Set(ctx, "task:"+id, b, 0) + } + } + } + } + + pipe := h.rdb.TxPipeline() + pipe.ZRem(ctx, "queue:tasks:scheduled", id) + pipe.ZRem(ctx, "queue:tasks:inflight", id) + pipe.HDel(ctx, "queue:tasks:leases", id) + pipe.LPush(ctx, "queue:tasks:immediate", id) + if _, err := pipe.Exec(ctx); err != nil { + return false, err + } + return true, nil +} + +// deleteTask removes a task from every queue structure and drops its body. +func (h *Handler) deleteTask(ctx context.Context, id string) error { + if _, _, err := h.takeDead(ctx, id); err != nil { + return err + } + pipe := h.rdb.TxPipeline() + pipe.ZRem(ctx, "queue:tasks:scheduled", id) + pipe.ZRem(ctx, "queue:tasks:inflight", id) + pipe.HDel(ctx, "queue:tasks:leases", id) + pipe.LRem(ctx, "queue:tasks:immediate", 0, id) + pipe.Del(ctx, "task:"+id) + _, err := pipe.Exec(ctx) + return err +} + +func (h *Handler) RetryJob(w http.ResponseWriter, r *http.Request, id string) { + ok, err := h.requeue(r.Context(), id, true) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + h.writeError(w, http.StatusNotFound, fmt.Sprintf("job %s not found", id)) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) RequeueJob(w http.ResponseWriter, r *http.Request, id string) { + ok, err := h.requeue(r.Context(), id, false) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + h.writeError(w, http.StatusNotFound, fmt.Sprintf("job %s not found", id)) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) DeleteJob(w http.ResponseWriter, r *http.Request, id string) { + if err := h.deleteTask(r.Context(), id); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) PauseQueue(w http.ResponseWriter, r *http.Request, name string) { + if err := h.rdb.Set(r.Context(), pausedKey, "1", 0).Err(); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) ResumeQueue(w http.ResponseWriter, r *http.Request, name string) { + if err := h.rdb.Del(r.Context(), pausedKey).Err(); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) PurgeQueue(w http.ResponseWriter, r *http.Request, name string) { + ctx := r.Context() + ids, err := h.rdb.LRange(ctx, "queue:tasks:immediate", 0, -1).Result() + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + pipe := h.rdb.TxPipeline() + for _, id := range ids { + pipe.Del(ctx, "task:"+id) + } + pipe.Del(ctx, "queue:tasks:immediate") + if _, err := pipe.Exec(ctx); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + h.writeJSON(w, http.StatusOK, map[string]int{"purged": len(ids)}) +} +func (h *Handler) Workers(w http.ResponseWriter, r *http.Request) { + h.writeJSON(w, http.StatusOK, h.readWorkers(r.Context())) +} +func (h *Handler) Worker(w http.ResponseWriter, r *http.Request, id string) { + for _, wk := range h.readWorkers(r.Context()) { + if wk.ID == id { + h.writeJSON(w, http.StatusOK, wk) + return + } + } + h.writeError(w, http.StatusNotFound, fmt.Sprintf("worker %s not found", id)) +} + +// registeredWorker mirrors the document the worker pool publishes at +// worker::meta. It lives here (not in the worker package) so the API +// stays decoupled from worker internals. +type registeredWorker struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Version string `json:"version"` + Status string `json:"status"` + StartedAt string `json:"startedAt"` + LastHeartbeat string `json:"lastHeartbeatAt"` + Concurrency int `json:"concurrency"` +} + +// readWorkers assembles the live worker list from the registry Redis keys. +// Stale registrations (meta key expired, or heartbeat older than the healthy +// window) are reported with status "stale" rather than dropped, so the +// dashboard can see a worker that stopped heartbeating. +func (h *Handler) readWorkers(ctx context.Context) []Worker { + ids, err := h.rdb.SMembers(ctx, "forgequeue:workers").Result() + if err != nil { + return []Worker{} + } + workers := make([]Worker, 0, len(ids)) + for _, id := range ids { + raw, err := h.rdb.Get(ctx, "worker:"+id+":meta").Bytes() + if err == redis.Nil { + // The meta key lapsed; the set member is stale. Clean it up. + h.rdb.SRem(ctx, "forgequeue:workers", id) + continue + } + if err != nil { + continue + } + var meta registeredWorker + if err := json.Unmarshal(raw, &meta); err != nil { + continue + } + + status := "healthy" + if hb, perr := time.Parse(time.RFC3339Nano, meta.LastHeartbeat); perr == nil { + if time.Since(hb) > 25*time.Second { + status = "stale" + } + } + + leases := h.readWorkerLeases(ctx, id) + workers = append(workers, Worker{ + ID: meta.ID, + Hostname: meta.Hostname, + Version: meta.Version, + Status: status, + StartedAt: meta.StartedAt, + LastHeartbeatAt: meta.LastHeartbeat, + LeaseCount: len(leases), + JobsProcessing: len(leases), + Concurrency: meta.Concurrency, + Leases: leases, + RecentJobs: []RecentJob{}, + }) + } + return workers +} + +// readWorkerLeases returns the leases a worker is actively holding. A task is +// only counted if Redis still knows about its lease, which filters out IDs +// left behind after a crash. +func (h *Handler) readWorkerLeases(ctx context.Context, id string) []Lease { + ids, err := h.rdb.SMembers(ctx, "worker:"+id+":leases").Result() + if err != nil { + return []Lease{} + } + leases := make([]Lease, 0, len(ids)) + for _, taskID := range ids { + leaseID, err := h.rdb.HGet(ctx, "queue:tasks:leases", taskID).Result() + if err == redis.Nil { + // Not leased anymore (done or recovered); drop the stale entry. + h.rdb.SRem(ctx, "worker:"+id+":leases", taskID) + continue + } + if err != nil { + continue + } + var task string + if raw, err := h.rdb.Get(ctx, "task:"+taskID).Bytes(); err == nil { + var tm TaskMetaData + if json.Unmarshal(raw, &tm) == nil { + task = tm.TaskType + } + } + expires := time.Now().Add(10 * time.Second) + if score, err := h.rdb.ZScore(ctx, "queue:tasks:inflight", taskID).Result(); err == nil { + expires = time.Unix(int64(score), 0) + } + leases = append(leases, Lease{ + JobID: taskID, + Queue: "default", + Task: task, + LeaseID: leaseID, + AcquiredAt: time.Now().Add(-10 * time.Second).UTC().Format(time.RFC3339Nano), + ExpiresAt: expires.UTC().Format(time.RFC3339Nano), + }) + } + return leases +} + +// workerCounts returns how many registered workers are healthy vs stale. +func (h *Handler) workerCounts(ctx context.Context) (healthy, stale int) { + for _, wk := range h.readWorkers(ctx) { + if wk.Status == "healthy" { + healthy++ + } else { + stale++ + } + } + return healthy, stale +} +func (h *Handler) DLQ(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + bodies, err := h.rdb.LRange(ctx, "queue:tasks:dead", 0, -1).Result() + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items := []DLQItem{} + for _, body := range bodies { + var tm TaskMetaData + if err := json.Unmarshal([]byte(body), &tm); err != nil { + continue + } + items = append(items, DLQItem{ + JobID: tm.ID, + OriginalQueue: "default", + Task: tm.TaskType, + Attempts: int(tm.Attempts), + LastError: "task failed", + FailedAt: time.Now().UTC().Format(time.RFC3339Nano), + AgeMs: 0, + }) + } + h.writeJSON(w, http.StatusOK, items) +} +func (h *Handler) RetryDLQ(w http.ResponseWriter, r *http.Request, id string) { + ok, err := h.requeue(r.Context(), id, true) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + h.writeError(w, http.StatusNotFound, fmt.Sprintf("dlq entry %s not found", id)) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) DeleteDLQ(w http.ResponseWriter, r *http.Request, id string) { + if err := h.deleteTask(r.Context(), id); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) PurgeDLQ(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + bodies, err := h.rdb.LRange(ctx, "queue:tasks:dead", 0, -1).Result() + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + pipe := h.rdb.TxPipeline() + for _, body := range bodies { + var tm TaskMetaData + if json.Unmarshal([]byte(body), &tm) == nil { + pipe.Del(ctx, "task:"+tm.ID) + } + } + pipe.Del(ctx, "queue:tasks:dead") + if _, err := pipe.Exec(ctx); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + h.writeJSON(w, http.StatusOK, map[string]int{"purged": len(bodies)}) +} +func (h *Handler) Delayed(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + zs, err := h.rdb.ZRangeWithScores(ctx, "queue:tasks:scheduled", 0, -1).Result() + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items := []DelayedJob{} + for _, z := range zs { + id := z.Member.(string) + tm, ok, _ := h.readTaskFull(r, id) + if !ok || tm == nil { + continue + } + sched := time.Unix(int64(z.Score), 0) + items = append(items, DelayedJob{ + JobID: id, + Queue: "default", + Task: tm.Task, + Attempts: tm.Attempts, + ScheduledFor: sched.UTC().Format(time.RFC3339Nano), + DelayMs: sched.Sub(time.Now()).Milliseconds(), + Status: StatusDelayed, + }) + } + h.writeJSON(w, http.StatusOK, items) +} +func (h *Handler) PromoteDelayed(w http.ResponseWriter, r *http.Request, id string) { + ctx := r.Context() + // Only a task actually waiting in the scheduled set can be promoted. + if _, err := h.rdb.ZScore(ctx, "queue:tasks:scheduled", id).Result(); err == redis.Nil { + h.writeError(w, http.StatusNotFound, fmt.Sprintf("delayed job %s not found", id)) + return + } else if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + pipe := h.rdb.TxPipeline() + pipe.ZRem(ctx, "queue:tasks:scheduled", id) + pipe.LPush(ctx, "queue:tasks:immediate", id) + if _, err := pipe.Exec(ctx); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) CancelDelayed(w http.ResponseWriter, r *http.Request, id string) { + if err := h.deleteTask(r.Context(), id); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) Throughput(w http.ResponseWriter, r *http.Request) { + rangeQ := r.URL.Query().Get("range") + if rangeQ == "" { + rangeQ = "15m" + } + points := genSeries(rangeQ, h.rdb, r.Context()) + h.writeJSON(w, http.StatusOK, points) +} +func (h *Handler) Version(w http.ResponseWriter, r *http.Request) { + h.writeJSON(w, http.StatusOK, map[string]string{"version": "v0.9.1"}) +} + +func genSeries(rangeQ string, rdb *redis.Client, ctx context.Context) []ThroughputPoint { + now := time.Now() + var span time.Duration + var n int + switch rangeQ { + case "5m": + span, n = 5*time.Minute, 20 + case "15m": + span, n = 15*time.Minute, 30 + case "1h": + span, n = 1*time.Hour, 30 + case "6h": + span, n = 6*time.Hour, 36 + case "24h": + span, n = 24*time.Hour, 48 + case "7d": + span, n = 7*24*time.Hour, 56 + default: + span, n = 15*time.Minute, 30 + } + step := span / time.Duration(n) + points := make([]ThroughputPoint, n) + for i := 0; i < n; i++ { + bucketStart := now.Add(-span + time.Duration(i)*step) + bucketEnd := bucketStart.Add(step) + start := bucketStart.Unix() + end := bucketEnd.Unix() + // Count real events in this bucket + enq := int(rdb.ZCount(ctx, "metrics:enqueued", fmt.Sprint(start), fmt.Sprint(end)).Val()) + comp := int(rdb.ZCount(ctx, "metrics:completed", fmt.Sprint(start), fmt.Sprint(end)).Val()) + fail := int(rdb.ZCount(ctx, "metrics:failed", fmt.Sprint(start), fmt.Sprint(end)).Val()) + if comp < 0 { + comp = 0 + } + if fail < 0 { + fail = 0 + } + if enq < 0 { + enq = 0 + } + dlqTotal := int(rdb.LLen(ctx, "queue:tasks:dead").Val()) + pending := int(rdb.LLen(ctx, "queue:tasks:immediate").Val()) + processing := int(rdb.ZCard(ctx, "queue:tasks:inflight").Val()) + delayed := int(rdb.ZCard(ctx, "queue:tasks:scheduled").Val()) + depth := pending + processing + delayed + points[i] = ThroughputPoint{ + Time: bucketStart.UTC().Format(time.RFC3339Nano), + Enqueued: enq, + Completed: comp, + Failed: fail, + Depth: depth, + LatencyMs: 40, + ExecMs: 380, + Retries: 0, + DLQ: dlqTotal, + Workers: 3, + LeaseExpirations: 0, + } + } + return points +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..d952938 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,164 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/redis/go-redis/v9" + + "forgequeue/internal/auth" +) + +type Server struct { + srv *http.Server + auth *auth.Service +} + +func NewServer(addr string, rdb *redis.Client, authSvc *auth.Service) *Server { + if addr == "" { + addr = ":8080" + } + + s := &Server{auth: authSvc} + h := NewHandler(rdb, authSvc) + mux := http.NewServeMux() + + // Public endpoints. + mux.HandleFunc("POST /api/auth/login", h.Login) + mux.HandleFunc("GET /api/system/version", h.Version) + + // Authenticated endpoints, each gated by a permission. + mux.HandleFunc("GET /api/auth/me", s.require("", h.Me)) + mux.HandleFunc("GET /api/system/stats", s.require(auth.PermSystemRead, h.Stats)) + mux.HandleFunc("GET /api/queues", s.require(auth.PermQueuesRead, h.Queues)) + mux.HandleFunc("GET /api/queues/{name}", s.require(auth.PermQueuesRead, func(w http.ResponseWriter, r *http.Request) { + h.Queue(w, r, r.PathValue("name")) + })) + mux.HandleFunc("GET /api/queues/{name}/jobs", s.require(auth.PermJobsRead, func(w http.ResponseWriter, r *http.Request) { + h.QueueJobs(w, r, r.PathValue("name")) + })) + mux.HandleFunc("POST /api/queues/{name}/pause", s.require(auth.PermQueuesWrite, func(w http.ResponseWriter, r *http.Request) { + h.PauseQueue(w, r, r.PathValue("name")) + })) + mux.HandleFunc("POST /api/queues/{name}/resume", s.require(auth.PermQueuesWrite, func(w http.ResponseWriter, r *http.Request) { + h.ResumeQueue(w, r, r.PathValue("name")) + })) + mux.HandleFunc("POST /api/queues/{name}/purge", s.require(auth.PermQueuesWrite, func(w http.ResponseWriter, r *http.Request) { + h.PurgeQueue(w, r, r.PathValue("name")) + })) + mux.HandleFunc("GET /api/jobs/{id}", s.require(auth.PermJobsRead, func(w http.ResponseWriter, r *http.Request) { + h.Job(w, r, r.PathValue("id")) + })) + mux.HandleFunc("POST /api/jobs/{id}/retry", s.require(auth.PermJobsWrite, func(w http.ResponseWriter, r *http.Request) { + h.RetryJob(w, r, r.PathValue("id")) + })) + mux.HandleFunc("POST /api/jobs/{id}/requeue", s.require(auth.PermJobsWrite, func(w http.ResponseWriter, r *http.Request) { + h.RequeueJob(w, r, r.PathValue("id")) + })) + mux.HandleFunc("DELETE /api/jobs/{id}", s.require(auth.PermJobsWrite, func(w http.ResponseWriter, r *http.Request) { + h.DeleteJob(w, r, r.PathValue("id")) + })) + mux.HandleFunc("GET /api/workers", s.require(auth.PermWorkersRead, h.Workers)) + mux.HandleFunc("GET /api/workers/{id}", s.require(auth.PermWorkersRead, func(w http.ResponseWriter, r *http.Request) { + h.Worker(w, r, r.PathValue("id")) + })) + mux.HandleFunc("GET /api/dlq", s.require(auth.PermDLQRead, h.DLQ)) + mux.HandleFunc("POST /api/dlq/purge", s.require(auth.PermDLQWrite, h.PurgeDLQ)) + mux.HandleFunc("POST /api/dlq/{id}/retry", s.require(auth.PermDLQWrite, func(w http.ResponseWriter, r *http.Request) { + h.RetryDLQ(w, r, r.PathValue("id")) + })) + mux.HandleFunc("DELETE /api/dlq/{id}", s.require(auth.PermDLQWrite, func(w http.ResponseWriter, r *http.Request) { + h.DeleteDLQ(w, r, r.PathValue("id")) + })) + mux.HandleFunc("GET /api/delayed", s.require(auth.PermDelayedRead, h.Delayed)) + mux.HandleFunc("POST /api/delayed/{id}/promote", s.require(auth.PermDelayedWrite, func(w http.ResponseWriter, r *http.Request) { + h.PromoteDelayed(w, r, r.PathValue("id")) + })) + mux.HandleFunc("DELETE /api/delayed/{id}", s.require(auth.PermDelayedWrite, func(w http.ResponseWriter, r *http.Request) { + h.CancelDelayed(w, r, r.PathValue("id")) + })) + mux.HandleFunc("GET /api/metrics/throughput", s.require(auth.PermMetricsRead, h.Throughput)) + + srv := &http.Server{ + Addr: addr, + Handler: corsMiddleware(mux), + ReadHeaderTimeout: 5 * time.Second, + } + s.srv = srv + return s +} + +// require wraps a handler with bearer-token authentication and an optional +// permission check. An empty perm authenticates without authorizing. +func (s *Server) require(perm auth.Permission, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, ok := s.authenticate(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if perm != "" && !id.Can(perm) { + writeError(w, http.StatusForbidden, "insufficient permissions") + return + } + next(w, r.WithContext(auth.WithIdentity(r.Context(), id))) + } +} + +// authenticate extracts and verifies the Authorization: Bearer token. +func (s *Server) authenticate(r *http.Request) (auth.Identity, bool) { + if s.auth == nil { + return auth.Identity{}, false + } + const prefix = "bearer " + header := r.Header.Get("Authorization") + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return auth.Identity{}, false + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return auth.Identity{}, false + } + id, err := s.auth.VerifyToken(token) + if err != nil { + return auth.Identity{}, false + } + return id, true +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) Start() error { + go func() { + _ = s.srv.ListenAndServe() + }() + return nil +} + +func (s *Server) Stop(ctx context.Context) error { + return s.srv.Shutdown(ctx) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"message": msg}) +} diff --git a/internal/httpapi/store.go b/internal/httpapi/store.go new file mode 100644 index 0000000..eb5bc2a --- /dev/null +++ b/internal/httpapi/store.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "context" + "encoding/json" + + "github.com/redis/go-redis/v9" +) + +type RedisStore struct { + rdb *redis.Client +} + +func NewRedisStore(rdb *redis.Client) *RedisStore { + return &RedisStore{rdb: rdb} +} + +func (s *RedisStore) readTask(id string) (TaskMetaData, bool, error) { + var t TaskMetaData + b, err := s.rdb.Get(context.Background(), "task:"+id).Bytes() + if err == redis.Nil { + return t, false, nil + } + if err != nil { + return t, false, err + } + if err := json.Unmarshal(b, &t); err != nil { + return t, false, err + } + return t, true, nil +} + +func (s *RedisStore) listIDs(key string) ([]string, error) { + ids, err := s.rdb.LRange(context.Background(), key, 0, -1).Result() + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *RedisStore) zrangeIDs(key string) ([]string, error) { + ids, err := s.rdb.ZRange(context.Background(), key, 0, -1).Result() + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *RedisStore) zrangeWithScores(key string) ([]redis.Z, error) { + res, err := s.rdb.ZRangeWithScores(context.Background(), key, 0, -1).Result() + if err != nil { + return nil, err + } + return res, nil +} diff --git a/internal/httpapi/types.go b/internal/httpapi/types.go new file mode 100644 index 0000000..41f2cba --- /dev/null +++ b/internal/httpapi/types.go @@ -0,0 +1,194 @@ +package httpapi + +import ( + "encoding/json" + + "forgequeue/internal/auth" +) + +// LoginRequest is the body accepted by POST /api/auth/login. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse is returned on successful login. The token is a bearer JWT. +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt string `json:"expiresAt"` + User auth.Identity `json:"user"` +} + +type JobStatus string + +const ( + StatusPending JobStatus = "pending" + StatusProcessing JobStatus = "processing" + StatusCompleted JobStatus = "completed" + StatusFailed JobStatus = "failed" + StatusRetrying JobStatus = "retrying" + StatusDelayed JobStatus = "delayed" + StatusDLQ JobStatus = "dlq" + StatusCancelled JobStatus = "cancelled" + StatusUnknown JobStatus = "unknown" +) + +type QueueStatus string + +const ( + QueueHealthy QueueStatus = "healthy" + QueuePaused QueueStatus = "paused" + QueueDegraded QueueStatus = "degraded" + QueueUnknown QueueStatus = "unknown" +) + +type RetryPolicy struct { + Backoff string `json:"backoff"` + BaseMs int `json:"baseMs"` + MaxMs int `json:"maxMs"` + Jitter bool `json:"jitter"` +} + +type JobError struct { + Message string `json:"message"` + Stack *string `json:"stack,omitempty"` + Code *string `json:"code,omitempty"` +} + +type Attempt struct { + Attempt int `json:"attempt"` + WorkerID *string `json:"workerId,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + DurationMs *int `json:"durationMs,omitempty"` + Result any `json:"result,omitempty"` + Error *string `json:"error,omitempty"` +} + +type Job struct { + ID string `json:"id"` + Queue string `json:"queue"` + Task string `json:"task"` + Status JobStatus `json:"status"` + Attempts int `json:"attempts"` + MaxAttempts int `json:"maxAttempts"` + CreatedAt string `json:"createdAt"` + EnqueuedAt *string `json:"enqueuedAt,omitempty"` + StartedAt *string `json:"startedAt,omitempty"` + CompletedAt *string `json:"completedAt,omitempty"` + FailedAt *string `json:"failedAt,omitempty"` + DurationMs *int `json:"durationMs,omitempty"` + WorkerID *string `json:"workerId,omitempty"` + LeaseID *string `json:"leaseId,omitempty"` + LeaseExpiresAt *string `json:"leaseExpiresAt,omitempty"` + NextRetryAt *string `json:"nextRetryAt,omitempty"` + RetryPolicy *RetryPolicy `json:"retryPolicy,omitempty"` + Payload json.RawMessage `json:"payload"` + Result any `json:"result,omitempty"` + Error *JobError `json:"error,omitempty"` + LastErrorAt *string `json:"lastErrorAt,omitempty"` + LastError *string `json:"lastError,omitempty"` + AttemptsHistory []Attempt `json:"attemptsHistory,omitempty"` +} + +type QueueSummary struct { + Name string `json:"name"` + Pending int `json:"pending"` + Processing int `json:"processing"` + Delayed int `json:"delayed"` + Failed int `json:"failed"` + Completed int `json:"completed"` + Workers int `json:"workers"` + RatePerMin float64 `json:"ratePerMin"` + OldestJobAt *string `json:"oldestJobAt,omitempty"` + Status QueueStatus `json:"status"` +} + +type Lease struct { + JobID string `json:"jobId"` + Queue string `json:"queue"` + Task string `json:"task"` + LeaseID string `json:"leaseId"` + AcquiredAt string `json:"acquiredAt"` + ExpiresAt string `json:"expiresAt"` +} + +type RecentJob struct { + JobID string `json:"jobId"` + Queue string `json:"queue"` + Status JobStatus `json:"status"` + DurationMs *int `json:"durationMs,omitempty"` + FinishedAt *string `json:"finishedAt,omitempty"` +} + +type Worker struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Version string `json:"version"` + Status string `json:"status"` + StartedAt string `json:"startedAt"` + LastHeartbeatAt string `json:"lastHeartbeatAt"` + LeaseCount int `json:"leaseCount"` + JobsProcessing int `json:"jobsProcessing"` + Concurrency int `json:"concurrency"` + Leases []Lease `json:"leases"` + RecentJobs []RecentJob `json:"recentJobs"` +} + +type DLQItem struct { + JobID string `json:"jobId"` + OriginalQueue string `json:"originalQueue"` + Task string `json:"task"` + Attempts int `json:"attempts"` + LastError string `json:"lastError"` + FailedAt string `json:"failedAt"` + AgeMs int64 `json:"ageMs"` + Job *Job `json:"job,omitempty"` +} + +type DelayedJob struct { + JobID string `json:"jobId"` + Queue string `json:"queue"` + Task string `json:"task"` + Attempts int `json:"attempts"` + ScheduledFor string `json:"scheduledFor"` + DelayMs int64 `json:"delayMs"` + Status JobStatus `json:"status"` +} + +type ThroughputPoint struct { + Time string `json:"time"` + Enqueued int `json:"enqueued"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Depth int `json:"depth"` + LatencyMs int `json:"latencyMs"` + ExecMs int `json:"execMs"` + Retries int `json:"retries"` + DLQ int `json:"dlq"` + Workers int `json:"workers"` + LeaseExpirations int `json:"leaseExpirations"` +} + +type SystemStats struct { + Queues int `json:"queues"` + ActiveJobs int `json:"activeJobs"` + Pending int `json:"pending"` + Processing int `json:"processing"` + Failed int `json:"failed"` + Completed int `json:"completed"` + Delayed int `json:"delayed"` + DLQ int `json:"dlq"` + ActiveWorkers int `json:"activeWorkers"` + HealthyWorkers int `json:"healthyWorkers"` + StaleWorkers int `json:"staleWorkers"` + WorkersWithExpiredLeases int `json:"workersWithExpiredLeases"` +} + +type TaskMetaData struct { + ID string `json:"id"` + TaskType string `json:"task_type"` + Payload json.RawMessage `json:"payload"` + MaxRetries int32 `json:"max_retries"` + Attempts int32 `json:"attempts"` +} diff --git a/internal/worker/registry.go b/internal/worker/registry.go new file mode 100644 index 0000000..3dc152f --- /dev/null +++ b/internal/worker/registry.go @@ -0,0 +1,103 @@ +package worker + +import ( + "context" + "encoding/json" + "time" +) + +// metaKey is the Redis key holding this pool's registration document. +func (wp *WorkerPool) metaKey() string { + return workerMetaPrefix + wp.id + ":meta" +} + +// leasesKey is the Redis set of task IDs this pool is currently holding +// leases for. The dashboard reads it to show what a worker is processing. +func (wp *WorkerPool) leasesKey() string { + return workerMetaPrefix + wp.id + ":leases" +} + +// publishMeta writes the registration document and refreshes its TTL. It is +// called once on Start and then on every heartbeat tick. Best effort: a +// failure to publish only hides the worker from the dashboard, it never +// stops task processing. +func (wp *WorkerPool) publishMeta() { + meta := workerMeta{ + ID: wp.id, + Hostname: wp.hostname, + Version: wp.version, + Status: "active", + StartedAt: wp.startedAt.UTC().Format(time.RFC3339Nano), + LastHeartbeat: time.Now().UTC().Format(time.RFC3339Nano), + Concurrency: wp.concurrencyLimit, + } + body, err := json.Marshal(meta) + if err != nil { + return + } + + ctx := context.Background() + pipe := wp.redisClient.TxPipeline() + pipe.SAdd(ctx, workerRegistrySet, wp.id) + pipe.Set(ctx, wp.metaKey(), body, workerMetaTTL) + pipe.Expire(ctx, wp.leasesKey(), workerMetaTTL) + _, _ = pipe.Exec(ctx) +} + +// registrationLoop republishes the worker's identity on a fixed interval so +// the TTL never lapses while the pool is alive. +func (wp *WorkerPool) registrationLoop() { + ticker := time.NewTicker(workerHeartbeatEvery) + defer ticker.Stop() + + wp.publishMeta() + for { + select { + case <-ticker.C: + wp.publishMeta() + case <-wp.quit: + return + } + } +} + +// deregister removes this worker from the registry so a clean shutdown +// disappears from the dashboard immediately instead of waiting for the TTL. +func (wp *WorkerPool) deregister() { + ctx := context.Background() + pipe := wp.redisClient.TxPipeline() + pipe.SRem(ctx, workerRegistrySet, wp.id) + pipe.Del(ctx, wp.metaKey()) + pipe.Del(ctx, wp.leasesKey()) + _, _ = pipe.Exec(ctx) +} + +// markLeases records, in one round trip, the set of tasks this pool just +// claimed. The TTL is refreshed so the set outlives the work. +func (wp *WorkerPool) markLeases(taskIDs []string) { + if len(taskIDs) == 0 { + return + } + members := make([]interface{}, len(taskIDs)) + for i, id := range taskIDs { + members[i] = id + } + ctx := context.Background() + pipe := wp.redisClient.TxPipeline() + pipe.SAdd(ctx, wp.leasesKey(), members...) + pipe.Expire(ctx, wp.leasesKey(), workerMetaTTL) + _, _ = pipe.Exec(ctx) +} + +// clearLeases drops the given tasks from this pool's lease set once their +// outcomes are known (ACKed, retried or dead-lettered). +func (wp *WorkerPool) clearLeases(taskIDs []string) { + if len(taskIDs) == 0 { + return + } + members := make([]interface{}, len(taskIDs)) + for i, id := range taskIDs { + members[i] = id + } + _ = wp.redisClient.SRem(context.Background(), wp.leasesKey(), members...).Err() +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index b20e819..58c376c 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log" + "os" "strconv" "sync" "time" @@ -17,6 +18,33 @@ import ( "forgequeue/internal/model" ) +// Worker registry keys. A worker pool publishes its identity here on Start +// and refreshes it on a heartbeat, so the dashboard can show live workers +// without the API and the worker sharing a process. +const ( + // workerRegistrySet holds the IDs of every worker that has registered. + workerRegistrySet = "forgequeue:workers" + // workerMetaPrefix builds the per-worker keys: :meta and + // :leases. + workerMetaPrefix = "worker:" + // workerMetaTTL bounds how long a registration outlives its last + // heartbeat. A crashed worker simply disappears once the TTL lapses. + workerMetaTTL = 30 * time.Second + // workerHeartbeatEvery is how often a live worker refreshes its keys. + workerHeartbeatEvery = 10 * time.Second +) + +// workerMeta is the JSON document published at worker::meta. +type workerMeta struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Version string `json:"version"` + Status string `json:"status"` + StartedAt string `json:"startedAt"` + LastHeartbeat string `json:"lastHeartbeatAt"` + Concurrency int `json:"concurrency"` +} + type WorkerPool struct { // The shared Redis client used for all queue operations. redisClient *redis.Client @@ -64,6 +92,45 @@ type WorkerPool struct { // Observability. Never nil: the default pool gets no-op instruments so // instrumentation call sites stay free of nil checks. metrics *metrics.Metrics + + // Identity of this pool instance. Published to Redis on Start so the + // dashboard can attribute leases and heartbeats to a specific worker. + id string + hostname string + startedAt time.Time + version string + + // bgCtx/bgCancel govern the background infrastructure loops (queue-depth + // sampling, the durable reconciler) that stop only on context + // cancellation. They are deliberately separate from ctx so Stop can drain + // in-flight handlers (which run under ctx) before tearing the background + // down. + bgCtx context.Context + bgCancel context.CancelFunc + + // drainTimeout bounds how long Stop waits for in-flight handlers to + // finish before cancelling them. + drainTimeout time.Duration + + // recoveryInterval is how often the recovery loop looks for expired + // leases. Defaults to one second; tests shrink it to exercise expiry + // without waiting. + recoveryInterval time.Duration +} + +// taskRuntime holds the per-task lifecycle of one claimed task: the context +// its handler runs under and the cancellation hooks for the handler and its +// heartbeat. +// +// A batch is handled sequentially, so the runtime for every task in it is +// created and its heartbeat started the moment the batch is claimed. Without +// that, tasks waiting behind the one being executed would have no heartbeat +// and their leases would expire mid-batch, letting recovery hand them to +// another worker while this one was still running them. +type taskRuntime struct { + ctx context.Context + cancel context.CancelFunc + stopBeat context.CancelFunc } // TaskLease is the proof of ownership a worker holds over a claimed task. @@ -101,18 +168,30 @@ func NewWorkerPool(addr string, concurrencyLimit int) (*WorkerPool, error) { // can be interrupted. ctx, cancel := context.WithCancel(context.Background()) + // Separate context for the background infrastructure loops, so they can + // be stopped at the end of a graceful drain without cancelling the + // handlers still finishing under ctx. + bgCtx, bgCancel := context.WithCancel(context.Background()) + // Check that Redis is reachable before returning the pool. if err := client.Ping(context.Background()).Err(); err != nil { - // If Redis isn't reachable, cancel the context we just created + // If Redis isn't reachable, cancel the contexts we just created // because the worker pool won't be started. cancel() + bgCancel() return nil, err } + hostname, _ := os.Hostname() + return &WorkerPool{ redisClient: client, concurrencyLimit: concurrencyLimit, store: durable.Nop{}, + id: uuid.New().String(), + hostname: hostname, + startedAt: time.Now(), + version: "v0.9.1", // sync.WaitGroup starts with a counter of 0. // Start() will add one for every worker it creates. @@ -122,14 +201,18 @@ func NewWorkerPool(addr string, concurrencyLimit int) (*WorkerPool, error) { quit: make(chan struct{}), // Shared context and its cancellation function. - ctx: ctx, - cancel: cancel, - handlers: make(map[string]TaskHandler), - leaseDuration: 10 * time.Second, - backoffBase: 1 * time.Second, - backoffCap: 60 * time.Second, - batchSize: 1, - metrics: metrics.NewNop(), + ctx: ctx, + cancel: cancel, + bgCtx: bgCtx, + bgCancel: bgCancel, + drainTimeout: 30 * time.Second, + recoveryInterval: 1 * time.Second, + handlers: make(map[string]TaskHandler), + leaseDuration: 10 * time.Second, + backoffBase: 1 * time.Second, + backoffCap: 60 * time.Second, + batchSize: 1, + metrics: metrics.NewNop(), }, nil } @@ -181,6 +264,46 @@ func (wp *WorkerPool) workerLoop(workerID int) { var ackIDs []string var ackLeases []string + // Record every claimed task against this worker so the + // dashboard can show what it is processing. + claimedIDs := make([]string, 0, len(claims)) + for _, claim := range claims { + if claim.TaskID != "" { + claimedIDs = append(claimedIDs, claim.TaskID) + } + } + wp.markLeases(claimedIDs) + + // Start a heartbeat for every claimed task up front. The batch + // is handled sequentially, so a task waiting behind the one + // being executed would otherwise have no heartbeat and its lease + // would expire mid-batch - exactly the window in which recovery + // hands the task to a second worker and it runs twice. + runtimes := make(map[string]*taskRuntime, len(claims)) + for _, claim := range claims { + if claim.TaskID == "" { + continue + } + taskCtx, cancelTask := context.WithCancel(wp.ctx) + beatCtx, stopBeat := context.WithCancel(wp.ctx) + runtimes[claim.TaskID] = &taskRuntime{ + ctx: taskCtx, + cancel: cancelTask, + stopBeat: stopBeat, + } + go wp.heartbeatLoop(beatCtx, claim.TaskID, claim.LeaseID, cancelTask) + } + + // stopTask ends the heartbeat and execution context for one task. + // Idempotent, so it is safe on every exit path and again in the + // cleanup sweep below. + stopTask := func(id string) { + if rt, ok := runtimes[id]; ok { + rt.stopBeat() + rt.cancel() + } + } + for _, claim := range claims { // Defensive guard: a claimed task must always have an ID. @@ -197,6 +320,7 @@ func (wp *WorkerPool) workerLoop(workerID int) { workerID, claim.TaskID, ) + stopTask(claim.TaskID) continue } @@ -213,6 +337,7 @@ func (wp *WorkerPool) workerLoop(workerID int) { claim.TaskID, err, ) + stopTask(claim.TaskID) continue } @@ -228,6 +353,7 @@ func (wp *WorkerPool) workerLoop(workerID int) { workerID, task.TaskType, ) + stopTask(claim.TaskID) continue } @@ -243,32 +369,15 @@ func (wp *WorkerPool) workerLoop(workerID int) { } handlerStart := time.Now() - // This task's own cancellation signal. If the lease is lost - // mid-execution, this context can be cancelled so the handler - // has a chance to stop early. Canceled explicitly (not via - // defer) because this is a loop - deferred calls would pile up. - taskCtx, cancelTask := context.WithCancel(wp.ctx) - // A per-task context so cancelling it only stops this - // task's heartbeat, not the whole worker pool. - heartbeatCtx, stopHeartbeat := context.WithCancel(wp.ctx) - - // Run the heartbeat in the background while the handler - // is executing, so the lease is renewed for long tasks. - go wp.heartbeatLoop( - heartbeatCtx, - claim.TaskID, - claim.LeaseID, - cancelTask, - ) - - handlerErr := handler.Handle(taskCtx, &task) - - // Stop the heartbeats as soon as this execution attempt - // is over, even if the handler failed. The attempt is done, - // so renewing the lease would only delay recovery. - stopHeartbeat() - cancelTask() + // The handler runs under the task's pre-created context, whose + // heartbeat is already renewing the lease in the background. + // The heartbeat is deliberately left running after the handler + // returns: the ACK is deferred to the single batched call at + // the end of the loop, so stopping it here would let an + // early-finished task's lease expire while it waits its turn. + rt := runtimes[claim.TaskID] + handlerErr := handler.Handle(rt.ctx, &task) // How long the handler actually ran. Recorded for both // outcomes: a slow success and a slow failure are very @@ -318,57 +427,80 @@ func (wp *WorkerPool) workerLoop(workerID int) { case "dead": wp.metrics.TasksDLQ.Add(wp.ctx, 1) wp.reportDead(task.ID) + now := time.Now().Unix() + wp.redisClient.ZAdd(wp.ctx, "metrics:failed", redis.Z{Score: float64(now), Member: task.ID}) + wp.redisClient.Expire(wp.ctx, "metrics:failed", 24*time.Hour) } - } - if len(ackIDs) == 0 { - continue + // The lease was released by requeueOrDead, so there is + // nothing left for the heartbeat to keep alive. + stopTask(task.ID) } - // ACK every successful task of the batch in one fenced script. + // Everything is decided for this batch now, so ACK the successes + // in one fenced script. Only after the ACK lands do we stop the + // remaining heartbeats: an early-finished success has to keep + // heartbeating until its ACK is committed, otherwise its lease + // expires while it waits for the rest of the batch and recovery + // hands the task to a second worker. // - // The script fences per task, so a batch can be partially ACKed: if - // one task's lease expired while the batch was being handled, - // recovery may already have handed that task to another worker and - // rotated its lease. That task is skipped, the rest are ACKed, and - // the returned count is what actually completed. - acked, ackErr := wp.ackBatch(ackIDs, ackLeases) - if ackErr != nil { - log.Printf( - "worker %d: failed to ACK %d task(s): %v", - workerID, - len(ackIDs), - ackErr, - ) - continue - } - - if acked < int64(len(ackIDs)) { - // Worth a warning: this is the at-least-once duplicate path. A - // silent overcount here would look like healthy throughput while - // tasks were actually running twice. - log.Printf( - "worker %d: only %d of %d task(s) ACKed, %d lost their lease "+ - "and may run again elsewhere", - workerID, acked, len(ackIDs), len(ackIDs)-int(acked), - ) - } - - // ACKed means done. Counted from the script's return value rather - // than len(ackIDs), so a fenced task is not reported as processed - // while it is still outstanding elsewhere. - wp.metrics.TasksProcessed.Add(wp.ctx, acked) - - // ACKed means done: record it so the journal stops tracking it. - for _, id := range ackIDs { - if err := wp.store.Succeeded(wp.ctx, id); err != nil { + // The script fences per task, so a batch can be partially ACKed: + // if one task's lease really did expire, recovery may already + // have handed it to another worker and rotated its lease. That + // task is skipped, the rest are ACKed, and the returned ids are + // the ones that actually completed. + if len(ackIDs) > 0 { + ackedIDs, ackErr := wp.ackBatch(ackIDs, ackLeases) + if ackErr != nil { log.Printf( - "journal: failed to mark task %s as succeeded: %v", - id, - err, + "worker %d: failed to ACK %d task(s): %v", + workerID, + len(ackIDs), + ackErr, ) + } else { + if len(ackedIDs) < len(ackIDs) { + // Worth a warning: this is the at-least-once duplicate + // path. A silent overcount here would look like healthy + // throughput while tasks were actually running twice. + log.Printf( + "worker %d: only %d of %d task(s) ACKed, %d lost their lease "+ + "and may run again elsewhere", + workerID, len(ackedIDs), len(ackIDs), len(ackIDs)-len(ackedIDs), + ) + } + + // ACKed means done. Counted from the script's return value + // rather than len(ackIDs), so a fenced task is not reported + // as processed while it is still outstanding elsewhere. + wp.metrics.TasksProcessed.Add(wp.ctx, int64(len(ackedIDs))) + + // ACKed means done: record it so the journal stops tracking + // it. Only the tasks the script actually ACKed are counted, + // so a task that lost its lease is not double-counted as + // completed. + now := time.Now().Unix() + for _, id := range ackedIDs { + if err := wp.store.Succeeded(wp.ctx, id); err != nil { + log.Printf( + "journal: failed to mark task %s as succeeded: %v", + id, + err, + ) + } + wp.redisClient.ZAdd(wp.ctx, "metrics:completed", redis.Z{Score: float64(now), Member: id}) + } + wp.redisClient.Expire(wp.ctx, "metrics:completed", 24*time.Hour) } } + + // Every task in this batch has reached an outcome by now (ACKed, + // retried or dead-lettered), so stop any heartbeat still running + // and drop them from this worker's lease set. + for id := range runtimes { + stopTask(id) + } + wp.clearLeases(claimedIDs) } } } @@ -392,10 +524,27 @@ func (wp *WorkerPool) WithBatchSize(n int) *WorkerPool { return wp } +// WithDrainTimeout bounds how long Stop waits for in-flight handlers to finish +// before cancelling them. Call it before Start(). +func (wp *WorkerPool) WithDrainTimeout(d time.Duration) *WorkerPool { + if d > 0 { + wp.drainTimeout = d + } + return wp +} + // Start creates and starts the workers. // // The number of workers is controlled by concurrencyLimit. func (wp *WorkerPool) Start() { + // Publish this pool's identity and keep it fresh with a heartbeat, so + // the dashboard can see the worker without sharing its process. + wp.wg.Add(1) + go func() { + defer wp.wg.Done() + wp.registrationLoop() + }() + for i := 0; i < wp.concurrencyLimit; i++ { // Tell the WaitGroup that another worker is about to start. @@ -441,7 +590,7 @@ func (wp *WorkerPool) Start() { wp.wg.Add(1) go func() { defer wp.wg.Done() - durable.ReconcileLoop(wp.ctx, wp.redisClient, wp.store) + durable.ReconcileLoop(wp.bgCtx, wp.redisClient, wp.store) }() } @@ -459,31 +608,54 @@ func (wp *WorkerPool) Start() { func(err error) { log.Printf("queue depth sample failed: %v", err) }, - ).Run(wp.ctx) + ).Run(wp.bgCtx) }() } } // Stop gracefully shuts down the worker pool. +// +// It works in three phases so in-flight work is drained rather than aborted: +// +// 1. close quit and stop the background infrastructure. Workers stop claiming +// new tasks at the top of their loop, but a worker already inside a batch +// keeps running it. +// 2. wait for every goroutine to return. Handlers run under ctx, which is +// still alive, so they finish normally instead of being cancelled. +// 3. cancel ctx and deregister. By now no handler is running, so cancelling +// is safe. +// +// A slow or stuck handler must not hold shutdown open forever, so phase 2 is +// bounded by drainTimeout; past it the context is cancelled to force the +// handlers to unwind. func (wp *WorkerPool) Stop() { - // Tell all workers that shutdown has been requested. - // - // Closing a channel wakes every goroutine waiting to receive - // from that channel. + // Phase 1: stop accepting new claims and stop the loops that would + // otherwise run for the lifetime of the pool. close(wp.quit) + wp.bgCancel() - // Cancel the shared context. - // - // This is important if a worker is currently blocked inside - // BRPop. It allows the Redis operation to stop instead of - // waiting for the full 2-second timeout. - wp.cancel() + // Phase 2: wait for workers to finish the batch they are inside, while + // their contexts are still valid. + done := make(chan struct{}) + go func() { + wp.wg.Wait() + close(done) + }() - // Wait until every worker has returned from workerLoop() - // and called wg.Done(). - // - // This makes Stop() wait until shutdown is actually complete. - wp.wg.Wait() + select { + case <-done: + case <-time.After(wp.drainTimeout): + log.Printf( + "shutdown: in-flight tasks still running after %s, cancelling them", + wp.drainTimeout, + ) + } + + // Phase 3: nothing should be executing now, so tear the context down and + // remove the registration so the dashboard reflects the shutdown. In the + // normal path the wait above already returned, so the pool is fully idle. + wp.cancel() + wp.deregister() } func (wp *WorkerPool) RegisterHandler( @@ -518,7 +690,9 @@ func (wp *WorkerPool) claimBatch() ([]TaskClaim, error) { // The expiry is the deadline for the lease. If the worker doesn't // ACK or heartbeat before this, recovery will requeue the task. - leaseExpiry := time.Now().Add(wp.leaseDuration).Unix() + // Keep fractional seconds: truncating to whole seconds would silently + // shave up to a second off short leases, expiring them early. + leaseExpiry := float64(time.Now().UnixNano())/1e9 + wp.leaseDuration.Seconds() batch := wp.batchSize if batch < 1 { @@ -526,7 +700,7 @@ func (wp *WorkerPool) claimBatch() ([]TaskClaim, error) { } leaseIDs := make([]string, batch) args := make([]interface{}, 0, batch+3) - args = append(args, batch, leaseExpiry, "task:") + args = append(args, batch, strconv.FormatFloat(leaseExpiry, 'f', -1, 64), "task:") for i := 0; i < batch; i++ { leaseIDs[i] = uuid.New().String() args = append(args, leaseIDs[i]) @@ -535,11 +709,16 @@ func (wp *WorkerPool) claimBatch() ([]TaskClaim, error) { // KEYS[1] -> queue:tasks:immediate (source of ready tasks) // KEYS[2] -> queue:tasks:inflight (sorted set: taskID -> expiry) // KEYS[3] -> queue:tasks:leases (hash: taskID -> leaseID) + // KEYS[4] -> queue:paused (presence means "stop claiming") // ARGV[1] -> how many tasks to try to claim (the batch size) - // ARGV[2] -> lease expiry (Unix seconds) + // ARGV[2] -> lease expiry (Unix seconds, fractional) // ARGV[3] -> "task:" prefix used to build the body's key // ARGV[4..] -> one leaseID per possible claim, in order script := redis.NewScript(` + if redis.call("EXISTS", KEYS[4]) == 1 then + return "" + end + local count = 0 local ids = {} @@ -574,6 +753,7 @@ func (wp *WorkerPool) claimBatch() ([]TaskClaim, error) { "queue:tasks:immediate", "queue:tasks:inflight", "queue:tasks:leases", + "queue:paused", }, args..., ).Result() @@ -634,14 +814,14 @@ func (wp *WorkerPool) claimBatch() ([]TaskClaim, error) { // everything between "-inf" and the current Unix time ("now") returns // exactly the tasks whose deadline has passed and are ready for requeue. func (wp *WorkerPool) findExpiredTasks() ([]string, error) { - now := time.Now().Unix() + now := float64(time.Now().UnixNano()) / 1e9 return wp.redisClient.ZRangeArgs( wp.ctx, redis.ZRangeArgs{ Key: "queue:tasks:inflight", Start: "-inf", - // Stop is an integer, so it has to be converted to a string. - Stop: strconv.FormatInt(now, 10), + // Stop is a float score, so it has to be converted to a string. + Stop: strconv.FormatFloat(now, 'f', -1, 64), ByScore: true, }, ).Result() @@ -687,6 +867,9 @@ func (wp *WorkerPool) findExpiredTasks() ([]string, error) { // ARGV[5] -> backoff cap in seconds func (wp *WorkerPool) requeueOrDead(taskID string, leaseID string) (string, error) { script := redis.NewScript(` + local now = redis.call("TIME") + local nowf = tonumber(now[1]) + tonumber(now[2]) / 1000000 + if ARGV[3] ~= "" then -- called by the worker: fence like an ACK. local current = redis.call("HGET", KEYS[2], ARGV[2]) @@ -701,8 +884,7 @@ func (wp *WorkerPool) requeueOrDead(taskID string, leaseID string) (string, erro if not expiry then return "gone" end - local now = redis.call("TIME")[1] - if tonumber(expiry) > tonumber(now) then + if tonumber(expiry) > nowf then return "renewed" end redis.call("ZREM", KEYS[1], ARGV[2]) @@ -750,7 +932,7 @@ func (wp *WorkerPool) requeueOrDead(taskID string, leaseID string) (string, erro delay = cap end - local retryAt = redis.call("TIME")[1] + delay + local retryAt = nowf + delay redis.call("ZADD", KEYS[3], retryAt, ARGV[2]) return "scheduled" @@ -768,8 +950,8 @@ func (wp *WorkerPool) requeueOrDead(taskID string, leaseID string) (string, erro "task:", taskID, leaseID, - int64(wp.backoffBase/time.Second), - int64(wp.backoffCap/time.Second), + strconv.FormatFloat(wp.backoffBase.Seconds(), 'f', -1, 64), + strconv.FormatFloat(wp.backoffCap.Seconds(), 'f', -1, 64), ).Result() if err != nil { @@ -843,8 +1025,9 @@ func (wp *WorkerPool) readTaskForJournal(taskID string) (model.TaskMetaData, boo // letters it once the retry budget is gone. It runs in its own goroutine and // stops when the pool shuts down. func (wp *WorkerPool) recoveryLoop() { - // Re-check for expired tasks once per second. - ticker := time.NewTicker(1 * time.Second) + // Re-check for expired tasks on the configured interval (one second by + // default). + ticker := time.NewTicker(wp.recoveryInterval) defer ticker.Stop() for { @@ -870,6 +1053,9 @@ func (wp *WorkerPool) recoveryLoop() { log.Printf("recovery: task %s exhausted its retry budget, moved to dead-letter queue", taskID) wp.metrics.TasksDLQ.Add(wp.ctx, 1) wp.reportDead(taskID) + now := time.Now().Unix() + wp.redisClient.ZAdd(wp.ctx, "metrics:failed", redis.Z{Score: float64(now), Member: taskID}) + wp.redisClient.Expire(wp.ctx, "metrics:failed", 24*time.Hour) case "no-body": log.Printf("recovery: task %s has no body, dropped", taskID) } @@ -889,13 +1075,13 @@ func (wp *WorkerPool) recoveryLoop() { // Being "due" as reported here is only a candidate: promoteTask re-checks the // score against Redis's own clock atomically, just like recovery does. func (wp *WorkerPool) findDueTasks() ([]string, error) { - now := time.Now().Unix() + now := float64(time.Now().UnixNano()) / 1e9 return wp.redisClient.ZRangeArgs( wp.ctx, redis.ZRangeArgs{ Key: "queue:tasks:scheduled", Start: "-inf", - Stop: strconv.FormatInt(now, 10), + Stop: strconv.FormatFloat(now, 'f', -1, 64), ByScore: true, }, ).Result() @@ -917,8 +1103,9 @@ func (wp *WorkerPool) promoteTask(taskID string) (bool, error) { if not score then return 0 end - local now = redis.call("TIME")[1] - if tonumber(score) > tonumber(now) then + local now = redis.call("TIME") + local nowf = tonumber(now[1]) + tonumber(now[2]) / 1000000 + if tonumber(score) > nowf then return 0 end redis.call("ZREM", KEYS[1], ARGV[1]) @@ -977,17 +1164,23 @@ func (wp *WorkerPool) promotionLoop() { // whose lease was overwritten (e.g. the task was requeued after expiry) is // rejected without touching anything else. One round trip for the whole list. // +// A successfully ACKed task has no further use for its body, so the script +// deletes task: in the same atomic call. That is what keeps the task +// store from growing without bound. The returned slice holds exactly the IDs +// that were ACKed, so callers never count a fenced task as completed. +// // KEYS[1] -> queue:tasks:inflight (sorted set: taskID -> expiry) // KEYS[2] -> queue:tasks:leases (hash: taskID -> leaseID) +// KEYS[3] -> "task:" prefix (builds the body key to delete) // ARGV[1..] -> flat pairs: taskID1, leaseID1, taskID2, leaseID2, ... -func (wp *WorkerPool) ackBatch(taskIDs []string, leaseIDs []string) (int64, error) { +func (wp *WorkerPool) ackBatch(taskIDs []string, leaseIDs []string) ([]string, error) { args := make([]interface{}, 0, 2*len(taskIDs)) for i := range taskIDs { args = append(args, taskIDs[i], leaseIDs[i]) } script := redis.NewScript(` - local acked = 0 + local acked = {} local i = 1 while i <= #ARGV do local taskID = ARGV[i] @@ -996,7 +1189,8 @@ func (wp *WorkerPool) ackBatch(taskIDs []string, leaseIDs []string) (int64, erro if redis.call("HGET", KEYS[2], taskID) == leaseID then redis.call("ZREM", KEYS[1], taskID) redis.call("HDEL", KEYS[2], taskID) - acked = acked + 1 + redis.call("DEL", KEYS[3] .. taskID) + acked[#acked + 1] = taskID end i = i + 2 @@ -1011,6 +1205,7 @@ func (wp *WorkerPool) ackBatch(taskIDs []string, leaseIDs []string) (int64, erro []string{ "queue:tasks:inflight", "queue:tasks:leases", + "task:", }, args..., ).Result() @@ -1021,10 +1216,17 @@ func (wp *WorkerPool) ackBatch(taskIDs []string, leaseIDs []string) (int64, erro ) if err != nil { - return 0, err + return nil, err } - return result.(int64), nil + values := result.([]interface{}) + acked := make([]string, 0, len(values)) + for _, v := range values { + if v != nil { + acked = append(acked, v.(string)) + } + } + return acked, nil } // errLeaseLost is returned by heartbeat when Redis answered but the lease @@ -1044,7 +1246,7 @@ var errLeaseLost = errors.New("heartbeat rejected: lease no longer held") // KEYS[2] -> queue:tasks:inflight (sorted set: taskID -> expiry) // ARGV[1] -> taskID // ARGV[2] -> leaseID presented by the worker -// ARGV[3] -> lease duration in seconds +// ARGV[3] -> lease duration in seconds (fractional) func (wp *WorkerPool) heartbeat(taskID string, leaseID string) error { beatStart := time.Now() script := redis.NewScript(` @@ -1054,7 +1256,9 @@ func (wp *WorkerPool) heartbeat(taskID string, leaseID string) error { return 0 end - local newExpiry = redis.call("TIME")[1] + ARGV[3] + local now = redis.call("TIME") + local nowf = tonumber(now[1]) + tonumber(now[2]) / 1000000 + local newExpiry = nowf + tonumber(ARGV[3]) redis.call("ZADD", KEYS[2], newExpiry, ARGV[1]) @@ -1070,7 +1274,7 @@ func (wp *WorkerPool) heartbeat(taskID string, leaseID string) error { }, taskID, leaseID, - int64(wp.leaseDuration/time.Second), + strconv.FormatFloat(wp.leaseDuration.Seconds(), 'f', -1, 64), ).Result() // Recorded on both paths: a slow heartbeat is what causes lease @@ -1116,6 +1320,14 @@ func (wp *WorkerPool) heartbeatLoop( select { case <-ticker.C: if err := wp.heartbeat(taskID, leaseID); err != nil { + // A heartbeat that raced with the batch's own ACK (the lease + // was removed the instant we stopped) is not a real loss: the + // stop already happened. Only report it when the loop is still + // meant to be running. + if ctx.Err() != nil { + return + } + log.Printf( "heartbeat failed for task %s: %v", taskID, diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index c90c503..d880801 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -539,3 +539,104 @@ func BenchmarkWorkerThroughputPreloaded(b *testing.B) { } b.ReportMetric(float64(total)/b.Elapsed().Seconds(), "tasks/sec") } + +// slowCountingHandler records how many times each task ID was handled and +// sleeps for a fixed duration on every call, so a test can make a handler +// outlast its lease. +type slowCountingHandler struct { + mu sync.Mutex + calls map[string]int + sleep time.Duration +} + +func (h *slowCountingHandler) Handle(ctx context.Context, task *model.TaskMetaData) error { + h.mu.Lock() + h.calls[task.ID]++ + h.mu.Unlock() + + select { + case <-time.After(h.sleep): + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +func (h *slowCountingHandler) Counts() map[string]int { + h.mu.Lock() + defer h.mu.Unlock() + out := make(map[string]int, len(h.calls)) + for k, v := range h.calls { + out[k] = v + } + return out +} + +// TestWorkerBatchLeaseKeptAlive is the regression test for the batch lease +// bug. With batchSize > 1 a whole batch is claimed at once, so every task in +// it gets the same initial lease expiry. The batch is handled sequentially: if +// the earlier tasks take longer than the lease, the later tasks would have no +// heartbeat and their leases would expire mid-batch. The recovery loop would +// then requeue them and they would run a second time. +// +// The fix starts a heartbeat for every claimed task the moment the batch is +// claimed, so no task in an in-progress batch is ever recovered. The test uses +// a short lease, a fast recovery loop and a handler that outlasts the lease, +// so any duplicate execution is caught deterministically. +func TestWorkerBatchLeaseKeptAlive(t *testing.T) { + wp, err := NewWorkerPool(testRedisAddr, 1) + if err != nil { + t.Fatalf("new worker pool: %v", err) + } + // A one-second lease with a fast recovery loop makes an expired lease + // show up almost immediately, while the handler below outlasts it and a + // whole batch takes several leases to drain. + wp.leaseDuration = 1 * time.Second + wp.recoveryInterval = 50 * time.Millisecond + wp.backoffBase = 100 * time.Millisecond + handler := &slowCountingHandler{calls: map[string]int{}, sleep: 700 * time.Millisecond} + wp.RegisterHandler("noop", handler) + wp.WithBatchSize(4) + + if err := wp.redisClient.FlushDB(wp.ctx).Err(); err != nil { + t.Fatalf("flush db: %v", err) + } + + const n = 8 + ids := make([]string, n) + for i := 0; i < n; i++ { + ids[i] = uuid.NewString() + enqueueTask(t, wp, ids[i], 0) + } + + wp.Start() + defer wp.Stop() + + // Wait until every task has been handled at least once. + waitFor(t, 20*time.Second, "all tasks handled", func() bool { + counts := handler.Counts() + if len(counts) != n { + return false + } + for _, c := range counts { + if c < 1 { + return false + } + } + return true + }) + + // Give the fast recovery loop time to wrongly re-run anything whose + // lease it thinks has expired. + time.Sleep(1 * time.Second) + + counts := handler.Counts() + if len(counts) != n { + t.Fatalf("handler saw %d distinct task(s), want %d", len(counts), n) + } + for id, c := range counts { + if c != 1 { + t.Fatalf("task %s ran %d times, want exactly 1 (batch lease was not kept alive)", id, c) + } + } +} diff --git a/web/.env.development b/web/.env.development new file mode 100644 index 0000000..9608399 --- /dev/null +++ b/web/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=http://localhost:8080/api +VITE_APP_VERSION=v0.9.1 diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..fa593f0 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +# Base URL for the ForgeQueue gRPC Gateway (HTTP/JSON). Relative paths work when served under the Go binary. +VITE_API_BASE_URL=/api +VITE_APP_VERSION=v0.9.1 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..d24df8a --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +.output +.vinxi +.tanstack/** +.nitro +*.local + +# Wrangler / Cloudflare +.wrangler/ +.dev.vars + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..be49b26 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,8 @@ +node_modules +dist +.output +.vinxi +pnpm-lock.yaml +package-lock.json +bun.lock +routeTree.gen.ts diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 0000000..77c2bc8 --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,5 @@ +## Architecture +- All data access goes through `src/api/endpoints.ts` (HTTP/JSON client) and hooks in `src/api/queries.ts`; components never call fetch directly, so the backend can be swapped without UI changes. +- Data routes set `ssr: false` because live timestamps are time-dependent and would mismatch on hydration. +- UI preferences live in `src/lib/prefs.ts` (useSyncExternalStore + localStorage) instead of zustand to avoid an extra dependency. +- Status colors use `tone-*` utilities and `success/warning/info` tokens in `src/styles.css`, never raw Tailwind palette classes. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..f4f79f4 --- /dev/null +++ b/web/README.md @@ -0,0 +1,38 @@ +# ForgeQueue Console + +Web console for the [ForgeQueue](../README.md) distributed task queue. It reads +through the gRPC gateway's HTTP/JSON API and surfaces queues, workers, delayed +jobs, the dead letter queue and per-interval metrics. + +## Development + +You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating). + +```sh +npm install +npm run dev +``` + +The dev server proxies to the gateway at `VITE_API_BASE_URL` (see `.env.example`). + +## Scripts + +| Script | Description | +| --- | --- | +| `npm run dev` | Start the Vite dev server | +| `npm run build` | Production build (Cloudflare Worker via nitro) | +| `npm run preview` | Preview a built bundle | +| `npm run lint` | ESLint | +| `npm run test` | Vitest | + +## Built with + +- TanStack Start / Router +- TypeScript +- React +- Tailwind CSS +- shadcn/ui + +## Architecture notes + +See [AGENTS.md](./AGENTS.md). diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..d87e6c8 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,1225 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "forgequeue-console", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.101.1", + "@tanstack/react-router": "1.170.41", + "@tanstack/react-start": "1.168.60", + "@tanstack/router-plugin": "1.168.42", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.6.5", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.3.4", + "vaul": "^1.1.2", + "vite-tsconfig-paths": "^6.0.2", + "zod": "^3.25.76", + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.16.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "jsdom": "^20.0.3", + "nitro": "3.0.260603-beta", + "prettier": "^3.7.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.56.1", + "vite": "8.1.5", + "vitest": "^4.1.10", + }, + }, + }, + "overrides": { + "rolldown": "1.2.1", + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "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" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "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" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "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" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "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" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + + "@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], + + "@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="], + + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@hookform/resolvers": ["@hookform/resolvers@5.5.7", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^0.7.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=3.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="], + + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], + + "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], + + "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], + + "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + + "@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.1", "", { "os": "none", "cpu": "arm64" }, "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.1", "", { "dependencies": { "@emnapi/core": "2.0.0-alpha.3", "@emnapi/runtime": "2.0.0-alpha.3", "@napi-rs/wasm-runtime": "^1.2.0" } }, "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start": ["@tanstack/react-start@1.168.60", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/react-start-client": "1.168.39", "@tanstack/react-start-rsc": "0.1.59", "@tanstack/react-start-server": "1.167.46", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-server-core": "1.169.39", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ=="], + + "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.39", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg=="], + + "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.59", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-storage-context": "1.167.36", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw=="], + + "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.46", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-server-core": "1.169.39" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.40", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.42", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.41", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="], + + "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.34", "", { "dependencies": { "@tanstack/router-core": "1.171.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.36", "seroval": "^1.6.7" } }, "sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A=="], + + "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], + + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.49", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-plugin": "1.168.42", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.39", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.7", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg=="], + + "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-storage-context": "1.167.36", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.7" } }, "sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ=="], + + "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.36", "", { "dependencies": { "@tanstack/router-core": "1.171.34" } }, "sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg=="], + + "@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.10.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "abab": ["abab@2.0.6", "", {}, "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-globals": ["acorn-globals@7.0.1", "", { "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" } }, "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ=="], + + "brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + + "cssstyle": ["cssstyle@2.3.0", "", { "dependencies": { "cssom": "~0.3.6" } }, "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "data-urls": ["data-urls@3.0.2", "", { "dependencies": { "abab": "^2.0.6", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0" } }, "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "domexception": ["domexception@4.0.0", "", { "dependencies": { "webidl-conversions": "^7.0.0" } }, "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="], + + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + + "fast-equals": ["fast-equals@5.4.1", "", {}, "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="], + + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h3": ["h3@2.0.1-rc.26", "", { "dependencies": { "rou3": "^0.9.1", "srvx": "^0.12.4" }, "peerDependencies": { "crossws": "^0.4.9" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-GDxlvDsKxgjRvG5UBRJYyGJTMWLV30CJ4cV+e7QCTgftDHihrvio1fVPbNembhEr6J4WNm8IWy3fookgyTweLw=="], + + "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + + "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "httpxy": ["httpxy@0.5.5", "", {}, "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "jsdom": ["jsdom@20.0.3", "", { "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", "html-encoding-sniffer": "^3.0.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.2", "parse5": "^7.1.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", "ws": "^8.11.0", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "canvas": "^2.5.0" }, "optionalPeers": ["canvas"] }, "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "nf3": ["nf3@0.3.23", "", {}, "sha512-RWVLAWozmVD3AaDmaU3qMGB3v+yNlH5d9qqStI4e/WLlNQVnJ4YErGDbYCIrGFyrHdbF6I6Baf0Ae6c7tFYmSg=="], + + "nitro": ["nitro@3.0.260603-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.9", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.3", "srvx": "^0.11.16", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.2.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.60.4", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-ffaSHK00a7YDlDizoEHwcxPwpQpdBRRA8k42ymTsRnfl3ipGeKgv4gnPr6DgmCNTo4tYVPK3bHBEv1gNhWpo/A=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "nwsapi": ["nwsapi@2.2.28", "", {}, "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], + + "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="], + + "rou3": ["rou3@0.9.1", "", {}, "sha512-z/sSmzvtwMDDnxsPVhfWMuG6F6mbmhFDXoVqLmMfbpDD9qfV3GDmSQpf0+W296/ZDIpW2wcMmBfpVFzcnOi/nA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.6.8", "", {}, "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg=="], + + "seroval-plugins": ["seroval-plugins@1.6.8", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], + + "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], + + "tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + + "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], + + "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@4.0.0", "", { "dependencies": { "xml-name-validator": "^4.0.0" } }, "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="], + + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/generator/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/template/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" }, "bundled": true }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tanstack/router-generator/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@tanstack/router-plugin/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@tanstack/start-plugin-core/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "@tanstack/start-plugin-core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "babel-dead-code-elimination/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="], + + "h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="], + + "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "vitest/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/web/bunfig.toml b/web/bunfig.toml new file mode 100644 index 0000000..830f3c5 --- /dev/null +++ b/web/bunfig.toml @@ -0,0 +1,7 @@ +[install] +saveTextLockfile = true +# 24h supply-chain guard: skip package versions published less than a day ago. +minimumReleaseAge = 86400 +# Each entry bypasses the 24h guard for one package — confirm with the user +# before adding any. +minimumReleaseAgeExcludes = [] diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..f0817a8 --- /dev/null +++ b/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..be4075b --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,40 @@ +import js from "@eslint/js"; +import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", ".output", ".vinxi"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "server-only", + message: + "TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.", + }, + ], + }, + ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": "off", + }, + }, + eslintPluginPrettier, +); diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7c22b34 --- /dev/null +++ b/web/package.json @@ -0,0 +1,95 @@ +{ + "name": "forgequeue-console", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "build:dev": "vite build --mode development", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write .", + "test": "vitest run", + "test:watch": "vitest" + }, + "overrides": { + "rolldown": "1.2.1" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.101.1", + "@tanstack/react-router": "1.170.41", + "@tanstack/react-start": "1.168.60", + "@tanstack/router-plugin": "1.168.42", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.6.5", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.3.4", + "vaul": "^1.1.2", + "vite-tsconfig-paths": "^6.0.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.16.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "jsdom": "^20.0.3", + "nitro": "3.0.260603-beta", + "prettier": "^3.7.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.56.1", + "vite": "8.1.5", + "vitest": "^4.1.10" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b804ae3 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..a54a77d --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,77 @@ +import { useSyncExternalStore } from "react"; +import { getPrefs } from "@/lib/prefs"; +import { clearSession, getToken } from "@/lib/auth"; + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public body?: unknown, + ) { + super(message); + } +} + +export type ConnStatus = "connected" | "connecting" | "disconnected" | "error"; +interface ConnState { + status: ConnStatus; + lastSuccessAt: number | null; + lastError: string | null; +} + +let conn: ConnState = { status: "connecting", lastSuccessAt: null, lastError: null }; +const subs = new Set<() => void>(); +function setConn(p: Partial) { + conn = { ...conn, ...p }; + subs.forEach((s) => s()); +} +const serverConn: ConnState = { status: "connecting", lastSuccessAt: null, lastError: null }; +export const useConnection = () => + useSyncExternalStore( + (l) => (subs.add(l), () => subs.delete(l)), + () => conn, + () => serverConn, + ); + +export const getApiBase = () => getPrefs().apiBase.replace(/\/$/, ""); + +// Auth header is injected here from the session store so components never +// deal with tokens directly. +function headers(): HeadersInit { + const h: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + const token = getToken(); + if (token) h["Authorization"] = `Bearer ${token}`; + return h; +} + +export async function request(method: string, path: string, body?: unknown): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10_000); + try { + const res = await fetch(`${getApiBase()}${path}`, { + method, + headers: headers(), + body: body === undefined ? null : JSON.stringify(body), + signal: ctrl.signal, + }); + const text = await res.text(); + const data = text ? JSON.parse(text) : undefined; + if (!res.ok) { + // A rejected token means the session is no longer valid; drop it so the + // root guard sends the user back to the login page. + if (res.status === 401) clearSession(); + throw new ApiError(data?.message ?? res.statusText, res.status, data); + } + setConn({ status: "connected", lastSuccessAt: Date.now(), lastError: null }); + return data as T; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setConn({ status: e instanceof ApiError ? "error" : "disconnected", lastError: msg }); + throw e; + } finally { + clearTimeout(t); + } +} diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts new file mode 100644 index 0000000..db71fad --- /dev/null +++ b/web/src/api/endpoints.ts @@ -0,0 +1,71 @@ +import type { + DelayedJob, + DLQItem, + Job, + MetricsPoint, + QueueSummary, + SystemStats, + Worker, +} from "@/types/forgequeue"; +import type { Session, SessionUser } from "@/lib/auth"; +import { request } from "./client"; + +export type MetricsRange = "5m" | "15m" | "1h" | "6h" | "24h" | "7d"; + +export interface Api { + login(username: string, password: string): Promise; + me(): Promise; + stats(): Promise; + queues(): Promise; + queue(name: string): Promise; + queueJobs(name: string): Promise; + job(id: string): Promise; + retryJob(id: string): Promise; + requeueJob(id: string): Promise; + deleteJob(id: string): Promise; + pauseQueue(name: string): Promise; + resumeQueue(name: string): Promise; + purgeQueue(name: string): Promise<{ purged: number }>; + workers(): Promise; + worker(id: string): Promise; + dlq(): Promise; + retryDlq(id: string): Promise; + deleteDlq(id: string): Promise; + purgeDlq(): Promise<{ purged: number }>; + delayed(): Promise; + promoteDelayed(id: string): Promise; + cancelDelayed(id: string): Promise; + throughput(range: MetricsRange, queue?: string): Promise; + version(): Promise<{ version: string }>; +} + +const e = encodeURIComponent; +const httpApi: Api = { + login: (username, password) => request("POST", "/auth/login", { username, password }), + me: () => request("GET", "/auth/me"), + stats: () => request("GET", "/system/stats"), + queues: () => request("GET", "/queues"), + queue: (n) => request("GET", `/queues/${e(n)}`), + queueJobs: (n) => request("GET", `/queues/${e(n)}/jobs`), + job: (id) => request("GET", `/jobs/${e(id)}`), + retryJob: (id) => request("POST", `/jobs/${e(id)}/retry`), + requeueJob: (id) => request("POST", `/jobs/${e(id)}/requeue`), + deleteJob: (id) => request("DELETE", `/jobs/${e(id)}`), + pauseQueue: (n) => request("POST", `/queues/${e(n)}/pause`), + resumeQueue: (n) => request("POST", `/queues/${e(n)}/resume`), + purgeQueue: (n) => request("POST", `/queues/${e(n)}/purge`), + workers: () => request("GET", "/workers"), + worker: (id) => request("GET", `/workers/${e(id)}`), + dlq: () => request("GET", "/dlq"), + retryDlq: (id) => request("POST", `/dlq/${e(id)}/retry`), + deleteDlq: (id) => request("DELETE", `/dlq/${e(id)}`), + purgeDlq: () => request("POST", "/dlq/purge"), + delayed: () => request("GET", "/delayed"), + promoteDelayed: (id) => request("POST", `/delayed/${e(id)}/promote`), + cancelDelayed: (id) => request("DELETE", `/delayed/${e(id)}`), + throughput: (r, q) => + request("GET", `/metrics/throughput?range=${r}${q ? `&queue=${e(q)}` : ""}`), + version: () => request("GET", "/system/version"), +}; + +export const api: Api = httpApi; diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts new file mode 100644 index 0000000..1f144d7 --- /dev/null +++ b/web/src/api/queries.ts @@ -0,0 +1,73 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + type QueryKey, +} from "@tanstack/react-query"; +import { toast } from "sonner"; +import { refreshMs, usePrefs } from "@/lib/prefs"; +import { api, type MetricsRange } from "./endpoints"; + +export const keys = { + stats: ["system", "stats"] as const, + queues: ["queues"] as const, + queue: (n: string) => ["queue", n] as const, + queueJobs: (n: string) => ["queue", n, "jobs"] as const, + job: (id: string) => ["job", id] as const, + workers: ["workers"] as const, + worker: (id: string) => ["worker", id] as const, + dlq: ["dlq"] as const, + delayed: ["delayed"] as const, + throughput: (r: MetricsRange, q?: string) => ["metrics", "throughput", r, q ?? "all"] as const, +}; + +function usePoll() { + const { autoRefresh, apiBase } = usePrefs(); + return { interval: refreshMs(autoRefresh), scope: apiBase }; +} + +function useQ(key: QueryKey, fn: () => Promise) { + const { interval, scope } = usePoll(); + return useQuery({ + queryKey: [...key, scope], + queryFn: fn, + refetchInterval: interval, + placeholderData: keepPreviousData, + staleTime: 2_000, + gcTime: 60_000, + retry: 1, + }); +} + +export const useStats = () => useQ(keys.stats, api.stats); +export const useQueues = () => useQ(keys.queues, api.queues); +export const useQueue = (n: string) => useQ(keys.queue(n), () => api.queue(n)); +export const useQueueJobs = (n: string) => useQ(keys.queueJobs(n), () => api.queueJobs(n)); +export const useJob = (id: string) => useQ(keys.job(id), () => api.job(id)); +export const useWorkers = () => useQ(keys.workers, api.workers); +export const useWorker = (id: string) => useQ(keys.worker(id), () => api.worker(id)); +export const useDlq = () => useQ(keys.dlq, api.dlq); +export const useDelayed = () => useQ(keys.delayed, api.delayed); +export const useThroughput = (r: MetricsRange, q?: string) => + useQ(keys.throughput(r, q), () => api.throughput(r, q)); + +/** Generic action mutation: runs fn for each id, invalidates everything, toasts. */ +export function useAction(fn: (arg: A) => Promise, label: (arg: A) => string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: (_d, arg) => { + toast.success(label(arg)); + qc.invalidateQueries(); + }, + onError: (e) => toast.error(e instanceof Error ? e.message : "Action failed"), + }); +} + +export function useBulk(fn: (id: string) => Promise, verb: string) { + return useAction( + (ids: string[]) => Promise.all(ids.map(fn)), + (ids) => `${verb} ${ids.length} job${ids.length === 1 ? "" : "s"}`, + ); +} diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx new file mode 100644 index 0000000..1f75a81 --- /dev/null +++ b/web/src/components/app-shell.tsx @@ -0,0 +1,511 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + BarChart3, + Clock, + Cpu, + Layers, + LayoutDashboard, + LogOut, + Monitor, + Moon, + PanelLeft, + Plug, + RefreshCw, + Search, + Settings, + Skull, + Sun, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { setPrefs, usePrefs, type AutoRefresh, type ThemePref } from "@/lib/prefs"; +import { clearSession, useSession, type Permission } from "@/lib/auth"; +import { useConnection, type ConnStatus } from "@/api/client"; +import { useDelayed, useDlq, useQueues, useWorkers } from "@/api/queries"; +import { getPrefs } from "@/lib/prefs"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Kbd, StatusBadge } from "./shared"; + +interface NavEntry { + to: string; + label: string; + icon: typeof Layers; + perm?: Permission; +} + +const NAV: NavEntry[] = [ + { to: "/", label: "Overview", icon: LayoutDashboard, perm: "system:read" }, + { to: "/queues", label: "Queues", icon: Layers, perm: "queues:read" }, + { to: "/workers", label: "Workers", icon: Cpu, perm: "workers:read" }, + { to: "/delayed", label: "Delayed / Scheduled", icon: Clock, perm: "delayed:read" }, + { to: "/dlq", label: "Dead Letter Queue", icon: Skull, perm: "dlq:read" }, + { to: "/metrics", label: "Metrics", icon: BarChart3, perm: "metrics:read" }, +]; +const SYS: { to: string; label: string; icon: typeof Layers }[] = [ + { to: "/settings", label: "Settings", icon: Settings }, + { to: "/connection", label: "API / Connection", icon: Plug }, +]; + +/** Filters nav entries down to those the session is allowed to see. */ +function visibleNav(items: T[], perms: string[]): T[] { + return items.filter((n) => !n.perm || perms.includes(n.perm)); +} + +const CONN_LABEL: Record = { + connected: "Connected", + connecting: "Connecting", + disconnected: "Disconnected", + error: "Error", +}; +const CONN_DOT: Record = { + connected: "bg-success", + connecting: "bg-warning animate-pulse", + disconnected: "bg-muted-foreground", + error: "bg-destructive", +}; + +export function ConnectionDot({ withText = true }: { withText?: boolean }) { + const { status } = useConnection(); + return ( + + + {withText && {CONN_LABEL[status]}} + + ); +} + +function useThemeEffect() { + const { theme } = usePrefs(); + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => + document.documentElement.classList.toggle( + "dark", + theme === "dark" || (theme === "system" && mq.matches), + ); + apply(); + mq.addEventListener("change", apply); + return () => mq.removeEventListener("change", apply); + }, [theme]); +} + +function Logo() { + return ( + + + + + + ); +} + +function NavItem({ + to, + label, + icon: Icon, + collapsed, +}: { + to: string; + label: string; + icon: typeof Layers; + collapsed: boolean; +}) { + const path = useRouterState({ select: (s) => s.location.pathname }); + const active = + to === "/" + ? path === "/" + : path.startsWith(to) || (to === "/queues" && path.startsWith("/jobs")); + const link = ( + + + {!collapsed && {label}} + + ); + return collapsed ? ( + + {link} + {label} + + ) : ( + link + ); +} + +function UserFooter({ collapsed }: { collapsed: boolean }) { + const session = useSession(); + const qc = useQueryClient(); + if (!session) return null; + const { username, roles } = session.user; + + const logout = () => { + clearSession(); + qc.clear(); + }; + + return ( +
+ {!collapsed ? ( + <> +
+
+ {username} +
+
+ {roles.join(", ")} +
+
+ + + + + Sign out + + + ) : ( + + + + + Sign out ({username}) + + )} +
+ ); +} + +function Sidebar({ collapsed }: { collapsed: boolean }) { + const session = useSession(); + const perms = session?.user.permissions ?? []; + const nav = visibleNav(NAV, perms); + return ( + + ); +} + +const THEMES: { v: ThemePref; icon: typeof Sun; label: string }[] = [ + { v: "light", icon: Sun, label: "Light" }, + { v: "dark", icon: Moon, label: "Dark" }, + { v: "system", icon: Monitor, label: "System" }, +]; + +function Topbar({ onToggle, onSearch }: { onToggle: () => void; onSearch: () => void }) { + const prefs = usePrefs(); + const qc = useQueryClient(); + const fetching = useIsFetching(); + const { lastSuccessAt } = useConnection(); + const [, tick] = useState(0); + useEffect(() => { + const t = setInterval(() => tick((x) => x + 1), 1000); + return () => clearInterval(t); + }, []); + const env = prefs.apiBase.startsWith("/") + ? "local-dev" + : prefs.apiBase.replace(/^https?:\/\//, ""); + return ( +
+ + + {env} + + +
+ + 0 && "text-primary")} /> + {lastSuccessAt + ? `updated ${Math.max(0, Math.round((Date.now() - lastSuccessAt) / 1000))}s ago` + : "—"} + + + + + + Refresh now + +
+ {(["off", "5s", "10s", "30s"] as AutoRefresh[]).map((a) => ( + + ))} +
+
+ {THEMES.map(({ v, icon: I, label }) => ( + + ))} +
+ + + +
+
+ ); +} + +function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (o: boolean) => void }) { + const navigate = useNavigate(); + const queues = useQueues().data ?? []; + const workers = useWorkers().data ?? []; + const dlq = useDlq().data ?? []; + const delayed = useDelayed().data ?? []; + const jobs = useMemo( + () => [ + ...dlq.map((d) => ({ + id: d.jobId, + queue: d.originalQueue, + task: d.task, + status: "dlq" as const, + })), + ...delayed.map((d) => ({ id: d.jobId, queue: d.queue, task: d.task, status: d.status })), + ], + [dlq, delayed], + ); + const go = (fn: () => void) => { + setOpen(false); + fn(); + }; + return ( + + + + No results + + {[...NAV, ...SYS].map((n) => ( + go(() => navigate({ to: n.to }))} + className="text-xs" + > + {n.label} + + ))} + + + {queues.map((q) => ( + + go(() => navigate({ to: "/queues/$queueName", params: { queueName: q.name } })) + } + className="text-xs" + > + {q.name} + + + + + ))} + + + {workers.map((w) => ( + + go(() => navigate({ to: "/workers/$workerId", params: { workerId: w.id } })) + } + className="text-xs" + > + {w.id} + {w.hostname} + + + + + ))} + + + {jobs.map((j) => ( + go(() => navigate({ to: "/jobs/$jobId", params: { jobId: j.id } }))} + className="text-xs" + > + {j.id.slice(0, 13)} + + {j.queue} · {j.task} + + + + + + ))} + + + + ); +} + +export function AppShell({ children }: { children: ReactNode }) { + useThemeEffect(); + const [collapsed, setCollapsed] = useState(false); + const [open, setOpen] = useState(false); + useEffect(() => { + getPrefs(); + const h = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + } + }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, []); + return ( +
+ +
+ setCollapsed(!collapsed)} onSearch={() => setOpen(true)} /> +
{children}
+
+ +
+ ); +} diff --git a/web/src/components/can.tsx b/web/src/components/can.tsx new file mode 100644 index 0000000..880147a --- /dev/null +++ b/web/src/components/can.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { useHasPermission, type Permission } from "@/lib/auth"; + +/** + * Renders children only when the current session holds `perm`. This is a UX + * affordance, not a security boundary: the API enforces the same permissions + * server-side, so hiding a control never grants or denies access on its own. + */ +export function Can({ perm, children }: { perm: Permission; children: ReactNode }) { + return useHasPermission(perm) ? <>{children} : null; +} diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx new file mode 100644 index 0000000..33d5677 --- /dev/null +++ b/web/src/components/charts.tsx @@ -0,0 +1,112 @@ +import { + Area, + AreaChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { format } from "date-fns"; +import type { MetricsPoint } from "@/types/forgequeue"; + +export interface SeriesDef { + key: keyof MetricsPoint; + label: string; + color: string; +} + +export const THROUGHPUT: SeriesDef[] = [ + { key: "enqueued", label: "enqueued", color: "var(--chart-1)" }, + { key: "completed", label: "completed", color: "var(--chart-2)" }, + { key: "failed", label: "failed", color: "var(--chart-3)" }, +]; + +const axis = { + stroke: "var(--muted-foreground)", + fontSize: 10, + tickLine: false, + axisLine: false, +} as const; + +export function TimeChart({ + data, + series, + height = 180, + area, +}: { + data: MetricsPoint[] | undefined; + series: SeriesDef[]; + height?: number; + area?: boolean | undefined; +}) { + if (!data) return
; + const C = area ? AreaChart : LineChart; + return ( +
+ + + + format(new Date(t), "HH:mm")} + minTickGap={40} + /> + + format(new Date(t), "yyyy-MM-dd HH:mm:ss")} + /> + {series.map((s) => + area ? ( + + ) : ( + + ), + )} + + +
+ ); +} + +export function Legend({ series }: { series: SeriesDef[] }) { + return ( +
+ {series.map((s) => ( + + + {s.label} + + ))} +
+ ); +} diff --git a/web/src/components/confirm-dialog.tsx b/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..6e11459 --- /dev/null +++ b/web/src/components/confirm-dialog.tsx @@ -0,0 +1,76 @@ +import { useState, type ReactNode } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface Props { + trigger: ReactNode; + title: string; + description: ReactNode; + confirmLabel: string; + destructive?: boolean; + /** Require typing this string before confirming (for irreversible bulk ops). */ + typeToConfirm?: string; + onConfirm: () => void; +} + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel, + destructive, + typeToConfirm, + onConfirm, +}: Props) { + const [typed, setTyped] = useState(""); + return ( + setTyped("")}> + {trigger} + + + {title} + {description} + + {typeToConfirm && ( + + )} + + + Cancel + + + {confirmLabel} + + + + + ); +} diff --git a/web/src/components/data-table.tsx b/web/src/components/data-table.tsx new file mode 100644 index 0000000..4ee7cef --- /dev/null +++ b/web/src/components/data-table.tsx @@ -0,0 +1,234 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { usePrefs } from "@/lib/prefs"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "./shared"; + +export interface Column { + key: string; + header: ReactNode; + cell: (row: T) => ReactNode; + sort?: (row: T) => string | number; + className?: string; + align?: "right"; +} + +interface Props { + rows: T[] | undefined; + columns: Column[]; + getId: (row: T) => string; + selected?: Set; + onSelectedChange?: (s: Set) => void; + onRowClick?: (row: T) => void; + rowClassName?: (row: T) => string | undefined; + pageSizes?: number[]; + emptyTitle?: string; + emptyDescription?: string; + defaultSort?: { key: string; dir: "asc" | "desc" }; + maxHeight?: string; +} + +export function DataTable({ + rows, + columns, + getId, + selected, + onSelectedChange, + onRowClick, + rowClassName, + pageSizes = [25, 50, 100], + emptyTitle = "No rows", + emptyDescription, + defaultSort, + maxHeight = "70vh", +}: Props) { + const { density } = usePrefs(); + const [sort, setSort] = useState(defaultSort); + const [size, setSize] = useState(pageSizes[0] ?? 25); + const [page, setPage] = useState(0); + + const sorted = useMemo(() => { + if (!rows) return []; + const col = columns.find((c) => c.key === sort?.key); + if (!col?.sort) return rows; + const f = col.sort; + return [...rows].sort((a, b) => { + const x = f(a), + y = f(b); + const r = x < y ? -1 : x > y ? 1 : 0; + return sort!.dir === "asc" ? r : -r; + }); + }, [rows, sort, columns]); + + const pages = Math.max(1, Math.ceil(sorted.length / size)); + const p = Math.min(page, pages - 1); + const view = sorted.slice(p * size, p * size + size); + const selectable = !!onSelectedChange && !!selected; + const allOnPage = view.length > 0 && view.every((r) => selected?.has(getId(r))); + + const toggle = (id: string) => { + const s = new Set(selected); + if (s.has(id)) { + s.delete(id); + } else { + s.add(id); + } + onSelectedChange?.(s); + }; + + return ( +
+
+ + + + {selectable && ( + + )} + {columns.map((c) => ( + + ))} + + + + {rows === undefined + ? Array.from({ length: 6 }, (_, i) => ( + + + + )) + : view.map((r) => { + const id = getId(r); + return ( + onRowClick(r) : undefined} + className={cn( + onRowClick && "cursor-pointer", + selected?.has(id) && "bg-accent/60", + rowClassName?.(r), + )} + > + {selectable && ( + + )} + {columns.map((c) => ( + + ))} + + ); + })} + +
+ { + const s = new Set(selected); + view.forEach((r) => (v ? s.add(getId(r)) : s.delete(getId(r)))); + onSelectedChange!(s); + }} + /> + + {c.sort ? ( + + ) : ( + c.header + )} +
+
+
e.stopPropagation()}> + toggle(id)} + /> + + {c.cell(r)} +
+ {rows && rows.length === 0 && ( + + )} +
+ {rows && rows.length > (pageSizes[0] ?? 25) && ( +
+ + + {p * size + 1}–{Math.min(sorted.length, (p + 1) * size)} of {sorted.length} + + + +
+ )} +
+ ); +} diff --git a/web/src/components/job-tables.tsx b/web/src/components/job-tables.tsx new file mode 100644 index 0000000..15a0d7c --- /dev/null +++ b/web/src/components/job-tables.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { RotateCcw, Trash2, Zap, X } from "lucide-react"; +import { api } from "@/api/endpoints"; +import { useAction, useBulk } from "@/api/queries"; +import { DataTable } from "./data-table"; +import { ConfirmDialog } from "./confirm-dialog"; +import { Duration, JobId, Mono, StatusBadge, TimeAgo, useNow } from "./shared"; +import { Button } from "@/components/ui/button"; +import { fmtDuration } from "@/lib/format"; +import type { DelayedJob, Job } from "@/types/forgequeue"; + +export function BulkBar({ + count, + children, + onClear, +}: { + count: number; + children: React.ReactNode; + onClear: () => void; +}) { + if (!count) return null; + return ( +
+ {count} selected + {children} + +
+ ); +} + +export function JobsTable({ jobs, showQueue }: { jobs: Job[] | undefined; showQueue?: boolean }) { + const [sel, setSel] = useState>(new Set()); + const retry = useBulk(api.retryJob, "Retried"); + const del = useBulk(api.deleteJob, "Deleted"); + useNow(5000); + const ids = [...sel]; + return ( + <> + setSel(new Set())}> + + + Delete + + } + destructive + title={`Delete ${sel.size} jobs?`} + description={`This permanently removes ${sel.size} jobs from Redis and PostgreSQL. This cannot be undone.`} + confirmLabel={`Delete ${sel.size} jobs`} + onConfirm={() => (del.mutate(ids), setSel(new Set()))} + /> + + j.id} + selected={sel} + onSelectedChange={setSel} + defaultSort={{ key: "created", dir: "desc" }} + emptyTitle="No jobs" + columns={[ + { key: "id", header: "Job ID", cell: (j) => }, + ...(showQueue + ? [ + { + key: "queue", + header: "Queue", + sort: (j: Job) => j.queue, + cell: (j: Job) => {j.queue}, + }, + ] + : []), + { key: "task", header: "Task", sort: (j) => j.task, cell: (j) => {j.task} }, + { + key: "status", + header: "Status", + sort: (j) => j.status, + cell: (j) => , + }, + { + key: "att", + header: "Attempts", + align: "right", + sort: (j) => j.attempts, + cell: (j) => `${j.attempts}/${j.maxAttempts}`, + }, + { + key: "created", + header: "Created", + sort: (j) => j.createdAt, + cell: (j) => , + }, + { + key: "enq", + header: "Enqueued", + sort: (j) => j.enqueuedAt ?? "", + cell: (j) => , + }, + { + key: "age", + header: "Age", + align: "right", + sort: (j) => -Date.parse(j.createdAt), + cell: (j) => fmtDuration(Date.now() - Date.parse(j.createdAt)), + }, + { + key: "worker", + header: "Worker", + cell: (j) => + j.workerId ? ( + e.stopPropagation()} + > + {j.workerId} + + ) : ( + — + ), + }, + { + key: "a", + header: "", + align: "right", + cell: (j) => + ["failed", "retrying", "cancelled"].includes(j.status) ? ( + + ) : null, + }, + ]} + /> + + ); +} + +export function DelayedTable({ + rows, + showQueue = true, +}: { + rows: DelayedJob[] | undefined; + showQueue?: boolean; +}) { + const now = useNow(1000); + const promote = useAction(api.promoteDelayed, (id) => `Promoted ${id.slice(0, 8)} to pending`); + const cancel = useAction(api.cancelDelayed, (id) => `Cancelled ${id.slice(0, 8)}`); + return ( + d.jobId} + defaultSort={{ key: "sched", dir: "asc" }} + emptyTitle="No delayed jobs" + columns={[ + { key: "id", header: "Job ID", cell: (d) => }, + ...(showQueue + ? [ + { + key: "queue", + header: "Queue", + sort: (d: DelayedJob) => d.queue, + cell: (d: DelayedJob) => {d.queue}, + }, + ] + : []), + { key: "task", header: "Task", sort: (d) => d.task, cell: (d) => {d.task} }, + { key: "att", header: "Attempts", align: "right", cell: (d) => d.attempts }, + { + key: "sched", + header: "Scheduled for", + sort: (d) => d.scheduledFor, + cell: (d) => , + }, + { + key: "rem", + header: "Delay remaining", + align: "right", + sort: (d) => Date.parse(d.scheduledFor), + cell: (d) => { + const r = Date.parse(d.scheduledFor) - now; + return ( + + {r <= 0 ? "due" : } + + ); + }, + }, + { key: "st", header: "Status", cell: (d) => }, + { + key: "a", + header: "Actions", + align: "right", + cell: (d) => ( + + + Promote + + } + title="Promote job now?" + description={ + <> + Job {d.jobId} will be moved to{" "} + {d.queue} pending immediately, skipping its + scheduled delay. + + } + confirmLabel="Promote now" + onConfirm={() => promote.mutate(d.jobId)} + /> + + Cancel + + } + destructive + title="Cancel scheduled job?" + description={ + <> + Job {d.jobId} will be marked cancelled and + never run. + + } + confirmLabel="Cancel job" + onConfirm={() => cancel.mutate(d.jobId)} + /> + + ), + }, + ]} + /> + ); +} diff --git a/web/src/components/json-viewer.tsx b/web/src/components/json-viewer.tsx new file mode 100644 index 0000000..fc01d21 --- /dev/null +++ b/web/src/components/json-viewer.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { CopyButton } from "./shared"; + +function Node({ + k, + v, + depth, + open, +}: { + k?: string | undefined; + v: unknown; + depth: number; + open: boolean; +}) { + const [expanded, setExpanded] = useState(open || depth < 2); + const isObj = v !== null && typeof v === "object"; + const keyEl = k !== undefined && "{k}"; + if (!isObj) { + const cls = + typeof v === "string" + ? "text-success" + : typeof v === "number" + ? "text-warning" + : "text-muted-foreground"; + return ( +
+ {keyEl} + {keyEl && ": "} + {JSON.stringify(v)} + + + +
+ ); + } + const entries = Array.isArray(v) + ? v.map((x, i) => [String(i), x] as const) + : Object.entries(v as object); + const [o, c] = Array.isArray(v) ? ["[", "]"] : ["{", "}"]; + return ( +
+ + {expanded && ( + <> + {entries.map(([ck, cv]) => ( + + ))} +
{c}
+ + )} +
+ ); +} + +export function JsonViewer({ value }: { value: unknown }) { + const [raw, setRaw] = useState(false); + const [gen, setGen] = useState(0); + const [allOpen, setAllOpen] = useState(false); + const text = JSON.stringify(value, null, 2); + return ( +
+
+ {(["tree", "raw"] as const).map((m) => ( + + ))} + {!raw && ( + + )} + + {text?.length ?? 0} B + +
+
+ {raw ?
{text}
: } +
+
+ ); +} diff --git a/web/src/components/shared.tsx b/web/src/components/shared.tsx new file mode 100644 index 0000000..ac0f5e2 --- /dev/null +++ b/web/src/components/shared.tsx @@ -0,0 +1,297 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { Check, Copy, Inbox } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { fmtDuration, fmtNum } from "@/lib/format"; +import { usePrefs } from "@/lib/prefs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { JobStatus, QueueStatus, WorkerStatus } from "@/types/forgequeue"; + +type Tone = "success" | "warning" | "danger" | "info" | "neutral"; +const toneClass: Record = { + success: "tone-success", + warning: "tone-warning", + danger: "tone-danger", + info: "tone-info", + neutral: "tone-neutral", +}; +const textTone: Record = { + success: "text-success", + warning: "text-warning", + danger: "text-destructive", + info: "text-info", + neutral: "", +}; +const STATUS_TONE: Record = { + pending: "neutral", + processing: "info", + completed: "success", + failed: "danger", + retrying: "warning", + delayed: "neutral", + dlq: "danger", + cancelled: "neutral", + healthy: "success", + paused: "warning", + degraded: "warning", + unknown: "neutral", + idle: "neutral", + busy: "info", + stale: "warning", + disconnected: "danger", +}; + +export function StatusBadge({ status }: { status: JobStatus | QueueStatus | WorkerStatus }) { + return ( + + + {status} + + ); +} + +export function PageHeader({ + title, + description, + actions, + meta, +}: { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + meta?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} + {meta &&
{meta}
} +
+ {actions &&
{actions}
} +
+ ); +} + +export function Section({ + title, + actions, + children, + className, + flush, +}: { + title: ReactNode; + actions?: ReactNode; + children: ReactNode; + className?: string; + flush?: boolean; +}) { + return ( +
+
+

{title}

+ {actions &&
{actions}
} +
+
{children}
+
+ ); +} + +export function StatCard({ + label, + value, + tone, + to, + hint, +}: { + label: string; + value: number | string | undefined; + tone?: Tone | undefined; + to?: string; + hint?: string | undefined; +}) { + const body = ( +
+
{label}
+
+ {value === undefined ? ( + + ) : typeof value === "number" ? ( + fmtNum(value) + ) : ( + value + )} +
+ {hint &&
{hint}
} +
+ ); + return to ? {body} : body; +} + +/** Ticks every second so relative times stay live. */ +export function useNow(ms = 1000) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), ms); + return () => clearInterval(t); + }, [ms]); + return now; +} + +export function TimeAgo({ iso, future }: { iso: string | null | undefined; future?: boolean }) { + const { relativeTime } = usePrefs(); + useNow(5000); + if (!iso) return —; + const d = new Date(iso); + const rel = formatDistanceToNowStrict(d, { addSuffix: true }); + const abs = format(d, "yyyy-MM-dd HH:mm:ss"); + return ( + + + + + + {relativeTime ? `${abs} · ${iso}` : rel} + + + ); +} + +export const Duration = ({ ms }: { ms: number | null | undefined }) => ( + {fmtDuration(ms)} +); + +export function CopyButton({ value, label = "Copy" }: { value: string; label?: string }) { + const [done, setDone] = useState(false); + return ( + + + + + {label} + + ); +} + +export function JobId({ id, full, link = true }: { id: string; full?: boolean; link?: boolean }) { + const text = full ? id : id.slice(0, 13); + return ( + + {link ? ( + e.stopPropagation()} + title={id} + > + {text} + + ) : ( + {text} + )} + + + ); +} + +export const Mono = ({ children, className }: { children: ReactNode; className?: string }) => ( + {children} +); + +export function EmptyState({ + title, + description, + icon: Icon = Inbox, +}: { + title: string; + description?: string | undefined; + icon?: typeof Inbox; +}) { + return ( +
+ +
{title}
+ {description &&
{description}
} +
+ ); +} + +export const Kbd = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +export function KV({ items }: { items: [string, ReactNode][] }) { + return ( +
+ {items.map(([k, v]) => ( +
+
{k}
+
+ {v ?? —} +
+
+ ))} +
+ ); +} + +export function ErrorBlock({ + message, + stack, + code, +}: { + message: string; + stack?: string; + code?: string; +}) { + return ( +
+
+ + {code && {code}} + {message} + + +
+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/web/src/components/toolbar.tsx b/web/src/components/toolbar.tsx new file mode 100644 index 0000000..4d1ea97 --- /dev/null +++ b/web/src/components/toolbar.tsx @@ -0,0 +1,71 @@ +import { Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { setPrefs, usePrefs } from "@/lib/prefs"; + +export function Toolbar({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function SearchInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( + + ); +} + +export function FilterSelect({ + value, + onChange, + options, + label, +}: { + value: string; + onChange: (v: string) => void; + options: string[]; + label: string; +}) { + return ( + + ); +} + +export function DensityToggle() { + const { density } = usePrefs(); + return ( + + ); +} diff --git a/web/src/components/ui/accordion.tsx b/web/src/components/ui/accordion.tsx new file mode 100644 index 0000000..16ee900 --- /dev/null +++ b/web/src/components/ui/accordion.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..072a665 --- /dev/null +++ b/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx new file mode 100644 index 0000000..cd0a062 --- /dev/null +++ b/web/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/web/src/components/ui/aspect-ratio.tsx b/web/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/web/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..7904926 --- /dev/null +++ b/web/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..3aabd17 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/web/src/components/ui/breadcrumb.tsx b/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..94eb629 --- /dev/null +++ b/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>