From 398f42c43d4e7715c0f52e7e6d657976f872c224 Mon Sep 17 00:00:00 2001 From: Richard Quaicoe Date: Tue, 24 Mar 2026 10:01:23 +0000 Subject: [PATCH] Fix: Fixed multi service openapi bugs and added multi service global management --- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/changelog.md | 2 +- docs/contributing.md | 7 +- docs/guides/shield-server.md | 110 +++++ docs/index.md | 4 +- docs/reference/cli.md | 165 ++++++- docs/reference/engine.md | 76 ++++ docs/reference/rate-limiting.md | 216 ++++++++- docs/tutorial/rate-limiting.md | 27 ++ pyproject.toml | 2 +- shield/admin/api.py | 126 ++++- shield/admin/app.py | 76 ++++ shield/cli/client.py | 87 ++++ shield/cli/main.py | 286 ++++++++++++ shield/core/backends/base.py | 63 +++ shield/core/backends/server.py | 263 ++++++++--- shield/core/engine.py | 429 +++++++++++++++++- shield/dashboard/app.py | 15 + shield/dashboard/routes.py | 229 ++++++++++ shield/dashboard/static/shield.min.css | 2 +- shield/dashboard/templates/base.html | 30 +- shield/dashboard/templates/index.html | 36 +- .../partials/global_maintenance.html | 6 +- .../templates/partials/global_rl_card.html | 6 +- .../partials/modal_service_disable.html | 51 +++ .../partials/modal_service_enable.html | 95 ++++ .../templates/partials/modal_service_rl.html | 131 ++++++ .../partials/modal_service_rl_delete.html | 41 ++ .../partials/modal_service_rl_reset.html | 41 ++ .../partials/service_maintenance.html | 75 +++ .../templates/partials/service_rl_card.html | 135 ++++++ shield/dashboard/templates/rate_limits.html | 39 +- shield/sdk/__init__.py | 33 +- uv.lock | 16 +- 35 files changed, 2806 insertions(+), 118 deletions(-) create mode 100644 shield/dashboard/templates/partials/modal_service_disable.html create mode 100644 shield/dashboard/templates/partials/modal_service_enable.html create mode 100644 shield/dashboard/templates/partials/modal_service_rl.html create mode 100644 shield/dashboard/templates/partials/modal_service_rl_delete.html create mode 100644 shield/dashboard/templates/partials/modal_service_rl_reset.html create mode 100644 shield/dashboard/templates/partials/service_maintenance.html create mode 100644 shield/dashboard/templates/partials/service_rl_card.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a67d7a..19fdc6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,5 +135,5 @@ These are hard constraints enforced by the project design. PRs that violate them 2. **All business logic lives in `ShieldEngine`.** Middleware and decorators are transport layers only. 3. **Decorators stamp `__shield_meta__` and do nothing else** — no logic at request time. 4. **`engine.check()` is the single chokepoint** — never duplicate the check logic elsewhere. -5. **Backends must implement the full `ShieldBackend` ABC** — no partial implementations. +5. **Backends must implement the full `ShieldBackend` ABC** — no partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError`. `ShieldEngine.start()` catches this internally and skips the listener — the engine handles the fallback, not the caller. 6. **Fail-open** — if the backend is unreachable, the request passes through. Shield never takes down an API. diff --git a/README.md b/README.md index d9acb41..d46f4e0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
API Shield -

Route(API) lifecycle management for ASGI Python web frameworks — maintenance mode, environment gating, deprecation, rate limiting, feature flags, admin panels, and more. No restarts required.

+

Feature flags and runtime control for Python APIs — rollouts, rate limits, manage maintenance windows across single ASGI services or a multi-service fleet without redeploying.

PyPI Python versions diff --git a/docs/changelog.md b/docs/changelog.md index 43a6a03..dd24cff 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- -## [Unreleased] +## [0.8.0] ### Fixed diff --git a/docs/contributing.md b/docs/contributing.md index 5cc3379..1ce8067 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -164,7 +164,7 @@ These constraints are enforced at review time. PRs that violate them will be ask Every request path must flow through `engine.check()`. Never duplicate the check logic in middleware, a dependency, or a decorator. 5. **Backends implement the full `ShieldBackend` ABC.** - No partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError` and callers handle the fallback. + No partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError`. `ShieldEngine.start()` catches this internally and skips the listener — the engine handles the fallback, not the caller. 6. **Fail-open on backend errors.** If `backend.get_state()` raises, `engine.check()` logs the error and lets the request through. Shield must never take down an API because its own storage is temporarily unavailable. @@ -191,11 +191,14 @@ shield/ ├── core/ # Zero framework dependencies — engine, models, backends │ ├── engine.py # ShieldEngine — all business logic lives here │ ├── models.py # RouteState, AuditEntry, RateLimitPolicy, … -│ ├── backends/ # MemoryBackend, FileBackend, RedisBackend +│ ├── backends/ # MemoryBackend, FileBackend, RedisBackend, ShieldServerBackend │ ├── rate_limit/ # Rate limiting subsystem │ └── scheduler.py # asyncio-based maintenance window scheduler ├── fastapi/ # FastAPI adapter — middleware, decorators, router, OpenAPI ├── admin/ # Unified admin ASGI app (dashboard UI + REST API + auth) +├── server/ # ShieldServer — standalone control plane for multi-service deployments +├── sdk/ # ShieldSDK — service-side client that connects to a Shield Server via SSE +├── adapters/ # Framework adapter helpers (ASGI base, future adapter scaffolding) ├── dashboard/ # HTMX/Jinja2 templates and static assets │ ├── templates/ # Edit these, then run `npm run build:css` │ └── static/ # shield.min.css lives here — commit after rebuilding diff --git a/docs/guides/shield-server.md b/docs/guides/shield-server.md index cfb2f34..cfe676d 100644 --- a/docs/guides/shield-server.md +++ b/docs/guides/shield-server.md @@ -164,6 +164,81 @@ shield global disable --reason "emergency maintenance" shield global enable ``` +--- + +## Global maintenance in multi-service environments + +`shield global enable` and `shield global disable` operate on the Shield Server and affect **every route across every connected service** simultaneously. Use this for fleet-wide outages or deployments where all services must be taken offline at once. + +```bash +# Block all routes on all services +shield global enable --reason "Deploying v3" --exempt /health --exempt /ready + +# Restore all routes +shield global disable +``` + +--- + +## Per-service maintenance + +Per-service maintenance puts **all routes of one service** into maintenance mode without affecting other services. SDK clients with a matching `app_id` receive the sentinel over SSE and treat all their routes as in maintenance — no individual route changes needed. + +### From the engine (programmatic) + +```python +# Put payments-service into maintenance +await engine.enable_service_maintenance( + "payments-service", + reason="DB migration", + exempt_paths=["/health"], +) + +# Restore +await engine.disable_service_maintenance("payments-service") + +# Inspect +cfg = await engine.get_service_maintenance("payments-service") +print(cfg.enabled, cfg.reason) +``` + +### From the REST API + +```http +POST /api/services/payments-service/maintenance/enable +Authorization: Bearer +Content-Type: application/json + +{"reason": "DB migration", "exempt_paths": ["/health"]} +``` + +```http +POST /api/services/payments-service/maintenance/disable +``` + +### From the CLI + +`shield sm` and `shield service-maintenance` are aliases for the same command group: + +```bash +# Enable — all routes of payments-service return 503 +shield sm enable payments-service --reason "DB migration" +shield sm enable payments-service --reason "Upgrade" --exempt /health + +# Check current state +shield sm status payments-service + +# Restore +shield sm disable payments-service +``` + +### From the dashboard + +Open the Routes page and select the service using the service filter. A **Service Maintenance** card appears with Enable and Disable controls. + +!!! tip "Use `@force_active` on health checks" + Health and readiness endpoints should always be decorated with `@force_active` so they are never affected by global or per-service maintenance mode. + ### Useful discovery commands ```bash @@ -227,6 +302,41 @@ Use a **different Redis database** (or a different Redis instance) from the one --- +## Per-service rate limits + +A per-service rate limit applies a single policy to **all routes of one service** without configuring each route individually. It is checked after the all-services global rate limit and before any per-route limit: + +``` +global maintenance -> service maintenance -> global rate limit -> service rate limit -> per-route rate limit +``` + +Configure it with the `shield srl` CLI (also available as `shield service-rate-limit`): + +```bash +# Cap all payments-service routes at 1000 per minute per IP +shield srl set payments-service 1000/minute + +# With options +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics + +# Inspect, pause, reset counters, remove +shield srl get payments-service +shield srl disable payments-service +shield srl enable payments-service +shield srl reset payments-service +shield srl delete payments-service +``` + +From the dashboard: open the **Rate Limits** tab and select a service using the service filter. A **Service Rate Limit** card appears above the policies table with controls to configure, pause, reset, and remove the policy. + +The service rate limit uses the same `GlobalRateLimitPolicy` model as the all-services global rate limit. It is stored in the backend under a sentinel key and survives Shield Server restarts on `FileBackend` or `RedisBackend`. + +!!! note "Independent layers" + The all-services global rate limit (`shield grl`) and the per-service rate limit (`shield srl`) are independent. A request must pass both before reaching per-route checking. You can configure one, both, or neither. + +--- + ## SSE event types The Shield Server's `GET /api/sdk/events` stream carries two event types: diff --git a/docs/index.md b/docs/index.md index ac16059..5abe2fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,9 +13,9 @@ !!! warning "Early Access: your feedback shapes the roadmap" `api-shield` is fully functional and ready to use. We are actively building on a solid foundation and would love to hear from you. If you have feedback, feature ideas, or suggestions, **[open an issue on GitHub](https://github.com/Attakay78/api-shield/issues)**. Every voice helps make the library better for everyone. -**Route(API) lifecycle management for ASGI Python web frameworks: maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.** +**Feature flags and runtime control for Python APIs — rollouts, rate limits, manage maintenance windows across single ASGI services or a multi-service fleet without redeploying.** -Most "route lifecycle management" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately through middleware, with no redeployment and no server restart. +Most "runtime control management" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately, with no redeployment and no server restart. --- diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a00be39..f7110d3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -282,6 +282,75 @@ shield global exempt-remove /monitoring/ping --- +## `shield sm` / `shield service-maintenance` + +`shield sm` and `shield service-maintenance` are aliases for the same command group. Puts all routes of one service into maintenance mode without affecting other services. The affected SDK client's `app_id` must match the service name. + +```bash +shield sm enable payments-service --reason "DB migration" +shield service-maintenance enable payments-service # identical +``` + +### `shield sm status` + +Show the current maintenance configuration for a service. + +```bash +shield sm status +``` + +```bash +shield sm status payments-service +``` + +**Example output:** + +``` + Service maintenance (payments-service): ON + Reason : DB migration + Include @force_active: no + Exempt paths : + • /health +``` + +--- + +### `shield sm enable` + +Block all routes of a service immediately. Routes return `503` until `shield sm disable` is called. + +```bash +shield sm enable +``` + +```bash +shield sm enable payments-service --reason "DB migration" +shield sm enable payments-service --reason "Upgrade" --exempt /health --exempt GET:/ready +shield sm enable orders-service --include-force-active +``` + +| Option | Description | +|---|---| +| `--reason TEXT` | Shown in every 503 response while maintenance is active | +| `--exempt PATH` | Exempt a path from the block (repeatable). Use bare `/health` or `GET:/health`. | +| `--include-force-active` | Also block `@force_active` routes. Use with care. | + +--- + +### `shield sm disable` + +Restore all routes of a service to their individual states. + +```bash +shield sm disable +``` + +```bash +shield sm disable payments-service +``` + +--- + ## Rate limit commands `shield rl` and `shield rate-limits` are aliases for the same command group — use whichever you prefer. Requires `api-shield[rate-limit]` on the server. @@ -450,11 +519,105 @@ shield grl disable --- +## `shield srl` / `shield service-rate-limit` + +`shield srl` and `shield service-rate-limit` are aliases for the same command group. Manages the rate limit policy for a single service — applies to all routes of that service. Requires `api-shield[rate-limit]` on the server. + +```bash +shield srl get payments-service +shield service-rate-limit get payments-service # identical +``` + +### `shield srl get` + +Show the current rate limit policy for a service, including limit, algorithm, key strategy, burst, exempt routes, and enabled state. + +```bash +shield srl get +``` + +```bash +shield srl get payments-service +``` + +--- + +### `shield srl set` + +Configure the rate limit for a service. Creates a new policy or replaces the existing one. + +```bash +shield srl set +``` + +```bash +shield srl set payments-service 1000/minute +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path (`/health`) or method-prefixed (`GET:/metrics`) | + +--- + +### `shield srl delete` + +Remove the service rate limit policy entirely. + +```bash +shield srl delete +``` + +```bash +shield srl delete payments-service +``` + +--- + +### `shield srl reset` + +Clear all counters for the service. The policy is kept; clients get their full quota back on the next request. + +```bash +shield srl reset +``` + +```bash +shield srl reset payments-service +``` + +--- + +### `shield srl enable` + +Resume a paused service rate limit policy. + +```bash +shield srl enable +``` + +--- + +### `shield srl disable` + +Pause the service rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield srl disable +``` + +--- + ## Audit log ### `shield log` -Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label for rate limit policy changes (including global RL actions such as `global set`, `global reset`, `global enabled`, `global disabled`). +Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label for rate limit policy changes (including global RL actions such as `global set`, `global reset`, `global enabled`, `global disabled`, and service RL actions such as `svc set`, `svc reset`, `svc enabled`, `svc disabled`). The `Path` column shows human-readable labels for sentinel-keyed entries: `[Global Maintenance]`, `[Global Rate Limit]`, `[{service} Maintenance]`, and `[{service} Rate Limit]`. ```bash shield log # page 1, 20 rows diff --git a/docs/reference/engine.md b/docs/reference/engine.md index 4dca111..67e2803 100644 --- a/docs/reference/engine.md +++ b/docs/reference/engine.md @@ -440,6 +440,82 @@ Update the exempt path list while global maintenance is active, without toggling --- +## Per-service maintenance + +Per-service maintenance puts **all routes of one service** into maintenance mode at once, without touching other services or requiring individual route changes. It uses the same `GlobalMaintenanceConfig` model and exempt-paths mechanism as all-services global maintenance, but is scoped to a single `app_id`. + +Available via the engine, the `shield sm` CLI command group, the REST API (`POST /api/services/{service}/maintenance/enable|disable`), and the dashboard Routes page when a service filter is active. + +Audit log actions: `service_maintenance_on` (enabled), `service_maintenance_off` (disabled). The `Path` column displays as `[{service} Maintenance]`. + +### `enable_service_maintenance` + +```python +async def enable_service_maintenance( + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", +) -> GlobalMaintenanceConfig +``` + +Block all routes for *service* immediately. SDK clients with a matching `app_id` receive the sentinel via SSE and treat every request as if their own global maintenance were enabled. Exempt paths respond normally. + +```python title="example" +await engine.enable_service_maintenance( + "payments-service", + reason="DB migration", + exempt_paths=["/health"], + actor="alice", +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `service` | `str` | required | The service `app_id` to put into maintenance | +| `reason` | `str` | `""` | Shown in every 503 response while maintenance is active | +| `exempt_paths` | `list[str] \| None` | `None` | Paths that bypass the service maintenance check. Bare path or `METHOD:/path`. | +| `include_force_active` | `bool` | `False` | When `True`, even `@force_active` routes on this service are blocked | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | + +--- + +### `disable_service_maintenance` + +```python +async def disable_service_maintenance( + service: str, + actor: str = "system", + platform: str = "system", +) -> GlobalMaintenanceConfig +``` + +Restore all routes of *service* to their individual states. + +```python title="example" +await engine.disable_service_maintenance("payments-service", actor="alice") +``` + +--- + +### `get_service_maintenance` + +```python +async def get_service_maintenance(service: str) -> GlobalMaintenanceConfig +``` + +Return the current per-service maintenance configuration. Returns a `GlobalMaintenanceConfig` with `enabled=False` when no sentinel is stored for this service. + +```python title="example" +cfg = await engine.get_service_maintenance("payments-service") +if cfg.enabled: + print(f"payments-service in maintenance: {cfg.reason}") +``` + +--- + ## Audit log ### `get_audit_log` diff --git a/docs/reference/rate-limiting.md b/docs/reference/rate-limiting.md index e9df8f4..35faa94 100644 --- a/docs/reference/rate-limiting.md +++ b/docs/reference/rate-limiting.md @@ -460,6 +460,109 @@ The **Rate Limits** page includes a Global Rate Limit card above the policies ta --- +## Per-service rate limit + +A per-service rate limit applies a `GlobalRateLimitPolicy` to **all routes of one service**. It sits between the all-services global rate limit and per-route limits in the enforcement chain: + +``` +global maintenance -> service maintenance -> global rate limit -> service rate limit -> per-route rate limit +``` + +Uses the same `GlobalRateLimitPolicy` model as the all-services global rate limit. The policy is persisted in the backend via a sentinel key so it survives restarts. + +### Engine methods + +#### `set_service_rate_limit` + +```python +async def set_service_rate_limit( + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "", +) -> GlobalRateLimitPolicy +``` + +Create or replace the rate limit policy for a service. Persists to the backend. Logged as `svc_rl_set` (new) or `svc_rl_updated` (replacement). + +```python +await engine.set_service_rate_limit( + "payments-service", + "1000/minute", + key_strategy="ip", + exempt_routes=["/health", "GET:/metrics"], + actor="alice", +) +``` + +--- + +#### `get_service_rate_limit` + +```python +async def get_service_rate_limit(service: str) -> GlobalRateLimitPolicy | None +``` + +Return the current policy for a service, or `None` if not configured. + +--- + +#### `delete_service_rate_limit` + +```python +async def delete_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Remove the service rate limit policy entirely. Logged as `svc_rl_deleted`. + +--- + +#### `reset_service_rate_limit` + +```python +async def reset_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Clear all counters for the service so the limit starts fresh. The policy is not removed. Logged as `svc_rl_reset`. + +--- + +#### `enable_service_rate_limit` + +```python +async def enable_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Resume a paused service rate limit policy. No-op if already enabled or not configured. Logged as `svc_rl_enabled`. + +--- + +#### `disable_service_rate_limit` + +```python +async def disable_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Pause the service rate limit without removing it. Per-route policies are unaffected. Logged as `svc_rl_disabled`. + +--- + +### Dashboard + +When a service filter is active on the **Rate Limits** page (`/shield/rate-limits?service=`), a **Service Rate Limit** card appears between the global RL card and the policies table. + +- **Not configured** — compact bar with a "Set Service Limit" button. +- **Active** — info card showing limit, algorithm, key strategy, burst, and exempt routes. Action buttons: Pause, Edit, Reset, Remove. +- **Paused** — same card with a "Paused" badge and a Resume button. + +--- + ## CLI commands `shield rl` and `shield rate-limits` are aliases for the same command group — use whichever you prefer. @@ -543,10 +646,6 @@ shield rl hits --limit 50 # show more --- -Now add the global rate limit CLI commands section after `shield rl hits`: - ---- - ### `shield grl` / `shield global-rate-limit` `shield grl` and `shield global-rate-limit` are aliases for the global rate limit command group. @@ -629,6 +728,100 @@ shield grl disable --- +## `shield srl` / `shield service-rate-limit` + +`shield srl` and `shield service-rate-limit` are aliases for the per-service rate limit command group. Requires `api-shield[rate-limit]` on the server. + +```bash +shield srl get payments-service +shield service-rate-limit get payments-service # identical +``` + +### `shield srl get` + +Show the current rate limit policy for a service. + +```bash +shield srl get +``` + +```bash +shield srl get payments-service +``` + +--- + +### `shield srl set` + +Configure the rate limit for a service. Creates a new policy or replaces the existing one. + +```bash +shield srl set +``` + +```bash +shield srl set payments-service 1000/minute +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path (`/health`) or method-prefixed (`GET:/metrics`) | + +--- + +### `shield srl delete` + +Remove the service rate limit policy entirely. + +```bash +shield srl delete +``` + +```bash +shield srl delete payments-service +``` + +--- + +### `shield srl reset` + +Clear all counters for the service. The policy is kept; clients get their full quota back on the next request. + +```bash +shield srl reset +``` + +```bash +shield srl reset payments-service +``` + +--- + +### `shield srl enable` + +Resume a paused service rate limit policy. + +```bash +shield srl enable +``` + +--- + +### `shield srl disable` + +Pause the service rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield srl disable +``` + +--- + ## Audit log integration Rate limit policy changes are recorded in the same audit log as route state changes. The `action` field uses the following values: @@ -642,7 +835,7 @@ Rate limit policy changes are recorded in the same audit log as route state chan | `rl_reset` | reset | Per-route counters cleared | | `rl_policy_deleted` | delete | Per-route policy removed | -**Global:** +**Global (all services):** | Action | Badge | When | |---|---|---| @@ -653,6 +846,19 @@ Rate limit policy changes are recorded in the same audit log as route state chan | `global_rl_enabled` | global enabled | Policy resumed after pause | | `global_rl_disabled` | global disabled | Policy paused | +**Per-service:** + +| Action | Badge | When | +|---|---|---| +| `svc_rl_set` | svc set | Service policy created | +| `svc_rl_updated` | svc update | Service policy replaced | +| `svc_rl_reset` | svc reset | Service counters cleared | +| `svc_rl_deleted` | svc delete | Service policy removed | +| `svc_rl_enabled` | svc enabled | Service policy resumed after pause | +| `svc_rl_disabled` | svc disabled | Service policy paused | + +The `Path` column for service rate limit entries displays as `[{service} Rate Limit]` (e.g. `[payments-service Rate Limit]`). + View in the dashboard at `/shield/audit` or via `shield log`. --- diff --git a/docs/tutorial/rate-limiting.md b/docs/tutorial/rate-limiting.md index 6468715..0835b38 100644 --- a/docs/tutorial/rate-limiting.md +++ b/docs/tutorial/rate-limiting.md @@ -405,6 +405,33 @@ Submitting the form calls `POST /api/rate-limits` under the hood and redirects b --- +## Per-service rate limit (multi-service) + +When running multiple services with Shield Server and `ShieldSDK`, you can set a rate limit that applies to **every route of one service** without decorating individual handlers. + +```bash +# Cap all payments-service routes at 1000 per minute per IP +shield srl set payments-service 1000/minute + +# Shared counter across all callers +shield srl set payments-service 5000/hour --key global + +# Pause and resume enforcement +shield srl disable payments-service +shield srl enable payments-service + +# Clear counters (policy stays in place) +shield srl reset payments-service +``` + +The service rate limit sits between the all-services global rate limit (`shield grl`) and individual per-route limits. A request passes through all three layers in order before reaching the handler. You can configure any combination of the three independently. + +From the dashboard: open the **Rate Limits** tab and filter to a service. A **Service Rate Limit** card appears above the policies table with controls to configure, pause, reset, and remove the policy. + +See [Per-service rate limit reference](../reference/rate-limiting.md#per-service-rate-limit) and the [`shield srl` CLI reference](../reference/cli.md#shield-srl--shield-service-rate-limit) for the full API. + +--- + ## Blocked requests log Every blocked request is recorded. View from the dashboard at `/shield/blocked` or via the CLI: diff --git a/pyproject.toml b/pyproject.toml index af82cc6..d187c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api-shield" -version = "0.7.0" +version = "0.8.0" description = "Route lifecycle management for APIs — maintenance mode, env gating, deprecation, and more" readme = "README.md" license = { text = "MIT" } diff --git a/shield/admin/api.py b/shield/admin/api.py index 3736015..a1e39f0 100644 --- a/shield/admin/api.py +++ b/shield/admin/api.py @@ -369,6 +369,45 @@ async def global_disable_api(request: Request) -> JSONResponse: return JSONResponse(cfg.model_dump(mode="json")) +async def service_maintenance_get(request: Request) -> JSONResponse: + """GET /api/services/{service}/maintenance — current per-service maintenance config.""" + service = request.path_params["service"] + cfg = await _engine(request).get_service_maintenance(service) + return JSONResponse(cfg.model_dump(mode="json")) + + +async def service_maintenance_enable(request: Request) -> JSONResponse: + """POST /api/services/{service}/maintenance/enable — enable per-service maintenance.""" + service = request.path_params["service"] + actor = _actor(request) + try: + body = await request.json() + except Exception: + body = {} + reason = body.get("reason", "") if isinstance(body, dict) else "" + exempt = body.get("exempt_paths", []) if isinstance(body, dict) else [] + include_fa = body.get("include_force_active", False) if isinstance(body, dict) else False + cfg = await _engine(request).enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt, + include_force_active=include_fa, + actor=actor, + platform=_platform(request), + ) + return JSONResponse(cfg.model_dump(mode="json")) + + +async def service_maintenance_disable(request: Request) -> JSONResponse: + """POST /api/services/{service}/maintenance/disable — disable per-service maintenance.""" + service = request.path_params["service"] + actor = _actor(request) + cfg = await _engine(request).disable_service_maintenance( + service=service, actor=actor, platform=_platform(request) + ) + return JSONResponse(cfg.model_dump(mode="json")) + + # --------------------------------------------------------------------------- # Rate limits # --------------------------------------------------------------------------- @@ -532,6 +571,90 @@ async def disable_global_rate_limit_api(request: Request) -> JSONResponse: return JSONResponse({"ok": True}) +# --------------------------------------------------------------------------- +# Per-service rate limit endpoints +# --------------------------------------------------------------------------- + + +async def get_service_rate_limit(request: Request) -> JSONResponse: + """GET /api/services/{service}/rate-limit — current per-service rate limit policy.""" + service = request.path_params["service"] + policy = await _engine(request).get_service_rate_limit(service) + if policy is None: + return JSONResponse({"enabled": False, "policy": None}) + return JSONResponse({"enabled": policy.enabled, "policy": policy.model_dump(mode="json")}) + + +async def set_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit — set or update per-service rate limit.""" + service = request.path_params["service"] + engine = _engine(request) + actor = _actor(request) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "invalid JSON body"}, status_code=400) + + limit = body.get("limit", "") + if not limit: + return JSONResponse({"error": "limit is required"}, status_code=400) + exempt_routes = body.get("exempt_routes", []) + if not isinstance(exempt_routes, list): + return JSONResponse({"error": "exempt_routes must be a list"}, status_code=400) + + try: + policy = await engine.set_service_rate_limit( + service, + limit=limit, + algorithm=body.get("algorithm"), + key_strategy=body.get("key_strategy"), + on_missing_key=body.get("on_missing_key"), + burst=int(body.get("burst", 0)), + exempt_routes=exempt_routes, + actor=actor, + platform=_platform(request), + ) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return JSONResponse(policy.model_dump(mode="json"), status_code=201) + + +async def delete_service_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/services/{service}/rate-limit — remove per-service rate limit policy.""" + service = request.path_params["service"] + await _engine(request).delete_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def reset_service_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/services/{service}/rate-limit/reset — reset per-service counters.""" + service = request.path_params["service"] + await _engine(request).reset_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def enable_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit/enable — resume paused service rate limit.""" + service = request.path_params["service"] + await _engine(request).enable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def disable_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit/disable — pause service rate limit.""" + service = request.path_params["service"] + await _engine(request).disable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + # --------------------------------------------------------------------------- # SDK endpoints — used by ShieldServerBackend / ShieldSDK clients # --------------------------------------------------------------------------- @@ -593,7 +716,8 @@ async def _feed_flags() -> None: async for event in engine.backend.subscribe_flag_changes(): # type: ignore[attr-defined] envelope = _json.dumps(event) await queue.put(f"data: {envelope}\n\n") - except NotImplementedError: + except (NotImplementedError, AttributeError): + # Backend doesn't support flag pub/sub — silently skip. pass except asyncio.CancelledError: raise diff --git a/shield/admin/app.py b/shield/admin/app.py index 50af3d7..d8db078 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -356,6 +356,17 @@ def _clean_entry_path(entry: object) -> str: """Same as _clean_path but works on AuditEntry objects.""" svc = getattr(entry, "service", None) raw = getattr(entry, "path", "") + # Translate internal sentinel keys to human-friendly labels + if raw == "__global__": + return "[Global Maintenance]" + if raw == "__global_rl__": + return "[Global Rate Limit]" + if raw.startswith("__shield:svc_global:") and raw.endswith("__"): + name = raw[len("__shield:svc_global:") : -2] + return f"[{name} Maintenance]" + if raw.startswith("__shield:svc_rl:") and raw.endswith("__"): + name = raw[len("__shield:svc_rl:") : -2] + return f"[{name} Rate Limit]" if svc and raw.startswith(f"{svc}:"): return raw[len(svc) + 1 :] return raw @@ -389,9 +400,14 @@ def _clean_entry_path(entry: object) -> str: Route("/routes", _dash.routes_partial), Route("/modal/global/enable", _dash.modal_global_enable), Route("/modal/global/disable", _dash.modal_global_disable), + Route("/modal/service/enable", _dash.modal_service_enable), + Route("/modal/service/disable", _dash.modal_service_disable), Route("/modal/global-rl", _dash.modal_global_rl), Route("/modal/global-rl/delete", _dash.modal_global_rl_delete), Route("/modal/global-rl/reset", _dash.modal_global_rl_reset), + Route("/modal/service-rl", _dash.modal_service_rl), + Route("/modal/service-rl/delete", _dash.modal_service_rl_delete), + Route("/modal/service-rl/reset", _dash.modal_service_rl_reset), Route("/modal/env/{path_key}", _dash.modal_env_gate), # Flag/segment modals must come before the generic wildcard below. *(_flag_dashboard_modal_routes() if enable_flags else []), @@ -406,6 +422,16 @@ def _clean_entry_path(entry: object) -> str: _dash.global_maintenance_disable, methods=["POST"], ), + Route( + "/service-maintenance/enable", + _dash.service_maintenance_enable, + methods=["POST"], + ), + Route( + "/service-maintenance/disable", + _dash.service_maintenance_disable, + methods=["POST"], + ), Route("/toggle/{path_key}", _dash.toggle, methods=["POST"]), Route("/disable/{path_key}", _dash.disable, methods=["POST"]), Route("/enable/{path_key}", _dash.enable, methods=["POST"]), @@ -431,6 +457,11 @@ def _clean_entry_path(entry: object) -> str: Route("/global-rl/reset", _dash.global_rl_reset, methods=["POST"]), Route("/global-rl/enable", _dash.global_rl_enable, methods=["POST"]), Route("/global-rl/disable", _dash.global_rl_disable, methods=["POST"]), + Route("/service-rl/set", _dash.service_rl_set, methods=["POST"]), + Route("/service-rl/delete", _dash.service_rl_delete, methods=["POST"]), + Route("/service-rl/reset", _dash.service_rl_reset, methods=["POST"]), + Route("/service-rl/enable", _dash.service_rl_enable, methods=["POST"]), + Route("/service-rl/disable", _dash.service_rl_disable, methods=["POST"]), Route("/events", _dash.events), # ── REST API (CLI) ──────────────────────────────────────────── Route("/api/auth/login", _api.auth_login, methods=["POST"]), @@ -456,6 +487,21 @@ def _clean_entry_path(entry: object) -> str: Route("/api/global", _api.get_global, methods=["GET"]), Route("/api/global/enable", _api.global_enable_api, methods=["POST"]), Route("/api/global/disable", _api.global_disable_api, methods=["POST"]), + Route( + "/api/services/{service}/maintenance", + _api.service_maintenance_get, + methods=["GET"], + ), + Route( + "/api/services/{service}/maintenance/enable", + _api.service_maintenance_enable, + methods=["POST"], + ), + Route( + "/api/services/{service}/maintenance/disable", + _api.service_maintenance_disable, + methods=["POST"], + ), Route("/api/rate-limits", _api.list_rate_limits, methods=["GET"]), Route("/api/rate-limits", _api.set_rate_limit_policy_api, methods=["POST"]), Route("/api/rate-limits/hits", _api.get_rate_limit_hits, methods=["GET"]), @@ -495,6 +541,36 @@ def _clean_entry_path(entry: object) -> str: _api.disable_global_rate_limit_api, methods=["POST"], ), + Route( + "/api/services/{service}/rate-limit", + _api.get_service_rate_limit, + methods=["GET"], + ), + Route( + "/api/services/{service}/rate-limit", + _api.set_service_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/services/{service}/rate-limit", + _api.delete_service_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/services/{service}/rate-limit/reset", + _api.reset_service_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/services/{service}/rate-limit/enable", + _api.enable_service_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/services/{service}/rate-limit/disable", + _api.disable_service_rate_limit_api, + methods=["POST"], + ), # ── SDK endpoints (ShieldServerBackend / ShieldSDK) ────────── Route("/api/sdk/events", _api.sdk_events, methods=["GET"]), Route("/api/sdk/register", _api.sdk_register, methods=["POST"]), diff --git a/shield/cli/client.py b/shield/cli/client.py index 23e5691..ec1855d 100644 --- a/shield/cli/client.py +++ b/shield/cli/client.py @@ -249,6 +249,39 @@ async def global_disable(self) -> dict[str, Any]: resp = await c.post("/api/global/disable") return cast(dict[str, Any], self._check(resp)) + # ── Per-service maintenance ─────────────────────────────────────── + + async def service_maintenance_status(self, service: str) -> dict[str, Any]: + """GET /api/services/{service}/maintenance — current per-service maintenance config.""" + async with self._make_client() as c: + resp = await c.get(f"/api/services/{service}/maintenance") + return cast(dict[str, Any], self._check(resp)) + + async def service_maintenance_enable( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + ) -> dict[str, Any]: + """POST /api/services/{service}/maintenance/enable — enable per-service maintenance.""" + async with self._make_client() as c: + resp = await c.post( + f"/api/services/{service}/maintenance/enable", + json={ + "reason": reason, + "exempt_paths": exempt_paths or [], + "include_force_active": include_force_active, + }, + ) + return cast(dict[str, Any], self._check(resp)) + + async def service_maintenance_disable(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/maintenance/disable — disable per-service maintenance.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/maintenance/disable") + return cast(dict[str, Any], self._check(resp)) + async def list_rate_limits(self) -> list[dict[str, Any]]: """GET /api/rate-limits — list all rate limit policies.""" async with self._make_client() as c: @@ -365,6 +398,60 @@ async def disable_global_rate_limit(self) -> dict[str, Any]: resp = await c.post("/api/global-rate-limit/disable") return cast(dict[str, Any], self._check(resp)) + # ── Per-service rate limit ───────────────────────────────────────── + + async def get_service_rate_limit(self, service: str) -> dict[str, Any]: + """GET /api/services/{service}/rate-limit — current per-service rate limit policy.""" + async with self._make_client() as c: + resp = await c.get(f"/api/services/{service}/rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + ) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit — set or update per-service rate limit.""" + payload: dict[str, Any] = {"limit": limit, "burst": burst} + if algorithm: + payload["algorithm"] = algorithm + if key_strategy: + payload["key_strategy"] = key_strategy + if exempt_routes: + payload["exempt_routes"] = exempt_routes + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit", json=payload) + return cast(dict[str, Any], self._check(resp)) + + async def delete_service_rate_limit(self, service: str) -> dict[str, Any]: + """DELETE /api/services/{service}/rate-limit — remove per-service rate limit policy.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/services/{service}/rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def reset_service_rate_limit(self, service: str) -> dict[str, Any]: + """DELETE /api/services/{service}/rate-limit/reset — reset per-service counters.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/services/{service}/rate-limit/reset") + return cast(dict[str, Any], self._check(resp)) + + async def enable_service_rate_limit(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit/enable — resume paused service rate limit.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit/enable") + return cast(dict[str, Any], self._check(resp)) + + async def disable_service_rate_limit(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit/disable — pause service rate limit.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit/disable") + return cast(dict[str, Any], self._check(resp)) + # ── Feature flags ───────────────────────────────────────────────── async def list_flags(self) -> list[dict[str, Any]]: diff --git a/shield/cli/main.py b/shield/cli/main.py index f7ee7c0..f27cf5e 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -1340,6 +1340,292 @@ async def _run_grl_disable() -> None: _run(_run_grl_disable) +# --------------------------------------------------------------------------- +# Per-service rate limit command group (shield service-rate-limit ...) +# --------------------------------------------------------------------------- + +srl_app = typer.Typer( + name="service-rate-limit", + help="Manage per-service rate limit policies applied to all routes of a service.", + no_args_is_help=True, +) +cli.add_typer(srl_app, name="service-rate-limit") +cli.add_typer(srl_app, name="srl") + + +@srl_app.command("get") +def srl_get( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Show the current per-service rate limit policy.""" + + async def _run_srl_get() -> None: + result = await make_client().get_service_rate_limit(service) + policy = result.get("policy") + if not policy: + console.print(f"[dim]No rate limit policy configured for {service!r}.[/dim]") + return + + table = Table(box=box.ROUNDED, show_header=True, header_style="bold") + table.add_column("Field", style="dim") + table.add_column("Value") + + table.add_row("Service", f"[cyan]{service}[/cyan]") + table.add_row("Limit", f"[magenta]{policy.get('limit', '—')}[/magenta]") + table.add_row("Algorithm", policy.get("algorithm", "—")) + table.add_row("Key Strategy", policy.get("key_strategy", "—")) + table.add_row("Burst", str(policy.get("burst", 0))) + table.add_row("Enabled", "[green]yes[/green]" if policy.get("enabled") else "[red]no[/red]") + exempt = policy.get("exempt_routes") or [] + table.add_row("Exempt Routes", "\n".join(exempt) if exempt else "[dim](none)[/dim]") + console.print(table) + + _run(_run_srl_get) + + +@srl_app.command("set") +def srl_set( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), + limit: str = typer.Argument(..., help="Rate limit string, e.g. 1000/minute"), + algorithm: str | None = typer.Option( + None, + "--algorithm", + "-a", + help="Algorithm: fixed_window, sliding_window, moving_window, token_bucket.", + ), + key_strategy: str | None = typer.Option( + None, + "--key", + "-k", + help="Key strategy: ip, user, api_key, global, custom.", + ), + burst: int = typer.Option(0, "--burst", "-b", help="Burst allowance (extra requests)."), + exempt: list[str] | None = typer.Option( + None, + "--exempt", + "-e", + help=( + "Route to exempt from the service limit. Repeat for multiple routes. " + "Use /path to exempt all methods, or METHOD:/path for a specific method." + ), + ), +) -> None: + """Set or update the per-service rate limit policy. + + The policy applies to every route of SERVICE that is not explicitly exempted. + Persisted to the backend so it survives restarts. Examples: + + \b + shield srl set payments-service 1000/minute + shield srl set orders-service 500/minute --key ip --exempt /health + shield srl set auth-service 200/hour --algorithm sliding_window --burst 20 + """ + + async def _run_srl_set() -> None: + result = await make_client().set_service_rate_limit( + service, + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=list(exempt) if exempt else [], + ) + key_strat = result.get("key_strategy", "ip") + algo = result.get("algorithm", "") + console.print( + f"[green]✓[/green] Service rate limit set for [cyan]{service}[/cyan]: " + f"[bold]{result.get('limit')}[/bold] ({algo}, key={key_strat})" + ) + exempt_list = result.get("exempt_routes") or [] + if exempt_list: + console.print(f" Exempt routes: [dim]{', '.join(exempt_list)}[/dim]") + + _run(_run_srl_set) + + +@srl_app.command("delete") +def srl_delete( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Remove the per-service rate limit policy. + + Clears the policy from the backend. In-process counters are not + affected — use ``srl reset`` to clear them too. + """ + + async def _run_srl_delete() -> None: + result = await make_client().delete_service_rate_limit(service) + if result.get("ok"): + console.print( + f"[green]✓[/green] Service rate limit policy removed for [cyan]{service}[/cyan]." + ) + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_delete) + + +@srl_app.command("reset") +def srl_reset( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Reset per-service rate limit counters. + + Clears all counters so the limit starts fresh. The policy itself + is not removed — use ``srl delete`` for that. + """ + + async def _run_srl_reset() -> None: + result = await make_client().reset_service_rate_limit(service) + if result.get("ok"): + console.print(f"[green]✓[/green] Rate limit counters reset for [cyan]{service}[/cyan].") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_reset) + + +@srl_app.command("enable") +def srl_enable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Resume a paused per-service rate limit policy.""" + + async def _run_srl_enable() -> None: + result = await make_client().enable_service_rate_limit(service) + if result.get("ok"): + console.print( + f"[green]✓[/green] Service rate limit resumed for [cyan]{service}[/cyan]." + ) + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_enable) + + +@srl_app.command("disable") +def srl_disable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Pause the per-service rate limit policy without removing it.""" + + async def _run_srl_disable() -> None: + result = await make_client().disable_service_rate_limit(service) + if result.get("ok"): + console.print(f"[green]✓[/green] Service rate limit paused for [cyan]{service}[/cyan].") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_disable) + + +# --------------------------------------------------------------------------- +# Per-service maintenance command group (shield sm ...) +# --------------------------------------------------------------------------- + +sm_app = typer.Typer( + name="service-maintenance", + help="Manage per-service maintenance mode (blocks all routes of one service).", + no_args_is_help=True, +) +cli.add_typer(sm_app, name="service-maintenance") +cli.add_typer(sm_app, name="sm") + + +@sm_app.command("status") +def sm_status( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Show the current maintenance configuration for a service.""" + + async def _run_sm_status() -> None: + cfg = await make_client().service_maintenance_status(service) + state_str = "[green]OFF[/green]" + if cfg.get("enabled"): + state_str = "[yellow]ON[/yellow]" + console.print(f"\n Service maintenance ({service}): {state_str}") + if cfg.get("enabled"): + console.print(f" Reason : {cfg.get('reason') or '—'}") + fa = cfg.get("include_force_active", False) + fa_colour = "red" if fa else "green" + fa_text = "yes" if fa else "no" + console.print(f" Include @force_active: [{fa_colour}]{fa_text}[/{fa_colour}]") + exempts = cfg.get("exempt_paths") or [] + if exempts: + console.print(" Exempt paths :") + for p in exempts: + console.print(f" • {p}") + else: + console.print(" Exempt paths : (none)") + console.print() + + _run(_run_sm_status) + + +@sm_app.command("enable") +def sm_enable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), + reason: str = typer.Option("", "--reason", "-r", help="Reason shown in 503 responses."), + exempt: list[str] | None = typer.Option( + None, + "--exempt", + "-e", + help="Route to exempt (repeat for multiple). Use /path or METHOD:/path.", + ), + include_force_active: bool = typer.Option( + False, + "--include-force-active/--no-include-force-active", + help="Also block @force_active routes.", + ), +) -> None: + """Enable maintenance mode for all routes of a service. + + All routes belonging to SERVICE return 503 until maintenance is disabled. + Exempt paths bypass the block and respond normally. + + \b + shield sm enable payments-service --reason "DB migration" + shield sm enable payments-service --reason "Upgrade" --exempt /health + shield sm enable orders-service --include-force-active + """ + + async def _run_sm_enable() -> None: + cfg = await make_client().service_maintenance_enable( + service, + reason=reason, + exempt_paths=list(exempt) if exempt else [], + include_force_active=include_force_active, + ) + console.print( + f"[yellow]⚠[/yellow] Service maintenance [yellow]ENABLED[/yellow]" + f" for [cyan]{service}[/cyan]" + ) + if cfg.get("reason"): + console.print(f" Reason: {cfg['reason']}") + if cfg.get("exempt_paths"): + console.print(f" Exempt: {', '.join(cfg['exempt_paths'])}") + if cfg.get("include_force_active"): + console.print(" [red]@force_active routes are also blocked.[/red]") + + _run(_run_sm_enable) + + +@sm_app.command("disable") +def sm_disable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Disable service maintenance mode, restoring normal per-route state.""" + + async def _run_sm_disable() -> None: + await make_client().service_maintenance_disable(service) + console.print( + f"[green]✓[/green] Service maintenance [green]DISABLED[/green]" + f" for [cyan]{service}[/cyan]" + ) + + _run(_run_sm_disable) + + # --------------------------------------------------------------------------- # Feature flags command group (shield flags ...) # --------------------------------------------------------------------------- diff --git a/shield/core/backends/base.py b/shield/core/backends/base.py index e26e5a6..6bba237 100644 --- a/shield/core/backends/base.py +++ b/shield/core/backends/base.py @@ -167,6 +167,17 @@ async def subscribe_global_config(self) -> AsyncIterator[None]: # Unreachable — makes this a valid async generator return type. yield + async def get_registered_paths(self) -> set[str]: + """Return the set of all registered path keys for deduplication. + + Used by ``ShieldEngine.register_batch()`` to detect already-registered + routes without re-querying the full state list. The default + implementation derives the set from ``list_states()``; backends that + store routes under transformed keys (e.g. ``ShieldServerBackend`` which + adds a service prefix) should override this to return local/plain keys. + """ + return {s.path for s in await self.list_states()} + # ------------------------------------------------------------------ # Rate limit hit log — concrete default implementations # ------------------------------------------------------------------ @@ -263,6 +274,58 @@ async def delete_global_rate_limit_policy(self) -> None: _GLOBAL_RL_KEY = "__shield:global_rl__" await self.delete_state(_GLOBAL_RL_KEY) + # ------------------------------------------------------------------ + # Per-service rate limit policy persistence — concrete default + # implementations using the sentinel RouteState pattern. + # Stored as a sentinel with path ``__shield:svc_rl:{service}__``. + # No subclass changes required for existing backends. + # ------------------------------------------------------------------ + + async def get_service_rate_limit_policy(self, service: str) -> dict[str, Any] | None: + """Return the persisted per-service rate limit policy dict, or ``None``.""" + import json + + key = f"__shield:svc_rl:{service}__" + try: + state = await self.get_state(key) + return dict(json.loads(state.reason)) + except (KeyError, Exception): + return None + + async def set_service_rate_limit_policy( + self, service: str, policy_data: dict[str, Any] + ) -> None: + """Persist *policy_data* as the rate limit policy for *service*.""" + import json + + key = f"__shield:svc_rl:{service}__" + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=json.dumps(policy_data), + service=service, + ) + await self.set_state(key, sentinel) + + async def delete_service_rate_limit_policy(self, service: str) -> None: + """Remove the persisted rate limit policy for *service*.""" + key = f"__shield:svc_rl:{service}__" + await self.delete_state(key) + + async def get_all_service_rate_limit_policies(self) -> dict[str, dict[str, Any]]: + """Return all persisted per-service rate limit policies as ``{service: policy_data}``.""" + import json + + result: dict[str, dict[str, Any]] = {} + for state in await self.list_states(): + if state.path.startswith("__shield:svc_rl:") and state.path.endswith("__"): + svc = state.path[len("__shield:svc_rl:") : -2] + try: + result[svc] = dict(json.loads(state.reason)) + except Exception: + pass + return result + async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: """Stream rate limit policy changes as they occur. diff --git a/shield/core/backends/server.py b/shield/core/backends/server.py index 2fa108c..8074acf 100644 --- a/shield/core/backends/server.py +++ b/shield/core/backends/server.py @@ -30,8 +30,8 @@ import httpx -from shield.core.backends.base import ShieldBackend -from shield.core.models import AuditEntry, RouteState +from shield.core.backends.base import _GLOBAL_KEY, ShieldBackend +from shield.core.models import AuditEntry, GlobalMaintenanceConfig, RouteState logger = logging.getLogger(__name__) @@ -89,13 +89,34 @@ def __init__( self._cache: dict[str, RouteState] = {} # Routes registered locally during startup before the HTTP client - # exists. Flushed to the server once startup() completes. - self._pending: list[RouteState] = [] + # exists, or before _startup_done is True. Keyed by server-side + # path so duplicates (e.g. from ShieldRouter + SDK scan) are collapsed. + # Flushed to the server as one batch once _flush_pending() completes. + self._pending: dict[str, RouteState] = {} + + # False until _flush_pending() completes for the first time. While + # False, set_state() always queues to _pending instead of firing + # individual _push_state() tasks so that ALL startup registrations — + # from ShieldRouter, from SDK route scan, from any ordering of startup + # hooks — travel in exactly ONE HTTP round-trip at the end. Once True, + # real-time pushes are enabled for runtime state mutations (enable, + # disable, maintenance, etc.). + self._startup_done: bool = False + + # Queues signalled whenever the global-maintenance config changes + # (all-services global key OR the service-specific key). Used by + # subscribe_global_config() so the engine can invalidate its cache. + self._global_config_changed: list[asyncio.Queue[None]] = [] # Local rate limit policy cache — keyed "METHOD:local_path" → policy dict. self._rl_policy_cache: dict[str, dict[str, Any]] = {} self._rl_policy_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] + # Queues for state-change subscribers (fed by _listen_sse on "state" events). + # Consumed by subscribe() so _run_route_state_listener bumps _schema_version, + # which invalidates the apply_shield_to_openapi cache on every SSE update. + self._state_subscribers: list[asyncio.Queue[RouteState]] = [] + # Local feature flag / segment cache (populated by SSE flag events). self._flag_cache: dict[str, Any] = {} # key → FeatureFlag raw dict self._segment_cache: dict[str, Any] = {} # key → Segment raw dict @@ -246,31 +267,40 @@ async def _flush_pending(self) -> None: Called by :class:`~shield.sdk.ShieldSDK` after route discovery at startup so the dashboard reflects the service's routes. Routes already present on the server are left untouched (server-wins). + + Sets :attr:`_startup_done` to ``True`` after the flush (whether or not + the HTTP request succeeded) so that subsequent :meth:`set_state` calls + — from runtime mutations like ``engine.disable()`` — are pushed + immediately instead of being queued. """ - if not self._pending or self._client is None: - return - batch = self._pending[:] - self._pending.clear() try: - await self._client.post( - "/api/sdk/register", - json={ - "app_id": self._app_id, - "states": [s.model_dump(mode="json") for s in batch], - }, - ) - logger.debug( - "ShieldServerBackend[%s]: registered %d new route(s) with server", - self._app_id, - len(batch), - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "ShieldServerBackend[%s]: failed to register %d route(s) with server: %s", - self._app_id, - len(batch), - exc, - ) + if not self._pending or self._client is None: + return + batch = list(self._pending.values()) + self._pending.clear() + try: + await self._client.post( + "/api/sdk/register", + json={ + "app_id": self._app_id, + "states": [s.model_dump(mode="json") for s in batch], + }, + ) + logger.debug( + "ShieldServerBackend[%s]: registered %d new route(s) with server", + self._app_id, + len(batch), + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "ShieldServerBackend[%s]: failed to register %d route(s) with server: %s", + self._app_id, + len(batch), + exc, + ) + finally: + # Mark startup complete so runtime set_state() calls push immediately. + self._startup_done = True # ------------------------------------------------------------------ # SSE listener @@ -350,6 +380,18 @@ async def _listen_sse(self) -> None: local_key, state.status, ) + # Notify state subscribers so _run_route_state_listener + # bumps _schema_version, which invalidates the OpenAPI cache. + _svc_global_key = f"__shield:svc_global:{self._app_id}__" + if local_key not in (_GLOBAL_KEY, _svc_global_key): + for q in self._state_subscribers: + q.put_nowait(state) + # Notify engine to invalidate global-maintenance cache when + # the all-services global config OR this service's own + # maintenance config arrives via SSE. + else: + for gc_q in self._global_config_changed: + gc_q.put_nowait(None) elif event_type == "rl_policy": # Rate limit policy change. @@ -359,16 +401,16 @@ async def _listen_sse(self) -> None: policy = envelope.get("policy", {}) self._rl_policy_cache[key] = policy event: dict[str, Any] = {"action": "set", "key": key, "policy": policy} - for q in self._rl_policy_subscribers: - q.put_nowait(event) + for rl_q in self._rl_policy_subscribers: + rl_q.put_nowait(event) logger.debug( "ShieldServerBackend[%s]: RL policy set — %s", self._app_id, key ) elif action == "delete": self._rl_policy_cache.pop(key, None) del_event: dict[str, Any] = {"action": "delete", "key": key} - for q in self._rl_policy_subscribers: - q.put_nowait(del_event) + for rl_q in self._rl_policy_subscribers: + rl_q.put_nowait(del_event) logger.debug( "ShieldServerBackend[%s]: RL policy deleted — %s", self._app_id, key ) @@ -383,8 +425,8 @@ async def _listen_sse(self) -> None: "key": key, "flag": flag_data, } - for q in self._flag_subscribers: - q.put_nowait(flag_event) + for flag_q in self._flag_subscribers: + flag_q.put_nowait(flag_event) logger.debug( "ShieldServerBackend[%s]: flag cache updated — %s", self._app_id, @@ -396,8 +438,8 @@ async def _listen_sse(self) -> None: if key: self._flag_cache.pop(key, None) flag_del_event: dict[str, Any] = {"type": "flag_deleted", "key": key} - for q in self._flag_subscribers: - q.put_nowait(flag_del_event) + for flag_q in self._flag_subscribers: + flag_q.put_nowait(flag_del_event) logger.debug( "ShieldServerBackend[%s]: flag deleted — %s", self._app_id, key ) @@ -412,8 +454,8 @@ async def _listen_sse(self) -> None: "key": key, "segment": seg_data, } - for q in self._flag_subscribers: - q.put_nowait(seg_event) + for flag_q in self._flag_subscribers: + flag_q.put_nowait(seg_event) logger.debug( "ShieldServerBackend[%s]: segment cache updated — %s", self._app_id, @@ -425,8 +467,8 @@ async def _listen_sse(self) -> None: if key: self._segment_cache.pop(key, None) seg_del_event: dict[str, Any] = {"type": "segment_deleted", "key": key} - for q in self._flag_subscribers: - q.put_nowait(seg_del_event) + for flag_q in self._flag_subscribers: + flag_q.put_nowait(seg_del_event) logger.debug( "ShieldServerBackend[%s]: segment deleted — %s", self._app_id, key ) @@ -439,6 +481,8 @@ async def _listen_sse(self) -> None: continue local_key = self._local_path(state) self._cache[local_key] = state + for q in self._state_subscribers: + q.put_nowait(state) except Exception: pass @@ -475,8 +519,11 @@ async def set_state(self, path: str, state: RouteState) -> None: ) # Local cache always uses the plain path for zero-overhead enforcement. self._cache[path] = state - if self._client is None: - self._pending.append(state) + if self._client is None or not self._startup_done: + # Queue for batch flush: use server-side path as key so duplicate + # registrations (e.g. from both ShieldRouter and SDK scan) collapse + # into one entry — latest state wins. + self._pending[state.path] = state else: asyncio.create_task(self._push_state(state)) @@ -503,7 +550,22 @@ async def delete_state(self, path: str) -> None: self._cache.pop(path, None) async def list_states(self) -> list[RouteState]: - return list(self._cache.values()) + # Exclude sentinel keys used for global/service maintenance configs + # so they don't appear as regular routes in the dashboard or CLI. + # + # Normalize state.path to the local (app-side) cache key — e.g. + # "GET:/api/payments" — rather than the server-side namespaced path + # "payments-service:GET:/api/payments". Callers such as + # apply_shield_to_openapi look up states by the local path that + # matches FastAPI's route paths, so the paths must align. + result: list[RouteState] = [] + for local_key, state in self._cache.items(): + if local_key.startswith("__shield:"): + continue + if state.path != local_key: + state = state.model_copy(update={"path": local_key}) + result.append(state) + return result # ------------------------------------------------------------------ # Audit log @@ -553,21 +615,118 @@ async def get_audit_log( return [] # ------------------------------------------------------------------ - # subscribe() — not needed; SDK uses an internal SSE connection + # subscribe() — yields state changes received via the SSE connection # ------------------------------------------------------------------ async def subscribe(self) -> AsyncIterator[RouteState]: - """Not supported — ``ShieldServerBackend`` manages its own SSE stream. + """Yield every route-state change pushed by the Shield Server via SSE. + + State changes arrive through the existing ``/api/sdk/events`` SSE + connection managed by :meth:`_listen_sse`. Each yielded + ``RouteState`` is a normal (non-sentinel) route update for this + service. - The Shield Server's ``/api/sdk/events`` endpoint is consumed - internally by :meth:`_listen_sse`. Callers (e.g. the dashboard) - should use the server's own ``/events`` endpoint instead. + The engine's ``_run_route_state_listener`` consumes this generator + and calls ``_bump_schema_version()`` on every yield so that + ``apply_shield_to_openapi``'s cache is invalidated and ``/docs`` + reflects live state changes made via the dashboard or CLI. """ - raise NotImplementedError( - "ShieldServerBackend manages its own SSE connection internally. " - "Connect directly to the Shield Server's /events endpoint for live updates." - ) - yield # pragma: no cover — makes this a valid async generator + q: asyncio.Queue[RouteState] = asyncio.Queue() + self._state_subscribers.append(q) + try: + while True: + yield await q.get() + finally: + with contextlib.suppress(ValueError): + self._state_subscribers.remove(q) + + # ------------------------------------------------------------------ + # Global maintenance — SSE-driven cache invalidation + # ------------------------------------------------------------------ + + async def subscribe_global_config(self) -> AsyncIterator[None]: + """Yield whenever the global or service-specific maintenance config changes. + + The signal arrives via the SDK's existing SSE connection: when + :meth:`_listen_sse` receives a state update for the global-config + sentinel key (``_GLOBAL_KEY``) or this service's own maintenance key, + it puts ``None`` into every queue registered here. The engine + consumes these signals to invalidate its in-process config cache. + """ + q: asyncio.Queue[None] = asyncio.Queue() + self._global_config_changed.append(q) + try: + while True: + await q.get() + yield None + finally: + try: + self._global_config_changed.remove(q) + except ValueError: + pass + + async def get_global_config(self) -> GlobalMaintenanceConfig: + """Return the effective global-maintenance config for this service. + + Checks (in order): + 1. All-services global maintenance (``_GLOBAL_KEY``) — if enabled, + return immediately (highest priority). + 2. Service-specific maintenance (``__shield:svc_global:__``) + — allows per-service emergency maintenance without touching all + other services. + + Both configs are read from the local SSE-driven cache so there is + no network hop per request. + """ + # 1. All-services global config + try: + state = self._cache.get(_GLOBAL_KEY) + if state is not None: + cfg = GlobalMaintenanceConfig.model_validate_json(state.reason) + if cfg.enabled: + return cfg + except Exception: # noqa: BLE001 + pass + # 2. Service-specific maintenance config + svc_key = f"__shield:svc_global:{self._app_id}__" + try: + state = self._cache.get(svc_key) + if state is not None: + return GlobalMaintenanceConfig.model_validate_json(state.reason) + except Exception: # noqa: BLE001 + pass + return GlobalMaintenanceConfig() + + # ------------------------------------------------------------------ + # Route deduplication helper + # ------------------------------------------------------------------ + + async def get_registered_paths(self) -> set[str]: + """Return local cache keys for route-deduplication in register_batch. + + Unlike the default implementation (which uses state ``.path`` fields + that include the service prefix), this returns the local cache keys — + i.e. plain paths like ``"GET:/api/payments"`` or ``"/api/payments"``. + This lets ``engine.register_batch()`` correctly detect routes that + are already known to this SDK instance, regardless of whether they + were synced from the server with or without a method prefix. + + The returned set also includes bare-path variants (method prefix + stripped) so that routes registered by ``ShieldRouter`` as + ``"GET:/api/payments"`` are matched against SDK-discovered paths of + ``"/api/payments"``. + """ + # Exclude sentinel keys (global config, service maintenance) so they + # are never treated as real routes in register_batch dedup checks. + keys = {k for k in self._cache.keys() if not k.startswith("__shield:")} + bare: set[str] = set() + _methods = ("GET:", "POST:", "PUT:", "PATCH:", "DELETE:", "HEAD:", "OPTIONS:") + for key in keys: + for m in _methods: + if key.startswith(m): + bare.add(key[len(m) :]) + break + return keys | bare # ------------------------------------------------------------------ # Rate limit policy — local cache, updated via SSE diff --git a/shield/core/engine.py b/shield/core/engine.py index ead8155..155869d 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -172,6 +172,41 @@ def set_global_exempt_paths(self, paths: list[str]) -> GlobalMaintenanceConfig: """Sync version of :meth:`ShieldEngine.set_global_exempt_paths`.""" return self._run(self._engine.set_global_exempt_paths(paths)) + def get_service_maintenance(self, service: str) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.get_service_maintenance`.""" + return self._run(self._engine.get_service_maintenance(service)) + + def enable_service_maintenance( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.enable_service_maintenance`.""" + return self._run( + self._engine.enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor=actor, + platform=platform, + ) + ) + + def disable_service_maintenance( + self, service: str, actor: str = "system", platform: str = "system" + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.disable_service_maintenance`.""" + return self._run( + self._engine.disable_service_maintenance( + service=service, actor=actor, platform=platform + ) + ) + # ------------------------------------------------------------------ # Rate limiting # ------------------------------------------------------------------ @@ -268,6 +303,62 @@ def disable_global_rate_limit(self, *, actor: str = "system", platform: str = "s """Sync version of :meth:`ShieldEngine.disable_global_rate_limit`.""" self._run(self._engine.disable_global_rate_limit(actor=actor, platform=platform)) + def get_service_rate_limit(self, service: str) -> Any: + """Sync version of :meth:`ShieldEngine.get_service_rate_limit`.""" + return self._run(self._engine.get_service_rate_limit(service)) + + def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Sync version of :meth:`ShieldEngine.set_service_rate_limit`.""" + return self._run( + self._engine.set_service_rate_limit( + service, + limit, + algorithm=algorithm, + key_strategy=key_strategy, + on_missing_key=on_missing_key, + burst=burst, + exempt_routes=exempt_routes, + actor=actor, + platform=platform, + ) + ) + + def delete_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.delete_service_rate_limit`.""" + self._run(self._engine.delete_service_rate_limit(service, actor=actor, platform=platform)) + + def reset_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.reset_service_rate_limit`.""" + self._run(self._engine.reset_service_rate_limit(service, actor=actor, platform=platform)) + + def enable_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.enable_service_rate_limit`.""" + self._run(self._engine.enable_service_rate_limit(service, actor=actor, platform=platform)) + + def disable_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.disable_service_rate_limit`.""" + self._run(self._engine.disable_service_rate_limit(service, actor=actor, platform=platform)) + # ------------------------------------------------------------------ # Read-only queries # ------------------------------------------------------------------ @@ -370,6 +461,7 @@ def __init__( self._rate_limiter: Any = None # ShieldRateLimiter | None self._rate_limit_policies: dict[str, Any] = {} # "METHOD:/path" → RateLimitPolicy self._global_rate_limit_policy: Any = None # GlobalRateLimitPolicy | None + self._service_rate_limit_policies: dict[str, Any] = {} # service → GlobalRateLimitPolicy # Sync proxy — created once, reused on every engine.sync access. self.sync: _SyncProxy = _SyncProxy(self) # Feature flags — lazily set by use_openfeature(). @@ -786,8 +878,10 @@ async def check( if state is None: return # no state registered → effectively ACTIVE + service = state.service if state is not None else None + if state.status == RouteStatus.ACTIVE: - return await self._run_rate_limit_check(path, method or "", context) + return await self._run_rate_limit_check(path, method or "", context, service=service) if state.status == RouteStatus.MAINTENANCE: retry_after = state.window.end if state.window else None @@ -808,15 +902,19 @@ async def check( if state.status == RouteStatus.DEPRECATED: # Deprecated routes still serve requests — headers injected by middleware. # Rate limit check still runs for deprecated routes. - return await self._run_rate_limit_check(path, method or "", context) + return await self._run_rate_limit_check(path, method or "", context, service=service) # Rate limiting runs after all lifecycle checks (maintenance / disabled # routes short-circuit before touching any counters). Within the rate # limit check, the global limit is evaluated first. - await self._run_rate_limit_check(path, method or "", context) + await self._run_rate_limit_check(path, method or "", context, service=service) async def _run_rate_limit_check( - self, path: str, method: str, context: dict[str, Any] | None + self, + path: str, + method: str, + context: dict[str, Any] | None, + service: str | None = None, ) -> Any: """Run the rate limit check for *path*/*method* if a policy is registered. @@ -837,6 +935,15 @@ async def _run_rate_limit_check( if not self._is_globally_exempt(path, method, grl.exempt_routes): await self._run_global_rate_limit_check(path, method, request, grl) + # Per-service rate limit — runs after the all-services global limit + # but before the per-route limit. Only applies when the route belongs + # to a named service and a policy has been configured for that service. + if service: + srl = self._service_rate_limit_policies.get(service) + if srl is not None and srl.enabled: + if not self._is_globally_exempt(path, method, srl.exempt_routes): + await self._run_service_rate_limit_check(path, method, request, srl, service) + # Per-route check — only reached when the global limit passed (or the # route is exempt from the global limit, or no global limit is set). route_result: Any = None @@ -865,6 +972,52 @@ async def _run_rate_limit_check( return route_result + async def _run_service_rate_limit_check( + self, + path: str, + method: str, + request: Any, + srl_policy: Any, + service: str, + ) -> None: + """Check the per-service rate limit for *path*/*method*. + + Uses ``__svc_rl:{service}__`` as the virtual path so all routes of + the same service share one counter namespace. Raises + ``RateLimitExceededException`` when the limit is exceeded. + """ + await self._ensure_rate_limiter() + + from shield.core.rate_limit.models import RateLimitPolicy + + virtual_path = f"__svc_rl:{service}__" + srl_as_policy = RateLimitPolicy( + path=virtual_path, + method="ALL", + limit=srl_policy.limit, + algorithm=srl_policy.algorithm, + key_strategy=srl_policy.key_strategy, + on_missing_key=srl_policy.on_missing_key, + burst=srl_policy.burst, + ) + + result = await self._rate_limiter.check( + path=virtual_path, + method="ALL", + request=request, + policy=srl_as_policy, + ) + + if not result.allowed: + await self._record_rate_limit_hit(path, method or "ALL", srl_as_policy, result) + raise RateLimitExceededException( + limit=result.limit, + retry_after_seconds=result.retry_after_seconds, + reset_at=result.reset_at, + remaining=0, + key=result.key, + ) + def _is_globally_exempt(self, path: str, method: str, exempt_routes: list[str]) -> bool: """Return ``True`` if *path*/*method* is in the global exempt list. @@ -1037,9 +1190,12 @@ async def register_batch(self, routes: list[tuple[str, dict[str, Any]]]) -> None return # One backend call to discover every already-persisted route. + # Use get_registered_paths() instead of list_states() so that + # backends that store routes under transformed keys (e.g. + # ShieldServerBackend which adds a service prefix) correctly + # compare against plain local paths. try: - existing = await self.backend.list_states() - existing_keys: set[str] = {s.path for s in existing} + existing_keys: set[str] = await self.backend.get_registered_paths() except Exception: logger.exception( "shield: register_batch — failed to list existing states, " @@ -1390,6 +1546,90 @@ async def set_global_exempt_paths( self._bump_schema_version() return updated + # ------------------------------------------------------------------ + # Per-service global maintenance + # ------------------------------------------------------------------ + + @staticmethod + def _service_global_key(service: str) -> str: + return f"__shield:svc_global:{service}__" + + async def get_service_maintenance(self, service: str) -> GlobalMaintenanceConfig: + """Return the current per-service maintenance config for *service*.""" + key = self._service_global_key(service) + try: + state = await self.backend.get_state(key) + return GlobalMaintenanceConfig.model_validate_json(state.reason) + except (KeyError, Exception): + return GlobalMaintenanceConfig() + + async def enable_service_maintenance( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Enable maintenance mode for all routes belonging to *service*. + + Stores a per-service sentinel in the backend (similar to the + all-services global maintenance sentinel). SDK clients with the + matching ``app_id`` pick this up via SSE and apply it as an + effective global maintenance for their service only. + """ + key = self._service_global_key(service) + cfg = GlobalMaintenanceConfig( + enabled=True, + reason=reason, + exempt_paths=exempt_paths or [], + include_force_active=include_force_active, + ) + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=cfg.model_dump_json(), + service=service, + ) + await self.backend.set_state(key, sentinel) + await self._audit( + path=key, + action="service_maintenance_on", + actor=actor, + reason=reason, + platform=platform, + previous_status=RouteStatus.ACTIVE, + new_status=RouteStatus.MAINTENANCE, + ) + return cfg + + async def disable_service_maintenance( + self, + service: str, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Disable per-service maintenance, restoring normal per-route state.""" + key = self._service_global_key(service) + cfg = GlobalMaintenanceConfig(enabled=False) + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=cfg.model_dump_json(), + service=service, + ) + await self.backend.set_state(key, sentinel) + await self._audit( + path=key, + action="service_maintenance_off", + actor=actor, + platform=platform, + previous_status=RouteStatus.MAINTENANCE, + new_status=RouteStatus.ACTIVE, + ) + return cfg + # ------------------------------------------------------------------ # Webhook management # ------------------------------------------------------------------ @@ -1757,6 +1997,7 @@ async def restore_rate_limit_policies(self) -> None: # Also restore the global rate limit policy if one was persisted. await self._restore_global_rate_limit_policy() + await self._restore_service_rate_limit_policies() async def _restore_global_rate_limit_policy(self) -> None: """Load the persisted global rate limit policy from the backend.""" @@ -1927,6 +2168,182 @@ async def disable_global_rate_limit( platform=platform, ) + async def _restore_service_rate_limit_policies(self) -> None: + """Load all persisted per-service rate limit policies from the backend.""" + try: + all_policies = await self.backend.get_all_service_rate_limit_policies() + except Exception: + logger.exception("shield: failed to restore service rate limit policies from backend") + return + + from shield.core.rate_limit.models import GlobalRateLimitPolicy + + for service, policy_data in all_policies.items(): + try: + self._service_rate_limit_policies[service] = GlobalRateLimitPolicy.model_validate( + policy_data + ) + logger.info("shield: restored service rate limit policy for %r", service) + except Exception: + logger.exception( + "shield: failed to parse service rate limit policy for %r", service + ) + + # ------------------------------------------------------------------ + # Per-service rate limit CRUD + # ------------------------------------------------------------------ + + @staticmethod + def _service_rl_key(service: str) -> str: + return f"__shield:svc_rl:{service}__" + + async def get_service_rate_limit(self, service: str) -> Any: + """Return the current ``GlobalRateLimitPolicy`` for *service*, or ``None``.""" + return self._service_rate_limit_policies.get(service) + + async def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Set or update the per-service rate limit policy for *service*. + + Applies to all routes of *service* combined. Persisted so the + policy survives restarts. Returns the ``GlobalRateLimitPolicy``. + """ + self._validate_limit_string(limit) + from shield.core.rate_limit.models import ( + GlobalRateLimitPolicy, + OnMissingKey, + RateLimitAlgorithm, + RateLimitKeyStrategy, + ) + + algo = RateLimitAlgorithm(algorithm) if algorithm else RateLimitAlgorithm.FIXED_WINDOW + key_strat = RateLimitKeyStrategy(key_strategy) if key_strategy else RateLimitKeyStrategy.IP + omk = OnMissingKey(on_missing_key) if on_missing_key else None + + is_update = service in self._service_rate_limit_policies + policy = GlobalRateLimitPolicy( + limit=limit, + algorithm=algo, + key_strategy=key_strat, + on_missing_key=omk, + burst=burst, + exempt_routes=exempt_routes or [], + enabled=True, + ) + self._service_rate_limit_policies[service] = policy + await self.backend.set_service_rate_limit_policy(service, policy.model_dump(mode="json")) + logger.info( + "shield: service rate limit policy %s for %r (%s) by %s", + "updated" if is_update else "set", + service, + limit, + actor, + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_updated" if is_update else "svc_rl_set", + actor=actor, + reason=f"{limit} · {algo} · {key_strat}", + platform=platform, + ) + return policy + + async def delete_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Remove the per-service rate limit policy for *service*.""" + self._service_rate_limit_policies.pop(service, None) + await self.backend.delete_service_rate_limit_policy(service) + logger.info("shield: service rate limit policy deleted for %r by %s", service, actor) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_deleted", + actor=actor, + platform=platform, + ) + + async def reset_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Reset the in-process rate limit counters for *service*. + + Clears the ``__svc_rl:{service}__`` counter namespace. The policy + itself is not removed. + """ + if self._rate_limiter is None: + return + virtual_path = f"__svc_rl:{service}__" + await self._rate_limiter.reset(path=virtual_path, method="ALL") + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_reset", + actor=actor, + platform=platform, + ) + + async def enable_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Re-enable a paused per-service rate limit policy.""" + policy = self._service_rate_limit_policies.get(service) + if policy is None or policy.enabled: + return + self._service_rate_limit_policies[service] = policy.model_copy(update={"enabled": True}) + await self.backend.set_service_rate_limit_policy( + service, self._service_rate_limit_policies[service].model_dump(mode="json") + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_enabled", + actor=actor, + platform=platform, + ) + + async def disable_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Pause (disable) a per-service rate limit policy without removing it.""" + policy = self._service_rate_limit_policies.get(service) + if policy is None or not policy.enabled: + return + self._service_rate_limit_policies[service] = policy.model_copy(update={"enabled": False}) + await self.backend.set_service_rate_limit_policy( + service, self._service_rate_limit_policies[service].model_dump(mode="json") + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_disabled", + actor=actor, + platform=platform, + ) + # ------------------------------------------------------------------ # Read methods # ------------------------------------------------------------------ diff --git a/shield/dashboard/app.py b/shield/dashboard/app.py index 0160f6d..0e5f6cd 100644 --- a/shield/dashboard/app.py +++ b/shield/dashboard/app.py @@ -76,6 +76,17 @@ def _clean_path(state: object) -> str: def _clean_entry_path(entry: object) -> str: svc = getattr(entry, "service", None) raw = getattr(entry, "path", "") + # Translate internal sentinel keys to human-friendly labels + if raw == "__global__": + return "[Global Maintenance]" + if raw == "__global_rl__": + return "[Global Rate Limit]" + if raw.startswith("__shield:svc_global:") and raw.endswith("__"): + name = raw[len("__shield:svc_global:") : -2] + return f"[{name} Maintenance]" + if raw.startswith("__shield:svc_rl:") and raw.endswith("__"): + name = raw[len("__shield:svc_rl:") : -2] + return f"[{name} Rate Limit]" if svc and raw.startswith(f"{svc}:"): return raw[len(svc) + 1 :] return raw @@ -98,9 +109,13 @@ def _clean_entry_path(entry: object) -> str: Route("/routes", r.routes_partial), Route("/modal/global/enable", r.modal_global_enable), Route("/modal/global/disable", r.modal_global_disable), + Route("/modal/service/enable", r.modal_service_enable), + Route("/modal/service/disable", r.modal_service_disable), Route("/modal/{action}/{path_key}", r.action_modal), Route("/global-maintenance/enable", r.global_maintenance_enable, methods=["POST"]), Route("/global-maintenance/disable", r.global_maintenance_disable, methods=["POST"]), + Route("/service-maintenance/enable", r.service_maintenance_enable, methods=["POST"]), + Route("/service-maintenance/disable", r.service_maintenance_disable, methods=["POST"]), Route("/toggle/{path_key}", r.toggle, methods=["POST"]), Route("/disable/{path_key}", r.disable, methods=["POST"]), Route("/enable/{path_key}", r.enable, methods=["POST"]), diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index fceb9cf..13a493b 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -184,6 +184,7 @@ async def index(request: Request) -> Response: if service: states = [s for s in states if s.service == service] global_config = await engine.get_global_maintenance() + service_config = await engine.get_service_maintenance(service) if service else None # Build a path → policy dict for the rate limit badge column. # Policies are keyed "METHOD:/path" so we index by path only (first match wins). rl_by_path: dict[str, object] = {} @@ -199,6 +200,7 @@ async def index(request: Request) -> Response: "states": paged["items"], "pagination": paged, "global_config": global_config, + "service_config": service_config, "rate_limit_policies": rl_by_path, "prefix": prefix, "active_tab": "routes", @@ -455,6 +457,85 @@ async def global_maintenance_disable(request: Request) -> HTMLResponse: return HTMLResponse(_render_global_widget(tpl, config, prefix)) +def _render_service_widget(tpl: Jinja2Templates, config: object, service: str, prefix: str) -> str: + """Render the per-service maintenance status widget partial.""" + return tpl.env.get_template("partials/service_maintenance.html").render( + config=config, + service=service, + prefix=prefix, + ) + + +async def modal_service_enable(request: Request) -> HTMLResponse: + """Return the per-service maintenance enable modal form.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_enable.html").render( + prefix=prefix, service=service + ) + return HTMLResponse(html) + + +async def modal_service_disable(request: Request) -> HTMLResponse: + """Return the per-service maintenance disable confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_disable.html").render( + prefix=prefix, service=service + ) + return HTMLResponse(html) + + +async def service_maintenance_enable(request: Request) -> HTMLResponse: + """Enable per-service maintenance from form data.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + service = str(form.get("service", "")) + reason = str(form.get("reason", "")) + exempt_raw = str(form.get("exempt_paths", "")) + exempt_paths = [p.strip() for p in exempt_raw.splitlines() if p.strip()] + include_force_active = form.get("include_force_active") == "1" + + if not service: + return HTMLResponse("Missing service", status_code=400) + + await engine.enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor=_actor(request), + platform=_platform(request), + ) + config = await engine.get_service_maintenance(service) + return HTMLResponse(_render_service_widget(tpl, config, service, prefix)) + + +async def service_maintenance_disable(request: Request) -> HTMLResponse: + """Disable per-service maintenance from form data.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + service = str(form.get("service", "")) + if not service: + return HTMLResponse("Missing service", status_code=400) + + await engine.disable_service_maintenance( + service=service, + actor=_actor(request), + platform=_platform(request), + ) + config = await engine.get_service_maintenance(service) + return HTMLResponse(_render_service_widget(tpl, config, service, prefix)) + + async def modal_env_gate(request: Request) -> HTMLResponse: """Return the env-gate modal form pre-filled with the current allowed envs.""" engine = _engine(request) @@ -561,6 +642,7 @@ async def rate_limits_page(request: Request) -> Response: policies = [p for p in policies if p.path in svc_paths] paged = _paginate(policies, page) global_rl = await engine.get_global_rate_limit() + service_rl = await engine.get_service_rate_limit(service) if service else None unrated_routes = _get_unrated_routes(states, engine._rate_limit_policies, service) return tpl.TemplateResponse( request, @@ -569,6 +651,7 @@ async def rate_limits_page(request: Request) -> Response: "policies": paged["items"], "pagination": paged, "global_rl": global_rl, + "service_rl": service_rl, "prefix": prefix, "active_tab": "rate_limits", "version": request.app.state.version, @@ -975,6 +1058,152 @@ async def global_rl_disable(request: Request) -> HTMLResponse: return HTMLResponse(html) +# --------------------------------------------------------------------------- +# Per-service rate limit dashboard handlers +# --------------------------------------------------------------------------- + + +async def modal_service_rl(request: Request) -> HTMLResponse: + """Return the per-service rate limit set/edit modal form.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/modal_service_rl.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def modal_service_rl_delete(request: Request) -> HTMLResponse: + """Return the per-service rate limit delete confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_rl_delete.html").render( + service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def modal_service_rl_reset(request: Request) -> HTMLResponse: + """Return the per-service rate limit reset confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_rl_reset.html").render( + service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_set(request: Request) -> HTMLResponse: + """Save per-service rate limit policy from form data and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + limit = str(form.get("limit", "")).strip() + algorithm = str(form.get("algorithm", "fixed_window")).strip() or None + key_strategy = str(form.get("key_strategy", "ip")).strip() or None + burst = int(str(form.get("burst", 0) or 0)) + exempt_raw = str(form.get("exempt_routes", "")).strip() + exempt_routes = [r.strip() for r in exempt_raw.splitlines() if r.strip()] + if limit and service: + try: + await engine.set_service_rate_limit( + service, + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=exempt_routes, + actor=_actor(request), + platform=_platform(request), + ) + except Exception: + pass + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_delete(request: Request) -> HTMLResponse: + """Delete per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.delete_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_reset(request: Request) -> HTMLResponse: + """Reset per-service rate limit counters and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.reset_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_enable(request: Request) -> HTMLResponse: + """Enable (resume) per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.enable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_disable(request: Request) -> HTMLResponse: + """Disable (pause) per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.disable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + async def rate_limits_hits_partial(request: Request) -> Response: """Return only the recent blocked requests table rows (HTMX auto-refresh).""" engine = _engine(request) diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index 73104e9..67008a2 100644 --- a/shield/dashboard/static/shield.min.css +++ b/shield/dashboard/static/shield.min.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-16{bottom:calc(var(--spacing) * 16)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-\[1\.45rem\]{height:1.45rem}.h-\[1\.85rem\]{height:1.85rem}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-60{width:calc(var(--spacing) * 60)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[18rem\]{max-width:18rem}.max-w-\[22rem\]{max-width:22rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[380px\]{min-width:380px}.min-w-\[500px\]{min-width:500px}.min-w-\[520px\]{min-width:520px}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-orange-100{border-color:var(--color-orange-100)}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-800{border-color:var(--color-red-800)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-700{border-color:var(--color-slate-700)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-950{background-color:var(--color-red-950)}.bg-red-950\/60{background-color:#46080999}@supports (color:color-mix(in lab, red, red)){.bg-red-950\/60{background-color:color-mix(in oklab, var(--color-red-950) 60%, transparent)}}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/50{background-color:#f8fafc80}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/50{background-color:color-mix(in oklab, var(--color-slate-50) 50%, transparent)}}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-violet-700{background-color:var(--color-violet-700)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-400{color:var(--color-indigo-400)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-red-200{color:var(--color-red-200)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-400{color:var(--color-violet-400)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-orange-600\/20{--tw-ring-color:#f0510033}@supports (color:color-mix(in lab, red, red)){.ring-orange-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-orange-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-200{--tw-ring-color:var(--color-violet-200)}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.group-open\/add\:rotate-45:is(:where(.group\/add):is([open],:popover-open,:open) *){rotate:45deg}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}.focus-within\:border-violet-500:focus-within{border-color:var(--color-violet-500)}.focus-within\:ring-1:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-violet-500:focus-within{--tw-ring-color:var(--color-violet-500)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:border-violet-300:hover{border-color:var(--color-violet-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-600:hover{background-color:var(--color-violet-600)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-violet-800:hover{color:var(--color-violet-800)}.hover\:text-violet-900:hover{color:var(--color-violet-900)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-violet-400:focus{border-color:var(--color-violet-400)}.focus\:border-violet-500:focus{border-color:var(--color-violet-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-300:focus{--tw-ring-color:var(--color-slate-300)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-400:focus{--tw-ring-color:var(--color-violet-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-violet-600:focus{--tw-ring-color:var(--color-violet-600)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-white:focus{--tw-ring-offset-color:var(--color-white)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-violet-800:active{background-color:var(--color-violet-800)}@media (min-width:480px){.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:flex-1{flex:1}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}}@media (min-width:48rem){.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:ml-auto{margin-left:auto}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.lg\:translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pl-60{padding-left:calc(var(--spacing) * 60)}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-16{bottom:calc(var(--spacing) * 16)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-\[1\.45rem\]{height:1.45rem}.h-\[1\.85rem\]{height:1.85rem}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-60{width:calc(var(--spacing) * 60)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[18rem\]{max-width:18rem}.max-w-\[22rem\]{max-width:22rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[380px\]{min-width:380px}.min-w-\[500px\]{min-width:500px}.min-w-\[520px\]{min-width:520px}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-orange-100{border-color:var(--color-orange-100)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-800{border-color:var(--color-red-800)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-700{border-color:var(--color-slate-700)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-950{background-color:var(--color-red-950)}.bg-red-950\/60{background-color:#46080999}@supports (color:color-mix(in lab, red, red)){.bg-red-950\/60{background-color:color-mix(in oklab, var(--color-red-950) 60%, transparent)}}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/50{background-color:#f8fafc80}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/50{background-color:color-mix(in oklab, var(--color-slate-50) 50%, transparent)}}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-500{background-color:var(--color-violet-500)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-violet-700{background-color:var(--color-violet-700)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-400{color:var(--color-indigo-400)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-red-200{color:var(--color-red-200)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-400{color:var(--color-violet-400)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-orange-300\/40{--tw-ring-color:#ffb96d66}@supports (color:color-mix(in lab, red, red)){.ring-orange-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-orange-300) 40%, transparent)}}.ring-orange-600\/20{--tw-ring-color:#f0510033}@supports (color:color-mix(in lab, red, red)){.ring-orange-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-orange-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-200{--tw-ring-color:var(--color-violet-200)}.ring-violet-300\/40{--tw-ring-color:#c4b4ff66}@supports (color:color-mix(in lab, red, red)){.ring-violet-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-violet-300) 40%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.group-open\/add\:rotate-45:is(:where(.group\/add):is([open],:popover-open,:open) *){rotate:45deg}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}.focus-within\:border-violet-500:focus-within{border-color:var(--color-violet-500)}.focus-within\:ring-1:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-violet-500:focus-within{--tw-ring-color:var(--color-violet-500)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-orange-200:hover{border-color:var(--color-orange-200)}.hover\:border-orange-400:hover{border-color:var(--color-orange-400)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:border-violet-200:hover{border-color:var(--color-violet-200)}.hover\:border-violet-300:hover{border-color:var(--color-violet-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-orange-50:hover{background-color:var(--color-orange-50)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-600:hover{background-color:var(--color-violet-600)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-orange-700:hover{color:var(--color-orange-700)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-violet-700:hover{color:var(--color-violet-700)}.hover\:text-violet-800:hover{color:var(--color-violet-800)}.hover\:text-violet-900:hover{color:var(--color-violet-900)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-violet-400:focus{border-color:var(--color-violet-400)}.focus\:border-violet-500:focus{border-color:var(--color-violet-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-orange-500:focus{--tw-ring-color:var(--color-orange-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-300:focus{--tw-ring-color:var(--color-slate-300)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-400:focus{--tw-ring-color:var(--color-violet-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-violet-600:focus{--tw-ring-color:var(--color-violet-600)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-white:focus{--tw-ring-offset-color:var(--color-white)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-violet-800:active{background-color:var(--color-violet-800)}@media (min-width:480px){.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:flex-1{flex:1}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}}@media (min-width:48rem){.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:ml-auto{margin-left:auto}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.lg\:translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pl-60{padding-left:calc(var(--spacing) * 60)}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/shield/dashboard/templates/base.html b/shield/dashboard/templates/base.html index 02aaadc..ba208d1 100644 --- a/shield/dashboard/templates/base.html +++ b/shield/dashboard/templates/base.html @@ -256,7 +256,7 @@