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 @@
-
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.
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 @@