Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
9
ai_platform/modules/dashboard/.dockerignore
Normal file
9
ai_platform/modules/dashboard/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.venv
|
||||
.git
|
||||
.pytest_cache
|
||||
|
||||
# Don't copy node deps into build context — Dockerfile installs fresh
|
||||
web/node_modules
|
||||
web/dist
|
||||
83
ai_platform/modules/dashboard/.env.example
Normal file
83
ai_platform/modules/dashboard/.env.example
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# =============================================================================
|
||||
# Dashboard Module — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values marked with CHANGE_ME.
|
||||
# All variables use the DASHBOARD_ prefix.
|
||||
#
|
||||
# Most dashboard settings mirror what web-api uses — the dashboard talks
|
||||
# to the same provider APIs to display live quota + billing info.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED — PostgreSQL
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are used by both the postgres container and the dashboard app.
|
||||
# Change the password before deploying to a shared environment.
|
||||
DASHBOARD_DB_USER=dashboard
|
||||
DASHBOARD_DB_PASSWORD=CHANGE_ME_secure_password
|
||||
DASHBOARD_DB_NAME=dashboard
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED — External URL for OpenAPI spec
|
||||
# -----------------------------------------------------------------------------
|
||||
DASHBOARD_EXTERNAL_URL=http://localhost:51300
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Provider API keys (for live quota / billing pages)
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are read-only — the dashboard queries each provider's /account
|
||||
# endpoint to show how many credits are left. Leave blank to disable
|
||||
# that provider's card.
|
||||
# DASHBOARD_SERPAPI_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_TAVILY_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_BRAVE_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_LINKUP_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_EXA_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_OPENROUTER_API_KEY=CHANGE_ME_sk-or-v1-...
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Upstream service URLs for health checks
|
||||
# -----------------------------------------------------------------------------
|
||||
# The dashboard probes these to show their status on /providers page.
|
||||
# Point the LLM URLs at your GPU machine (no GPU on this host).
|
||||
DASHBOARD_WEB_API_URL=http://didiAI-web-api:51100
|
||||
DASHBOARD_SEARXNG_URL=http://didiAI-web-searxng:8080
|
||||
DASHBOARD_LLM_API_URL=CHANGE_ME_http://your-gpu-host:14011
|
||||
DASHBOARD_VLLM_QWEN_URL=CHANGE_ME_http://your-gpu-host:14001
|
||||
# Comma-separated list of llama.cpp / vLLM servers (load balanced)
|
||||
# DASHBOARD_LLAMACPP_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Retention & caching
|
||||
# -----------------------------------------------------------------------------
|
||||
# How many days of request_history to keep (older rows are purged hourly).
|
||||
DASHBOARD_HISTORY_RETENTION_DAYS=30
|
||||
# How long to cache provider quota responses (seconds).
|
||||
DASHBOARD_PROVIDER_STATS_CACHE_SECONDS=30
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Authentication
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy static tokens — leave blank and use DB-backed users instead:
|
||||
# docker exec didiAI-dashboard python -m dashboard.cli create-user <name>
|
||||
# DASHBOARD_API_TOKENS=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Server settings
|
||||
# -----------------------------------------------------------------------------
|
||||
# DASHBOARD_HOST=0.0.0.0
|
||||
# DASHBOARD_PORT=51300
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
# DASHBOARD_LOG_LEVEL=INFO
|
||||
# DASHBOARD_LOG_JSON=false
|
||||
274
ai_platform/modules/dashboard/INDEX.md
Normal file
274
ai_platform/modules/dashboard/INDEX.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Dashboard Module - INDEX
|
||||
|
||||
AI platform monitoring/admin dashboard. Browse archived claims, view ingest history, audit trail, runtime config overrides, costs per provider/tier, and live provider quotas. Single FastAPI service serving:
|
||||
1. **React 19 + MUI 7 SPA** at `/admin-ai/` (default modern UI, added in Phase C 2026-05-02)
|
||||
2. **Jinja2 templates** at `/`, `/history`, `/cost`, `/providers`, `/archive`, `/audit`, `/config` (legacy, kept side-by-side until full deprecation)
|
||||
3. **JSON API** at `/api/*` and `/admin-ai/api/*` (dual-mounted) consumed by `web-api` + the SPA itself
|
||||
|
||||
## Stack
|
||||
|
||||
- Python 3.10+ (FastAPI 0.115+, Uvicorn)
|
||||
- React 19 + MUI 7 + Vite 7 + react-router 7 + TanStack Query + Recharts (built into `/app/web_dist/`, served as static via SPAStaticFiles with index-fallback)
|
||||
- Jinja2 templates legacy (HTMX + Alpine.js + Tailwind CDN — kept until React reaches 100% parity)
|
||||
- SQLAlchemy 2.0 async + asyncpg, Alembic
|
||||
- pydantic-settings (env prefix `DASHBOARD_`)
|
||||
- httpx for live provider quota fetching, brain proxy
|
||||
- python-jose[cryptography] for Keycloak JWT validation
|
||||
|
||||
## Coordinates
|
||||
|
||||
- **SPA URL**: `https://10.11.10.12:8443/admin-ai/` (via frontend nginx) or `http://10.11.10.12:51300/admin-ai/` (direct)
|
||||
- **Public URL**: `https://didi365.eu/admin-ai/` (via Cloudflare tunnel + frontend nginx)
|
||||
- Container: `didiAI-dashboard` (alongside `didiAI-dashboard-db` on `:15432`)
|
||||
- Compose: `deploy/docker-compose.yml`, profile `dashboard`
|
||||
- Network: `didi-network` (unified single network for all DIDI + AI platform stacks since 2026-05-04)
|
||||
|
||||
## Auth (current state — 2026-05-04)
|
||||
|
||||
**Hybrid auth** in `dependencies.py:verify_bearer_token`:
|
||||
1. `STAGING_MODE=true` → all auth bypassed (default for dev)
|
||||
2. JWT (3 dot-separated parts) → validated via `keycloak_auth.py` (JWKS cache 10min, signature, issuer, exp, role check)
|
||||
3. DB-backed bearer tokens (legacy, from CLI `dashboard create-user`)
|
||||
4. Static `api_tokens` env var (legacy fallback)
|
||||
5. Otherwise → 401
|
||||
|
||||
**Keycloak settings** (`config.py:DashboardSettings`):
|
||||
- `keycloak_url`, `keycloak_realm` (default `didi-clients`), `keycloak_client_id` (default `ai-platform-dashboard`)
|
||||
- `keycloak_required_role` (default `admin` — same role as DIDI admin-dashboard for unified access)
|
||||
- Manual setup: create client + assign role via Keycloak admin or `deploy/setup-keycloak.sh`
|
||||
|
||||
**Cutover from staging to prod**:
|
||||
- Set `DASHBOARD_KEYCLOAK_URL=https://sso.clossers.com`, `DASHBOARD_STAGING_MODE=false`, `VITE_STAGING_MODE=false`
|
||||
- Rebuild image with build args (Dockerfile bakes Keycloak config into JS bundle at build time)
|
||||
|
||||
Plan/runbook: `AI_PLATFORM_RESKIN_PLAN.md` (Phase C.8) + `/home/admin365/didi_mono/UNIFIED_KEYCLOAK_CUTOVER.md`
|
||||
|
||||
## Ce face
|
||||
|
||||
Sections served as HTML pages (`pages.py`) + JSON-mirror endpoints in `routes/`:
|
||||
|
||||
- **Overview** (`/`) - KPIs (totals, error rate, avg duration, total cost) + provider grid (live quotas)
|
||||
- **Providers** (`/providers`) - detailed provider cards + raw table (live SerpAPI/Tavily/Brave/LinkUp/Exa/OpenRouter/internal stats)
|
||||
- **History** (`/history`, `/history/{request_id}`) - request log with filters (tier, provider, endpoint, hours) + drill-down with full stages + raw_request/response
|
||||
- **Cost** (`/cost`) - 24h/7d/30d spend, projected monthly, by-provider, by-tier, top 10 expensive requests, quota-vs-budget bars
|
||||
- **Archive** (`/archive`, `/archive/{claim_id}`) - browse promoted claims + linked articles (permanent storage seeded via `POST /archive/promote/{request_id}`)
|
||||
- **Audit** (`/audit`) - audit log entries (config.set, config.reset, config.delete, archive.promote)
|
||||
- **Config** (`/config`) - runtime overrides table grouped by category (providers/routing/llm/tiers); HTMX in-place edit + reset
|
||||
|
||||
## API endpoints
|
||||
|
||||
### `routes/health.py`
|
||||
- `GET /health` - liveness + DB ping
|
||||
- `GET /ready` - app.state populated check
|
||||
|
||||
### `routes/ingest.py` (no auth, service-to-service)
|
||||
- `POST /api/ingest/event` - receives request events from `web-api` middleware; trims fields, computes cost via `pricing.estimate_cost` if missing, inserts into `request_history`
|
||||
|
||||
### `routes/history.py` (no auth, read-only)
|
||||
- `GET /api/history` - filtered list (tier, provider, endpoint, hours, limit, offset)
|
||||
- `GET /api/history/{request_id}` - full record (includes stages + raw_request/response)
|
||||
|
||||
### `routes/stats.py` (no auth, read-only)
|
||||
- `GET /api/stats/providers?force=` - live provider stats (cached `provider_stats_cache_seconds`, default 30s)
|
||||
- `GET /api/stats/summary?hours=` - aggregated counters (totals, by_tier, by_provider, by_endpoint, error_rate, avg_duration, total_cost)
|
||||
- `GET /api/stats/timeline?hours=` - hourly buckets (`date_trunc('hour', ...)`) for charts
|
||||
|
||||
### `routes/archive.py`
|
||||
- `GET /api/archive/claims` - paginated claims list with optional `q` ilike search
|
||||
- `GET /api/archive/claims/{claim_id}` - single claim + linked articles
|
||||
- `POST /api/archive/promote/{request_id}` (auth) - promote a `request_history` row into `claims_archive` + `articles_archive` + `claim_articles`; only `/v1/gather` rows can be promoted
|
||||
|
||||
### `routes/config.py`
|
||||
- `GET /api/config` - all keys with overrides merged on top of `KNOWN_KEYS` defaults (consumed by `web-api`, no auth)
|
||||
- `GET /api/config/{key}` - single key
|
||||
- `PUT /api/config/{key}` (auth) - set override, validates against schema (bool/int/enum/csv/string), writes audit log
|
||||
- `DELETE /api/config/{key}` (auth) - revert to default, writes audit log
|
||||
|
||||
`KNOWN_KEYS` in `routes/config.py` enumerates the runtime keys consumed by `web-api`: `web.providers.{serpapi,tavily,brave,linkup,exa}.enabled`, `web.premium.strategy`, `web.premium.priority_order`, `web.openrouter.model`, `web.tier.{free,premium}.max_search_results`.
|
||||
|
||||
### `routes/pages.py` (Jinja HTML)
|
||||
- `GET /` - overview
|
||||
- `GET /history`, `GET /history/{request_id}`
|
||||
- `GET /providers`
|
||||
- `GET /cost`
|
||||
- `GET /archive`, `GET /archive/{claim_id}`
|
||||
- `GET /audit`
|
||||
- `GET /config` + HTMX form handlers `POST /config/{key}` and `POST /config/{key}/reset` (return partial fragments)
|
||||
|
||||
## Structura fisiere
|
||||
|
||||
```
|
||||
src/dashboard/
|
||||
__init__.py
|
||||
cli.py # admin CLI: create-user, list-users, delete-user (python -m dashboard.cli)
|
||||
auth.py # SHA-256 + hmac.compare_digest, User CRUD
|
||||
config.py # DashboardSettings (pydantic-settings, DASHBOARD_ prefix), SettingsCache
|
||||
logging.py # get_logger helper (JSON or text via DASHBOARD_LOG_JSON)
|
||||
pricing.py # estimate_cost(endpoint, tier, provider) - per-provider USD
|
||||
retention.py # 30-day rolling cleanup of request_history
|
||||
api/
|
||||
app.py # FastAPI factory, lifespan (init engine + provider registry), router wiring
|
||||
dependencies.py # get_session, get_registry, verify_bearer_token, get_username
|
||||
routes/
|
||||
archive.py # claims archive CRUD + promote
|
||||
config.py # KNOWN_KEYS + runtime override CRUD
|
||||
health.py # /health, /ready
|
||||
history.py # /api/history (read-only)
|
||||
ingest.py # POST /api/ingest/event (service-to-service)
|
||||
pages.py # all Jinja HTML pages + HTMX handlers
|
||||
stats.py # /api/stats/{providers,summary,timeline}
|
||||
db/
|
||||
models.py # Base, RequestHistory, ProviderStatsHourly, ConfigOverride, User, AuditLog,
|
||||
# ClaimsArchive, ArticlesArchive, ClaimArticle
|
||||
session.py # init_engine, get_session, get_session_factory, close_engine
|
||||
providers/
|
||||
base.py # ProviderStats dataclass + ProviderClient ABC
|
||||
registry.py # ProviderRegistry (cached fan-out across providers)
|
||||
serpapi.py # SerpAPI quota + plan price
|
||||
tavily.py # Tavily quota
|
||||
brave.py # Brave Search quota
|
||||
linkup.py # LinkUp quota
|
||||
openrouter.py # OpenRouter spend
|
||||
internal.py # Internal services (web-api, SearXNG, vLLM, llama.cpp) live health
|
||||
templates/ # Jinja
|
||||
base.html
|
||||
overview.html
|
||||
archive.html, archive_detail.html
|
||||
audit.html
|
||||
config.html
|
||||
cost.html
|
||||
history.html, history_detail.html
|
||||
providers.html
|
||||
partials/
|
||||
config_row.html # HTMX swap target after edit/reset
|
||||
provider_card.html # reusable card on overview + providers pages
|
||||
quota_bar.html # quota progress bar
|
||||
static/
|
||||
css/, js/ # tailwind via CDN, htmx + alpine inline
|
||||
deploy/
|
||||
Dockerfile
|
||||
docker-compose.yml # didiAI-dashboard + didiAI-dashboard-db (postgres:16-alpine)
|
||||
deploy.sh # convenience wrapper around `docker compose --profile dashboard`
|
||||
tests/
|
||||
...
|
||||
pyproject.toml # hatchling build, ruff inherited from ../../ruff.toml
|
||||
uv.lock
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
`didiAI-dashboard-db` (postgres:16-alpine, host `:15432` -> container `:5432`). Tables:
|
||||
|
||||
- `request_history` - 30-day rolling per-request log (BigInt id, request_id unique, JSON stages/raw_request/raw_response, cost_usd Numeric(12,6), indexes on created_at, tier, provider, endpoint)
|
||||
- `provider_stats_hourly` - rollups by (provider, hour)
|
||||
- `config_overrides` - runtime config k/v overrides (key PK, JSON value, updated_by)
|
||||
- `users` - dashboard users (username unique, token_hash SHA-256, role, last_login)
|
||||
- `audit_log` - mutation history (timestamp, username, action, target, old_value/new_value JSON)
|
||||
- `claims_archive` - permanent claims storage (claim_hash unique, verdict, confidence Numeric(5,4), summary, entities JSON, tags JSON)
|
||||
- `articles_archive` - permanent article full-text (url unique, url_hash unique, full_text, publisher, credibility_score)
|
||||
- `claim_articles` - M2M claims <-> articles (relevance_score, snippet)
|
||||
|
||||
Connection string format: `postgresql+asyncpg://USER:PASS@didiAI-dashboard-db:5432/DB` injected via `DASHBOARD_DATABASE_URL`.
|
||||
|
||||
## Pages
|
||||
|
||||
- `base.html` - layout shell (Tailwind CDN, sidebar nav, HTMX + Alpine includes)
|
||||
- `overview.html` - KPI tiles + provider grid (uses `partials/provider_card.html` + `partials/quota_bar.html`)
|
||||
- `providers.html` - full provider cards + raw quota table
|
||||
- `history.html` / `history_detail.html` - filterable list + drill-down with stages JSON pretty-print
|
||||
- `cost.html` - cost cards + by-provider/by-tier breakdown + budget bars
|
||||
- `archive.html` / `archive_detail.html` - claim search + linked articles
|
||||
- `audit.html` - chronological mutation log
|
||||
- `config.html` - runtime overrides grouped by category, HTMX inline edit -> `partials/config_row.html`
|
||||
|
||||
## Authentication (current state)
|
||||
|
||||
- Bearer token in `Authorization: Bearer <token>` header
|
||||
- `dependencies.verify_bearer_token` reads header, hashes, scans `users`, returns `User` or 401
|
||||
- `dependencies.get_username(principal)` extracts username for audit log
|
||||
- Token issuance via CLI inside container:
|
||||
```
|
||||
docker exec -it didiAI-dashboard python -m dashboard.cli create-user <name> [--email] [--role admin|viewer]
|
||||
```
|
||||
- All read endpoints + `POST /api/ingest/event` are auth-free (VPN-internal trust)
|
||||
|
||||
## Reskin plan (FUTURE - NOT done yet)
|
||||
|
||||
- React 19 + MUI 7 + Keycloak SSO **DONE 2026-05-02** (Phase C in `agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`, detail in `AI_PLATFORM_RESKIN_PLAN.md`)
|
||||
- Becomes admin-only (Keycloak realm role)
|
||||
- Bearer-token table retired; existing `role` column may persist for historical audit-log mapping
|
||||
- Brain admin UI (atom browse, force-gold, brain stats) added as a new section in this dashboard during Phase C
|
||||
- Jinja templates + HTMX endpoints in `pages.py` will be replaced by JSON endpoints; existing `routes/*.py` JSON API stays as the contract
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||
cp ../.env.example .env # set DASHBOARD_DB_USER/PASSWORD/NAME + provider keys
|
||||
./deploy.sh up # docker compose --profile dashboard up -d --build
|
||||
```
|
||||
|
||||
- Healthcheck: `python -c urllib.request.urlopen('http://localhost:51300/health')` every 30s
|
||||
- Restart policy: `unless-stopped`
|
||||
- Settings prefix: `DASHBOARD_*` (see `config.py` for full list)
|
||||
|
||||
## Ce NU face
|
||||
|
||||
- ~~No SSO yet~~ Keycloak SSO wired (DONE 2026-05-02). Bearer tokens kept as legacy fallback.
|
||||
- No multi-tenant - single shared `users` table, no per-tenant scoping
|
||||
- No public access - binds to internal `didi-network` network, not exposed via Kong/edge
|
||||
- ~~No React frontend yet~~ React 19 SPA at `/admin-ai/` (DONE 2026-05-02). Jinja kept side-by-side until parity.
|
||||
- No real-time push - HTMX polling, no WebSockets/SSE
|
||||
|
||||
## Related docs
|
||||
|
||||
- AI platform CLAUDE.md: `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
|
||||
- Module README: `/home/admin365/didi_mono/ai_platform/modules/dashboard/README.md`
|
||||
- Reskin + Brain admin plan (Phase C): `/home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`
|
||||
- Web-api ingest middleware (the producer for `POST /api/ingest/event`): `/home/admin365/didi_mono/ai_platform/modules/web-api/`
|
||||
|
||||
## Recent Changes (2026-05-05)
|
||||
|
||||
- **3 pagini noi**:
|
||||
- `/admin-ai/operations/live` (Live Status) — KPI 1h cu refresh 5s, Recent Activity feed (latest 20 cu chip warning daca >1h vechi), Provider Health cu cache age, throughput sparkline 6h
|
||||
- `/admin-ai/system/settings` (System Settings) — health endpoint, identity card cu roluri, Configuration Surface (98 chei + breakdown per modul + count overrides), Quick Access dynamic links
|
||||
- `/admin-ai/system/schema` (Schema Overrides) — CRUD UI pentru `config_schema_override` (Register/Edit/Delete via dialog cu validare type/min/max/options)
|
||||
- **Pagini eliminate**: `/admin-ai/system/users` (admin backend gestioneaza userii Keycloak)
|
||||
- **Overview**: card Phase C status chips -> inlocuit cu Quick Links chips clickable (Live Status, History, Cost, Providers, Brain Atoms, Audit Log, Schema Overrides)
|
||||
- **Modules ModulePage** (toate 8): tab "Live State" functional cu real backend `/api/proxy/{moduleId}/health` (status badge, latency, payload `/v1/info`, refresh 10s, env override DASHBOARD_<MOD>_HEALTH_URL); tab "Actions" reformat ca "Pending Restarts" cu lista overrides cu `restart_required=true` + comanda SSH copy-able
|
||||
- **Backend endpoint nou**: `routes/proxy.py` cu `GET /api/proxy/{module_id}/health` — proxy catre modul real, hardcoded URLs pe 10.11.10.17 cu fallback la `DASHBOARD_<MODULE>_HEALTH_URL` env override
|
||||
- **Cost endpoint imbunatatit**: `GET /api/stats/cost` returneaza `projection_basis` (blend 30d=40%/7d=60%, sau 7d_avg, sau 24h_only), `projection_confidence` (stable/moderate/rough), `trend` (increasing/decreasing/stable), `trend_pct`. UI Cost page afiseaza confidence chip + trend arrow (↗↘→).
|
||||
- **Providers endpoint imbunatatit**: `GET /api/stats/providers` adauga `last_refresh` (ISO), `age_seconds`, `cache_ttl_seconds`. UI Providers + Live Status afiseaza "refreshed Xs ago" chip (warning peste 120s).
|
||||
- **ProviderRegistry**: tracks `_cache_wall: datetime` separat de `_cache_time` (monotonic), expune properties `last_refresh_iso` + `age_seconds`.
|
||||
- **Frontend type fix**: `Live.tsx` + `Providers.tsx` foloseau campuri inexistente (`status`, `quota_used_pct`, `plan`) — corectate la `healthy: bool`, `quota_percent_used`, `display_name`, `plan_name` (matching backend response).
|
||||
- **Schema migration**: 98 chei seed la primul startup in `config_schema_override`, vizibile in Schema Overrides UI cu actiuni Edit/Delete.
|
||||
|
||||
---
|
||||
|
||||
## Brain admin pages — Phase D2 (2026-05-05)
|
||||
|
||||
3 rute noi sub `/admin-ai/brain/` + 1 tab nou pe `system/audit`. Toate consumă brain prin proxy-ul `/api/brain/*`.
|
||||
|
||||
### `pages/brain/Facts.tsx`
|
||||
|
||||
Browser pe `brain_fact_status` cu filter (entity ILIKE, predicate exact, current_truth, locked_only, topic), DataGrid paginat. Click pe rând → drawer dreapta cu Triple summary (chip volatility + lock + topics), **Truth timeline** Stepper cu toate `brain_fact_version` rows, **Moderator override form** (moderator_user_id + set_truth + confidence + evidence URLs + lock/unlock + notes → PATCH `/v1/fact_status/{id}`).
|
||||
|
||||
### `pages/brain/Invalidate.tsx`
|
||||
|
||||
Form-driven mass invalidation cu UX 2-step: Build filter (topic_codes, entity_canonicals, claim_pattern, since, invalidate_gold cu warning) → **Preview (dry run)** → **Confirm invalidate** (button enabled doar după Preview). Side panel: ultimele 10 invalidări (auto-refresh 30s).
|
||||
|
||||
### `pages/system/AuditLog.tsx` — refactor cu tabs
|
||||
|
||||
Wrapper cu Tabs: **Dashboard tab** → `AuditLogDashboard.tsx` (existing code extras intact); **Brain tab** → `AuditLogBrain.tsx` nou, citește `/api/brain/v1/cache/audit_log` cu action presets (judge_*, fact_truth_*, invalidate, promote_gold), action chip color-coded, payload tooltip JSON pretty.
|
||||
|
||||
### Backend (`brain_proxy.py`)
|
||||
|
||||
Whitelist extins cu: `/v1/fact_status/list`, `/v1/fact_status/`, `/v1/cache/audit_log`, `/v1/cache/invalidate`, `/v1/canonicalize`. GET pass-through; POST/PATCH cer bearer.
|
||||
|
||||
### `types/brain.ts` extins
|
||||
|
||||
`Volatility`, `FactStatusItem(+List+VersionItem+Versions+Patch)Response`, `AuditLogItem(+Response)`, `CacheInvalidate(Request|Response)`.
|
||||
|
||||
### Routes + sidebar
|
||||
|
||||
`App.tsx`: `brain/facts`, `brain/invalidate`. `AppShell.tsx` Brain Admin section: Fact Status (FactCheckIcon), Invalidate (DeleteSweepIcon).
|
||||
60
ai_platform/modules/dashboard/README.md
Normal file
60
ai_platform/modules/dashboard/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Dashboard
|
||||
|
||||
Admin dashboard for the didiAI platform. Tracks search provider usage, costs, request history, and exposes runtime configuration.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Live quota & billing** — pulls real-time data from SerpAPI, Tavily, Brave, OpenRouter
|
||||
- **Health monitoring** — SearXNG, web-api, vLLM, llama.cpp servers
|
||||
- **Request history** — 30-day rolling log of every gather/search/fetch request with drill-down
|
||||
- **Cost tracking** — per-provider spend, projections, cost per tier
|
||||
- **Future:** runtime config (toggle providers, change strategies, manage tier caps)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker 24+ with Compose V2
|
||||
- PostgreSQL 16 (provided by compose)
|
||||
- Internal network access to web-api, SearXNG, LLM servers
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp ../.env.example .env # edit with your secrets
|
||||
./deploy.sh up
|
||||
```
|
||||
|
||||
Dashboard is now running at http://localhost:51300
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Web UI
|
||||
- `/` — overview with KPIs and provider grid
|
||||
- `/providers` — detailed provider cards + raw table
|
||||
- `/history` — filterable request history
|
||||
- `/history/{request_id}` — full request detail with stages
|
||||
|
||||
### JSON API
|
||||
- `GET /health` — liveness
|
||||
- `GET /api/stats/providers` — live provider stats
|
||||
- `GET /api/stats/summary?hours=24` — aggregated counters
|
||||
- `GET /api/stats/timeline?hours=24` — hourly buckets for charts
|
||||
- `GET /api/history?limit=50&tier=premium` — filtered history
|
||||
- `GET /api/history/{request_id}` — single request with full payload
|
||||
- `POST /api/ingest/event` — receives events from web-api middleware
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
web-api ────► POST /api/ingest/event ────► dashboard-api ────► PostgreSQL
|
||||
│
|
||||
├─ reads provider APIs live
|
||||
└─ serves UI via Jinja2+HTMX
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- FastAPI + Pydantic
|
||||
- SQLAlchemy 2.0 async + asyncpg
|
||||
- Jinja2 + HTMX + Alpine.js + Tailwind (zero build step)
|
||||
- PostgreSQL 16
|
||||
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Stage 1: build React SPA ----
|
||||
FROM node:20-alpine AS web-builder
|
||||
WORKDIR /web
|
||||
|
||||
# Build-time SPA config — pass via `--build-arg VITE_*=...`. Defaults
|
||||
# point at the DIDI SSO cluster, which is what production uses.
|
||||
ARG VITE_KEYCLOAK_URL=https://sso.clossers.com
|
||||
ARG VITE_KEYCLOAK_REALM=didi-clients
|
||||
ARG VITE_KEYCLOAK_CLIENT_ID=ai-platform-dashboard
|
||||
ARG VITE_KEYCLOAK_REQUIRED_ROLE=admin
|
||||
ARG VITE_STAGING_MODE=false
|
||||
|
||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL \
|
||||
VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM \
|
||||
VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID \
|
||||
VITE_KEYCLOAK_REQUIRED_ROLE=$VITE_KEYCLOAK_REQUIRED_ROLE \
|
||||
VITE_STAGING_MODE=$VITE_STAGING_MODE
|
||||
|
||||
# Cache deps
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
# Build
|
||||
COPY web/index.html web/tsconfig.json web/vite.config.ts ./
|
||||
COPY web/src ./src
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: build Python deps ----
|
||||
FROM python:3.11.12-slim AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /uvx /bin/
|
||||
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN uv venv /app/.venv && uv pip install --python /app/.venv/bin/python \
|
||||
fastapi uvicorn[standard] pydantic pydantic-settings httpx \
|
||||
"sqlalchemy[asyncio]>=2.0.36" asyncpg alembic jinja2 python-multipart
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN uv pip install --python /app/.venv/bin/python -e .
|
||||
|
||||
# ---- Stage 3: runtime ----
|
||||
FROM python:3.11.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app /app
|
||||
COPY --from=web-builder /web/dist /app/web_dist
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH=/app/src \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 51300
|
||||
|
||||
CMD ["uvicorn", "dashboard.api.app:app", "--host", "0.0.0.0", "--port", "51300"]
|
||||
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[[ -f .env ]] && set -a && source .env && set +a
|
||||
|
||||
check_required_var() {
|
||||
if [[ -z "${!1:-}" ]]; then
|
||||
echo "ERROR: Required variable $1 not set (check .env)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./deploy.sh {up|down|logs|restart}
|
||||
|
||||
Actions:
|
||||
up Build and start dashboard + database
|
||||
down Stop containers (volumes preserved)
|
||||
logs Tail logs
|
||||
restart Restart dashboard without rebuild
|
||||
EOF
|
||||
}
|
||||
|
||||
check_required_var DASHBOARD_DB_USER
|
||||
check_required_var DASHBOARD_DB_PASSWORD
|
||||
check_required_var DASHBOARD_DB_NAME
|
||||
check_required_var DASHBOARD_EXTERNAL_URL
|
||||
|
||||
case "${1:-}" in
|
||||
up)
|
||||
docker compose --profile dashboard up -d --build
|
||||
echo "Dashboard started at ${DASHBOARD_EXTERNAL_URL}"
|
||||
;;
|
||||
down)
|
||||
docker compose --profile dashboard down
|
||||
;;
|
||||
logs)
|
||||
docker compose --profile dashboard logs -f --tail=100
|
||||
;;
|
||||
restart)
|
||||
docker compose --profile dashboard restart
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Dashboard Module - Docker Compose
|
||||
#
|
||||
# Port Allocation (Dev: 51300):
|
||||
# 51300 - Dashboard API + Web UI
|
||||
# 15432 - PostgreSQL (optional host exposure for debugging)
|
||||
#
|
||||
# Naming Convention: didiAI-{module}-{service}
|
||||
|
||||
networks:
|
||||
didi-network:
|
||||
external: true # single shared network for all DIDI + AI platform stacks
|
||||
|
||||
volumes:
|
||||
dashboard_db_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# PostgreSQL
|
||||
# ==========================================================================
|
||||
dashboard-db:
|
||||
container_name: didiAI-dashboard-db
|
||||
image: postgres:16-alpine
|
||||
networks:
|
||||
- didi-network
|
||||
environment:
|
||||
- POSTGRES_USER=${DASHBOARD_DB_USER}
|
||||
- POSTGRES_PASSWORD=${DASHBOARD_DB_PASSWORD}
|
||||
- POSTGRES_DB=${DASHBOARD_DB_NAME}
|
||||
volumes:
|
||||
- dashboard_db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DASHBOARD_DB_USER} -d ${DASHBOARD_DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- dashboard
|
||||
|
||||
# ==========================================================================
|
||||
# Dashboard API + Web UI
|
||||
# ==========================================================================
|
||||
dashboard-api:
|
||||
container_name: didiAI-dashboard
|
||||
image: didiai-dashboard
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
args:
|
||||
# SPA build-time config — Keycloak settings get baked into the JS bundle.
|
||||
# Default: local Keycloak proxied through admin nginx at /auth/.
|
||||
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://10.11.10.11:3001/auth}
|
||||
VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-didi-admins}
|
||||
VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||
VITE_KEYCLOAK_REQUIRED_ROLE: ${VITE_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||
VITE_STAGING_MODE: ${VITE_STAGING_MODE:-false}
|
||||
ports:
|
||||
- "51300:51300"
|
||||
networks:
|
||||
- didi-network
|
||||
depends_on:
|
||||
dashboard-db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DASHBOARD_HOST=0.0.0.0
|
||||
- DASHBOARD_PORT=51300
|
||||
- DASHBOARD_DATABASE_URL=postgresql+asyncpg://${DASHBOARD_DB_USER}:${DASHBOARD_DB_PASSWORD}@didiAI-dashboard-db:5432/${DASHBOARD_DB_NAME}
|
||||
- DASHBOARD_BRAIN_URL=${DASHBOARD_BRAIN_URL:-http://didibrain-api:8090}
|
||||
# Keycloak SSO (leave empty to disable JWT auth)
|
||||
- DASHBOARD_KEYCLOAK_URL=${DASHBOARD_KEYCLOAK_URL:-}
|
||||
- DASHBOARD_KEYCLOAK_REALM=${DASHBOARD_KEYCLOAK_REALM:-didi-clients}
|
||||
- DASHBOARD_KEYCLOAK_CLIENT_ID=${DASHBOARD_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||
- DASHBOARD_KEYCLOAK_REQUIRED_ROLE=${DASHBOARD_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||
- DASHBOARD_STAGING_MODE=${DASHBOARD_STAGING_MODE:-false}
|
||||
# OTel — traces to Jaeger via OTel Collector
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
|
||||
- OTEL_SERVICE_NAME=didiAI-dashboard
|
||||
# Module health endpoint overrides — point at real upstreams
|
||||
# (raw vLLM at 10.11.10.15 instead of the unbuilt FastAPI wrapper at 10.11.10.17:14100/14200)
|
||||
- DASHBOARD_EMBEDDINGS_HEALTH_URL=${DASHBOARD_EMBEDDINGS_HEALTH_URL:-http://10.11.10.15:8200/v1/models}
|
||||
- DASHBOARD_RERANK_HEALTH_URL=${DASHBOARD_RERANK_HEALTH_URL:-http://10.11.10.15:8100/v1/models}
|
||||
# Catalog runs locally on Docker DNS; /v1/status is the aggregated reachability probe
|
||||
# (catalog does not expose /v1/info — it consumes it from other modules)
|
||||
- DASHBOARD_CATALOG_HEALTH_URL=${DASHBOARD_CATALOG_HEALTH_URL:-http://didiAI-catalog-api:11000/v1/status}
|
||||
# Gateway is nginx — only /health is auth-free and inline
|
||||
- DASHBOARD_GATEWAY_HEALTH_URL=${DASHBOARD_GATEWAY_HEALTH_URL:-http://didiAI-gateway:11000/health}
|
||||
# Brain doesn't expose /v1/info — point at /health (matches catalog rationale)
|
||||
- DASHBOARD_BRAIN_HEALTH_URL=${DASHBOARD_BRAIN_HEALTH_URL:-http://didibrain-api:8090/health}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:51300/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- dashboard
|
||||
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bash
|
||||
# Set up Keycloak client `ai-platform-dashboard` on sso.clossers.com realm `didi-clients`.
|
||||
# Idempotent — safe to re-run; existing client gets updated to match this config.
|
||||
#
|
||||
# Usage:
|
||||
# KC_ADMIN_USER=admin KC_ADMIN_PASS=admin123 ./setup-keycloak.sh
|
||||
#
|
||||
# Optional overrides:
|
||||
# KC_BASE_URL=https://sso.clossers.com
|
||||
# KC_REALM=didi-clients
|
||||
# KC_CLIENT_ID=ai-platform-dashboard
|
||||
# DASHBOARD_HOST=10.11.10.12 # used to build redirect URI
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Keycloak admin endpoints redirect public sso.clossers.com → internal
|
||||
# sso.clossers.local. Token issuer must match the host you call, so we use the
|
||||
# internal name end-to-end (this host has DNS for it).
|
||||
KC_BASE_URL="${KC_BASE_URL:-https://sso.clossers.local}"
|
||||
KC_REALM="${KC_REALM:-didi-clients}"
|
||||
KC_CLIENT_ID="${KC_CLIENT_ID:-ai-platform-dashboard}"
|
||||
KC_ADMIN_USER="${KC_ADMIN_USER:-admin}"
|
||||
KC_ADMIN_PASS="${KC_ADMIN_PASS:?Set KC_ADMIN_PASS}"
|
||||
DASHBOARD_HOST="${DASHBOARD_HOST:-10.11.10.12}"
|
||||
DASHBOARD_PORT="${DASHBOARD_PORT:-51300}"
|
||||
|
||||
echo "→ Authenticating to Keycloak master realm…"
|
||||
ADMIN_TOKEN=$(
|
||||
curl -ksS -X POST "$KC_BASE_URL/realms/master/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "client_id=admin-cli&grant_type=password&username=$KC_ADMIN_USER&password=$KC_ADMIN_PASS" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])"
|
||||
)
|
||||
[ -n "$ADMIN_TOKEN" ] || { echo "FAIL: could not get admin token"; exit 1; }
|
||||
echo " OK (token length ${#ADMIN_TOKEN})"
|
||||
|
||||
# ---------- Step 1: ensure realm role 'admin' exists ----------
|
||||
echo "→ Checking realm role 'admin' exists in realm '$KC_REALM'…"
|
||||
RAW=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/roles/admin")
|
||||
if echo "$RAW" | grep -q '"name"'; then
|
||||
echo " OK — role 'admin' present"
|
||||
else
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"admin","description":"Platform admin (DIDI + AI platform)"}' \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/roles"
|
||||
echo " CREATED"
|
||||
fi
|
||||
|
||||
# ---------- Step 2: ensure client `ai-platform-dashboard` exists ----------
|
||||
echo "→ Checking client '$KC_CLIENT_ID' exists…"
|
||||
EXISTING=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID")
|
||||
CLIENT_UUID=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||
|
||||
REDIRECTS=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps([
|
||||
'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||
'https://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||
'http://localhost:$DASHBOARD_PORT/admin-ai/*',
|
||||
'http://localhost:5173/*',
|
||||
# Reverse-proxy URL (admin-dashboard nginx exposes /admin-ai/ on port 3000)
|
||||
'https://$DASHBOARD_HOST:3000/admin-ai/*',
|
||||
]))
|
||||
")
|
||||
|
||||
PAYLOAD=$(python3 -c "
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
'clientId': '$KC_CLIENT_ID',
|
||||
'name': 'AI Platform Admin Dashboard',
|
||||
'description': 'Reskinned admin dashboard for the AI platform (web/llm/embeddings/rerank/audio/video/catalog/brain)',
|
||||
'rootUrl': 'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/',
|
||||
'baseUrl': '/admin-ai/',
|
||||
'enabled': True,
|
||||
'protocol': 'openid-connect',
|
||||
'publicClient': True,
|
||||
'standardFlowEnabled': True,
|
||||
'directAccessGrantsEnabled': False,
|
||||
'serviceAccountsEnabled': False,
|
||||
'frontchannelLogout': True,
|
||||
'redirectUris': $REDIRECTS,
|
||||
'webOrigins': ['+'],
|
||||
'attributes': {
|
||||
'pkce.code.challenge.method': 'S256',
|
||||
'post.logout.redirect.uris': '+',
|
||||
},
|
||||
}))
|
||||
")
|
||||
|
||||
if [ -z "$CLIENT_UUID" ]; then
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||
CLIENT_UUID=$(
|
||||
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||
)
|
||||
echo " CREATED uuid=$CLIENT_UUID"
|
||||
else
|
||||
echo " Found uuid=$CLIENT_UUID — updating to current spec…"
|
||||
curl -ksSL -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients/$CLIENT_UUID"
|
||||
echo " UPDATED"
|
||||
fi
|
||||
|
||||
# ---------- Step 3: smoke test JWKS reachable ----------
|
||||
echo "→ Smoke test: JWKS endpoint reachable…"
|
||||
JWKS_KEYS=$(curl -ksS \
|
||||
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs" \
|
||||
| python3 -c "import json,sys; print(len(json.load(sys.stdin).get('keys',[])))")
|
||||
echo " OK — JWKS returns $JWKS_KEYS keys"
|
||||
|
||||
# ---------- Step 3b: ensure admin-dashboard client also exists (DIDI side) ----------
|
||||
# This is a no-op when the client already exists with correct config; included
|
||||
# so a fresh Keycloak install gets both apps wired in one go.
|
||||
ADMIN_DASH_CLIENT="admin-dashboard"
|
||||
echo "→ Checking client '$ADMIN_DASH_CLIENT' (DIDI admin) exists…"
|
||||
EXISTING_AD=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT")
|
||||
ADMIN_DASH_UUID=$(echo "$EXISTING_AD" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||
|
||||
ADMIN_DASH_REDIRECTS=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps([
|
||||
'https://$DASHBOARD_HOST:3000/admin/*',
|
||||
'https://$DASHBOARD_HOST:3000/admin-backend/*',
|
||||
'http://localhost:3000/admin/*',
|
||||
]))
|
||||
")
|
||||
|
||||
ADMIN_DASH_PAYLOAD=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'clientId': '$ADMIN_DASH_CLIENT',
|
||||
'name': 'DIDI Admin Dashboard',
|
||||
'description': 'Backend admin (framework config, users, moderation queue, history)',
|
||||
'rootUrl': 'https://$DASHBOARD_HOST:3000/admin/',
|
||||
'baseUrl': '/admin/',
|
||||
'enabled': True,
|
||||
'protocol': 'openid-connect',
|
||||
'publicClient': True,
|
||||
'standardFlowEnabled': True,
|
||||
'directAccessGrantsEnabled': False,
|
||||
'redirectUris': $ADMIN_DASH_REDIRECTS,
|
||||
'webOrigins': ['+'],
|
||||
'attributes': {
|
||||
'pkce.code.challenge.method': 'S256',
|
||||
'post.logout.redirect.uris': '+',
|
||||
},
|
||||
}))
|
||||
")
|
||||
|
||||
if [ -z "$ADMIN_DASH_UUID" ]; then
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$ADMIN_DASH_PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||
ADMIN_DASH_UUID=$(
|
||||
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||
)
|
||||
echo " CREATED uuid=$ADMIN_DASH_UUID"
|
||||
else
|
||||
echo " Found uuid=$ADMIN_DASH_UUID — leaving config as-is (would overwrite custom redirect URIs)"
|
||||
fi
|
||||
|
||||
# ---------- Step 4: print summary ----------
|
||||
cat <<EOF
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Keycloak setup complete.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Realm: $KC_REALM
|
||||
Client: $KC_CLIENT_ID (uuid=$CLIENT_UUID)
|
||||
Role: admin (realm)
|
||||
Issuer: $KC_BASE_URL/realms/$KC_REALM
|
||||
JWKS: $KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs
|
||||
|
||||
REDIRECTS:
|
||||
$(python3 -c "import json; [print(f' - {u}') for u in $REDIRECTS]")
|
||||
|
||||
NEXT STEPS:
|
||||
1. Verify YOUR user has the 'admin' realm role assigned in $KC_REALM.
|
||||
(If you can already log into the DIDI admin-dashboard, you do.)
|
||||
|
||||
2. Update dashboard/.env:
|
||||
DASHBOARD_KEYCLOAK_URL=$KC_BASE_URL
|
||||
DASHBOARD_STAGING_MODE=false
|
||||
VITE_STAGING_MODE=false
|
||||
|
||||
3. Rebuild + redeploy:
|
||||
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||
docker compose --profile dashboard up -d --build dashboard-api
|
||||
|
||||
4. Open http://$DASHBOARD_HOST:$DASHBOARD_PORT/v2/
|
||||
→ Keycloak login redirect → enter creds → SPA loads with your JWT.
|
||||
|
||||
EOF
|
||||
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[project]
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
description = "Admin dashboard for web search providers, cost tracking, and config management"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.9.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"httpx>=0.27.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"jinja2>=3.1.4",
|
||||
"python-multipart>=0.0.12",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||
"opentelemetry-instrumentation-httpx>=0.50b0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.8.0",
|
||||
"respx>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/dashboard"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../ruff.toml"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Dashboard module — admin UI for web search providers and costs."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""FastAPI application."""
|
||||
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""FastAPI application factory."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
"""StaticFiles that falls back to index.html for client-side routes.
|
||||
|
||||
`StaticFiles(html=True)` only serves index.html for the mount root —
|
||||
/v2/brain/atoms etc. would 404 since there's no file at that path.
|
||||
React-router with BrowserRouter needs the SPA shell served for any
|
||||
unmatched URL so the JS bundle can pick up the route. We override
|
||||
`lookup_path` to return index.html as a final fallback (after the
|
||||
standard html=True attempts: path, path+".html", path+"/index.html").
|
||||
"""
|
||||
|
||||
def lookup_path(self, path: str): # type: ignore[override]
|
||||
full_path, stat_result = super().lookup_path(path)
|
||||
if stat_result is None:
|
||||
# Fall back to root index.html so React Router can handle it
|
||||
return super().lookup_path("index.html")
|
||||
return full_path, stat_result
|
||||
|
||||
from dashboard.api.routes import (
|
||||
archive,
|
||||
audit,
|
||||
brain_proxy,
|
||||
catalog,
|
||||
config,
|
||||
health,
|
||||
history,
|
||||
ingest,
|
||||
monitoring,
|
||||
pages,
|
||||
proxy,
|
||||
stats,
|
||||
)
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.db.session import close_engine, create_all_tables, init_engine
|
||||
from dashboard.logging import configure_logging, get_logger
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
from dashboard.retention import retention_loop
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
# React SPA built artifacts. Lives at <repo>/dashboard/web/dist; in the docker
|
||||
# image this is copied to /app/web_dist by the multi-stage Dockerfile.
|
||||
WEB_DIST_DIR = Path("/app/web_dist")
|
||||
if not WEB_DIST_DIR.exists():
|
||||
# Fall back to repo-relative path when running from a checkout (uv run uvicorn ...)
|
||||
WEB_DIST_DIR = Path(__file__).resolve().parents[3] / "web" / "dist"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings = SettingsCache.get()
|
||||
configure_logging(settings.log_level, settings.log_json)
|
||||
logger.info("Starting Dashboard on port %d", settings.port)
|
||||
|
||||
if settings.staging_mode:
|
||||
logger.warning(
|
||||
"SECURITY: staging_mode=true — ALL authentication/RBAC is bypassed. "
|
||||
"This MUST be disabled in production (set DASHBOARD_STAGING_MODE=false)."
|
||||
)
|
||||
|
||||
init_engine(settings)
|
||||
await create_all_tables()
|
||||
|
||||
# Seed schema from hardcoded KNOWN_KEYS on first run (idempotent — skips if non-empty).
|
||||
from dashboard.api.routes.config import seed_schema_if_empty
|
||||
from dashboard.db.session import get_session
|
||||
async for seed_session in get_session():
|
||||
seeded = await seed_schema_if_empty(seed_session)
|
||||
if seeded:
|
||||
logger.info("config_schema_override seeded with %d entries", seeded)
|
||||
break
|
||||
|
||||
app.state.settings = settings
|
||||
app.state.registry = ProviderRegistry(settings)
|
||||
|
||||
# Background retention task
|
||||
retention_task = asyncio.create_task(retention_loop(settings))
|
||||
|
||||
logger.info("Dashboard started successfully")
|
||||
yield
|
||||
|
||||
retention_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await retention_task
|
||||
|
||||
await close_engine()
|
||||
logger.info("Dashboard shutdown complete")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = SettingsCache.get()
|
||||
|
||||
app = FastAPI(
|
||||
title="Dashboard API",
|
||||
description="Admin dashboard for web search providers and cost tracking",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
servers=[{"url": settings.external_url, "description": "Dashboard API"}],
|
||||
)
|
||||
|
||||
# Static files (if any) — Tailwind/HTMX come via CDN for zero-build
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Prometheus /metrics + OTel tracing (no-op if deps missing)
|
||||
try:
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||
Instrumentator(should_group_status_codes=True).instrument(app).expose(
|
||||
app, endpoint="/metrics", include_in_schema=False
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
import os as _os
|
||||
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if _otel_ep:
|
||||
try:
|
||||
from opentelemetry import trace as _trace # type: ignore
|
||||
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor as _HXInst # type: ignore
|
||||
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-dashboard")}))
|
||||
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||
_trace.set_tracer_provider(_provider)
|
||||
_FInst.instrument_app(app)
|
||||
_HXInst().instrument()
|
||||
print(f"[otel] didiAI-dashboard instrumented → {_otel_ep}")
|
||||
except ImportError as _e:
|
||||
print(f"[otel] skip: {_e}")
|
||||
|
||||
# JSON API routes — registered TWICE so they're reachable both at /api/*
|
||||
# (direct access to dashboard:51300) AND at /admin-ai/api/* (when reached
|
||||
# via the admin-dashboard nginx reverse proxy on port 3000). Same handlers,
|
||||
# both URLs Just Work.
|
||||
app.include_router(health.router, tags=["Health"])
|
||||
for prefix in ("/api", "/admin-ai/api"):
|
||||
app.include_router(stats.router, prefix=prefix, tags=["Stats"])
|
||||
app.include_router(history.router, prefix=prefix, tags=["History"])
|
||||
app.include_router(ingest.router, prefix=prefix, tags=["Ingest"])
|
||||
app.include_router(config.router, prefix=prefix, tags=["Config"])
|
||||
app.include_router(archive.router, prefix=prefix, tags=["Archive"])
|
||||
app.include_router(audit.router, prefix=prefix, tags=["Audit"])
|
||||
app.include_router(brain_proxy.router, prefix=prefix, tags=["Brain"])
|
||||
app.include_router(proxy.router, prefix=prefix, tags=["Proxy"])
|
||||
app.include_router(monitoring.router, prefix=prefix, tags=["Monitoring"])
|
||||
app.include_router(catalog.router, prefix=prefix, tags=["Catalog"])
|
||||
|
||||
# HTML pages (Jinja+HTMX legacy — kept until React reaches parity)
|
||||
app.include_router(pages.router, tags=["Pages"])
|
||||
|
||||
# React SPA mounted at /v2/ — runs side-by-side with Jinja during reskin.
|
||||
# StaticFiles(html=True) handles SPA fallback to index.html for client-side routes.
|
||||
if WEB_DIST_DIR.exists():
|
||||
app.mount(
|
||||
"/admin-ai",
|
||||
SPAStaticFiles(directory=str(WEB_DIST_DIR), html=True),
|
||||
name="web-spa",
|
||||
)
|
||||
logger.info("React SPA mounted at /admin-ai from %s", WEB_DIST_DIR)
|
||||
else:
|
||||
logger.warning(
|
||||
"React dist not found at %s — /admin-ai will return 404. Run `npm run build` in dashboard/web/.",
|
||||
WEB_DIST_DIR,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""FastAPI dependencies."""
|
||||
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.keycloak_auth import has_required_role, verify_keycloak_jwt
|
||||
from dashboard.auth import find_user_by_token, record_login
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import User
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
logger = get_logger("dependencies")
|
||||
|
||||
|
||||
def get_settings(request: Request) -> DashboardSettings:
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
def get_registry(request: Request) -> ProviderRegistry:
|
||||
return request.app.state.registry
|
||||
|
||||
|
||||
async def verify_bearer_token(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User | str | dict[str, Any] | None:
|
||||
"""Resolve auth — Keycloak JWT first, legacy bearer second, anonymous last.
|
||||
|
||||
Resolution order:
|
||||
0. If `staging_mode=true`, return a synthetic "staging" principal
|
||||
1. If `keycloak_url` is set AND a Bearer token is provided, try JWT verification.
|
||||
On success, the principal is the JWT payload dict.
|
||||
2. Else if a Users row matches the token, return that User
|
||||
3. Else if the token is in settings.api_tokens (legacy), return the raw string
|
||||
4. Else (no auth configured at all): return None (endpoint open)
|
||||
5. Else raise 401
|
||||
"""
|
||||
settings: DashboardSettings = request.app.state.settings
|
||||
|
||||
if settings.staging_mode:
|
||||
return {"_staging": True, "username": "staging-mode", "_roles": []}
|
||||
|
||||
keycloak_enabled = bool(settings.keycloak_url)
|
||||
|
||||
# Snapshot if there's any local auth configured (for fallback path)
|
||||
from sqlalchemy import func, select
|
||||
|
||||
user_count = (await session.execute(select(func.count(User.id)))).scalar() or 0
|
||||
has_legacy_tokens = bool(settings.api_tokens)
|
||||
|
||||
auth_configured = keycloak_enabled or user_count > 0 or has_legacy_tokens
|
||||
if not auth_configured:
|
||||
return None # Auth disabled
|
||||
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "Authentication required"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail={"error": "Invalid Authorization"})
|
||||
|
||||
token = parts[1]
|
||||
|
||||
# 1) Try Keycloak JWT — JWTs are typically much longer than DB tokens
|
||||
# so we attempt this first when Keycloak is configured.
|
||||
if keycloak_enabled and token.count(".") == 2: # JWTs have 3 parts (header.payload.sig)
|
||||
try:
|
||||
payload = await verify_keycloak_jwt(token, settings)
|
||||
if not has_required_role(payload, settings.keycloak_required_role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Forbidden",
|
||||
"required_role": settings.keycloak_required_role,
|
||||
"user_roles": payload.get("_roles", []),
|
||||
},
|
||||
)
|
||||
return payload
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Not a valid JWT — fall through to local auth methods
|
||||
logger.debug("JWT path failed, falling back: %s", e)
|
||||
|
||||
# 2) DB-backed users
|
||||
if user_count > 0:
|
||||
user = await find_user_by_token(session, token)
|
||||
if user is not None:
|
||||
await record_login(session, user)
|
||||
return user
|
||||
|
||||
# 3) Legacy static tokens
|
||||
if has_legacy_tokens:
|
||||
is_valid = any(
|
||||
hmac.compare_digest(token.encode(), valid.encode())
|
||||
for valid in settings.api_tokens
|
||||
)
|
||||
if is_valid:
|
||||
return token
|
||||
|
||||
raise HTTPException(status_code=401, detail={"error": "Invalid token"})
|
||||
|
||||
|
||||
def get_username(principal: User | str | dict[str, Any] | None) -> str:
|
||||
"""Extract a display name for audit logs from the auth result."""
|
||||
if principal is None:
|
||||
return "anonymous"
|
||||
if isinstance(principal, User):
|
||||
return principal.username
|
||||
if isinstance(principal, dict):
|
||||
# Keycloak JWT payload — prefer email, then preferred_username, then sub
|
||||
return (
|
||||
principal.get("email")
|
||||
or principal.get("preferred_username")
|
||||
or principal.get("sub")
|
||||
or "keycloak-user"
|
||||
)
|
||||
return "legacy-token"
|
||||
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Keycloak JWT verification for the AI platform dashboard.
|
||||
|
||||
We do NOT proxy /auth/ here — the React SPA hits Keycloak directly at
|
||||
`{keycloak_url}/realms/{realm}/...`. Backend's only job is to validate
|
||||
incoming Bearer JWTs against the realm's JWKS and check the required role.
|
||||
|
||||
JWKS is cached for 10 min. On signature failure (e.g., key rotation) the
|
||||
cache is invalidated and refetched once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from jose import jwt
|
||||
from jose.exceptions import ExpiredSignatureError, JWTError
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("keycloak_auth")
|
||||
|
||||
_JWKS_CACHE: dict[str, Any] = {"keys": None, "fetched_at": 0.0, "url": ""}
|
||||
_JWKS_TTL_SECONDS = 600 # 10 min
|
||||
|
||||
|
||||
async def _fetch_jwks(url: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _get_jwks(settings: DashboardSettings, force: bool = False) -> dict[str, Any]:
|
||||
"""Return JWKS for the configured realm, with TTL cache."""
|
||||
if not settings.keycloak_url:
|
||||
raise RuntimeError("Keycloak URL not configured")
|
||||
url = (
|
||||
f"{settings.keycloak_url.rstrip('/')}/realms/"
|
||||
f"{settings.keycloak_realm}/protocol/openid-connect/certs"
|
||||
)
|
||||
now = time.time()
|
||||
cached_for_url = _JWKS_CACHE["url"] == url
|
||||
fresh = cached_for_url and (now - _JWKS_CACHE["fetched_at"]) < _JWKS_TTL_SECONDS
|
||||
if not force and fresh and _JWKS_CACHE["keys"] is not None:
|
||||
return _JWKS_CACHE["keys"]
|
||||
|
||||
keys = await _fetch_jwks(url)
|
||||
_JWKS_CACHE["keys"] = keys
|
||||
_JWKS_CACHE["fetched_at"] = now
|
||||
_JWKS_CACHE["url"] = url
|
||||
logger.info(
|
||||
"JWKS refreshed (realm=%s, keys=%d)",
|
||||
settings.keycloak_realm,
|
||||
len(keys.get("keys", [])),
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _expected_issuer(settings: DashboardSettings) -> str:
|
||||
return f"{settings.keycloak_url.rstrip('/')}/realms/{settings.keycloak_realm}"
|
||||
|
||||
|
||||
def _extract_roles(payload: dict[str, Any]) -> list[str]:
|
||||
"""Pick up realm roles + client roles for the configured client."""
|
||||
roles: list[str] = []
|
||||
realm_access = payload.get("realm_access") or {}
|
||||
if isinstance(realm_access, dict):
|
||||
roles.extend(realm_access.get("roles") or [])
|
||||
resource_access = payload.get("resource_access") or {}
|
||||
if isinstance(resource_access, dict):
|
||||
for client_block in resource_access.values():
|
||||
if isinstance(client_block, dict):
|
||||
roles.extend(client_block.get("roles") or [])
|
||||
return roles
|
||||
|
||||
|
||||
async def verify_keycloak_jwt(
|
||||
token: str,
|
||||
settings: DashboardSettings,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a Keycloak JWT against JWKS. Raises HTTPException on failure.
|
||||
|
||||
Returns the decoded payload + a synthetic `_roles` list flattened from
|
||||
realm_access + resource_access. Audience is NOT enforced — Keycloak
|
||||
issues tokens with audience='account' by default; instead we verify the
|
||||
issuer + signature + exp + presence of the required role.
|
||||
"""
|
||||
if not settings.keycloak_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Keycloak not configured",
|
||||
)
|
||||
|
||||
issuer = _expected_issuer(settings)
|
||||
|
||||
async def _decode_with_keys(force_refresh: bool) -> dict[str, Any]:
|
||||
jwks = await _get_jwks(settings, force=force_refresh)
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
jwks,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
|
||||
try:
|
||||
payload = await _decode_with_keys(force_refresh=False)
|
||||
except JWTError:
|
||||
# Try once with fresh JWKS in case keys rotated
|
||||
try:
|
||||
payload = await _decode_with_keys(force_refresh=True)
|
||||
except JWTError as e:
|
||||
logger.warning("JWT verify failed after JWKS refresh: %s", e)
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
||||
|
||||
payload["_roles"] = _extract_roles(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def has_required_role(payload: dict[str, Any], required_role: str) -> bool:
|
||||
return required_role in (payload.get("_roles") or [])
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""API routes."""
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""Claims & articles archive — permanent storage for valuable gather results.
|
||||
|
||||
Promoting a request_history row extracts its claim and evidence items and
|
||||
stores them in claims_archive + articles_archive. These tables will later
|
||||
be consumed by a dedicated claims-api that serves archived claims through
|
||||
the same /v1/gather response schema as web-api — no network search required.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||
from dashboard.db.models import (
|
||||
ArticlesArchive,
|
||||
ClaimArticle,
|
||||
ClaimsArchive,
|
||||
RequestHistory,
|
||||
User,
|
||||
)
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("archive")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Read endpoints (public — will be consumed by claims-api too)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/archive/claims")
|
||||
async def list_claims(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
q: str | None = Query(default=None, description="Case-insensitive claim search"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List archived claims with optional search."""
|
||||
stmt = select(ClaimsArchive)
|
||||
if q:
|
||||
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit).offset(offset)
|
||||
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
count_stmt = select(func.count(ClaimsArchive.id))
|
||||
if q:
|
||||
count_stmt = count_stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
total = (await session.execute(count_stmt)).scalar() or 0
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
"claim": r.claim,
|
||||
"verdict": r.verdict,
|
||||
"confidence": float(r.confidence) if r.confidence is not None else None,
|
||||
"summary": r.summary,
|
||||
"primary_country": r.primary_country,
|
||||
"detected_language": r.detected_language,
|
||||
"promoted_by": r.promoted_by,
|
||||
"tags": r.tags,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
|
||||
@router.get("/archive/claims/{claim_id}")
|
||||
async def get_claim(
|
||||
claim_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Get a single claim with its linked articles."""
|
||||
claim = (
|
||||
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=404, detail="Claim not found")
|
||||
|
||||
# Load linked articles
|
||||
links_stmt = (
|
||||
select(ClaimArticle, ArticlesArchive)
|
||||
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||
.where(ClaimArticle.claim_id == claim_id)
|
||||
)
|
||||
links = (await session.execute(links_stmt)).all()
|
||||
|
||||
articles = [
|
||||
{
|
||||
"id": article.id,
|
||||
"url": article.url,
|
||||
"title": article.title,
|
||||
"publisher": article.publisher,
|
||||
"published_at": article.published_at.isoformat()
|
||||
if article.published_at
|
||||
else None,
|
||||
"retrieved_at": article.retrieved_at.isoformat()
|
||||
if article.retrieved_at
|
||||
else None,
|
||||
"credibility_score": float(article.credibility_score)
|
||||
if article.credibility_score is not None
|
||||
else None,
|
||||
"relevance_score": float(link.relevance_score)
|
||||
if link.relevance_score is not None
|
||||
else None,
|
||||
"snippet": link.snippet,
|
||||
"full_text": article.full_text,
|
||||
}
|
||||
for link, article in links
|
||||
]
|
||||
|
||||
return {
|
||||
"id": claim.id,
|
||||
"claim": claim.claim,
|
||||
"verdict": claim.verdict,
|
||||
"confidence": float(claim.confidence) if claim.confidence is not None else None,
|
||||
"summary": claim.summary,
|
||||
"primary_country": claim.primary_country,
|
||||
"detected_language": claim.detected_language,
|
||||
"entities": claim.entities,
|
||||
"promoted_by": claim.promoted_by,
|
||||
"tags": claim.tags,
|
||||
"created_at": claim.created_at.isoformat() if claim.created_at else None,
|
||||
"source_request_id": claim.source_request_id,
|
||||
"articles": articles,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Write endpoints (auth required)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/archive/promote/{request_id}")
|
||||
async def promote_request(
|
||||
request_id: str,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Promote a request_history row into the permanent archive.
|
||||
|
||||
Extracts the claim, search_context and evidence items from the
|
||||
stored raw_response and creates/updates the corresponding rows.
|
||||
"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
if row.endpoint != "/v1/gather":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only /v1/gather requests can be promoted",
|
||||
)
|
||||
|
||||
if not row.raw_response:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Request has no stored response (too large or not a success)",
|
||||
)
|
||||
|
||||
raw = row.raw_response
|
||||
claim_text = raw.get("claim") or row.claim
|
||||
if not claim_text:
|
||||
raise HTTPException(status_code=400, detail="Missing claim text")
|
||||
|
||||
claim_hash = _sha256(claim_text.strip().lower())
|
||||
context = raw.get("search_context") or {}
|
||||
evidence_items = raw.get("evidence") or []
|
||||
|
||||
# Upsert claim
|
||||
existing_claim = (
|
||||
await session.execute(
|
||||
select(ClaimsArchive).where(ClaimsArchive.claim_hash == claim_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
username = get_username(principal)
|
||||
|
||||
if existing_claim is None:
|
||||
claim_row = ClaimsArchive(
|
||||
claim=claim_text,
|
||||
claim_hash=claim_hash,
|
||||
source_request_id=request_id,
|
||||
primary_country=context.get("primary_country"),
|
||||
detected_language=context.get("detected_language"),
|
||||
entities=context.get("entities"),
|
||||
promoted_by=username,
|
||||
tags=[],
|
||||
summary=_build_auto_summary(evidence_items),
|
||||
)
|
||||
session.add(claim_row)
|
||||
await session.flush() # need the id for the link rows
|
||||
else:
|
||||
claim_row = existing_claim
|
||||
# Refresh metadata on re-promotion
|
||||
claim_row.source_request_id = request_id
|
||||
claim_row.promoted_by = username
|
||||
claim_row.primary_country = context.get("primary_country") or claim_row.primary_country
|
||||
claim_row.detected_language = (
|
||||
context.get("detected_language") or claim_row.detected_language
|
||||
)
|
||||
claim_row.entities = context.get("entities") or claim_row.entities
|
||||
|
||||
# Upsert articles + links
|
||||
created_articles = 0
|
||||
linked_articles = 0
|
||||
for item in evidence_items:
|
||||
url = item.get("url")
|
||||
if not url:
|
||||
continue
|
||||
url_hash = _sha256(url)
|
||||
|
||||
existing_article = (
|
||||
await session.execute(
|
||||
select(ArticlesArchive).where(ArticlesArchive.url_hash == url_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
published_at = _parse_datetime(item.get("published_at"))
|
||||
retrieved_at = _parse_datetime(item.get("retrieved_at")) or datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
if existing_article is None:
|
||||
article_row = ArticlesArchive(
|
||||
url=url,
|
||||
url_hash=url_hash,
|
||||
canonical_url=item.get("canonical_url"),
|
||||
title=item.get("title"),
|
||||
full_text=item.get("full_text"),
|
||||
publisher=item.get("publisher"),
|
||||
published_at=published_at,
|
||||
retrieved_at=retrieved_at,
|
||||
extraction_method=(item.get("provenance") or {}).get(
|
||||
"extraction_method"
|
||||
),
|
||||
credibility_score=item.get("credibility_score"),
|
||||
)
|
||||
session.add(article_row)
|
||||
await session.flush()
|
||||
created_articles += 1
|
||||
else:
|
||||
article_row = existing_article
|
||||
if item.get("full_text") and not article_row.full_text:
|
||||
article_row.full_text = item["full_text"]
|
||||
|
||||
# Link claim ↔ article
|
||||
existing_link = (
|
||||
await session.execute(
|
||||
select(ClaimArticle).where(
|
||||
ClaimArticle.claim_id == claim_row.id,
|
||||
ClaimArticle.article_id == article_row.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing_link is None:
|
||||
session.add(
|
||||
ClaimArticle(
|
||||
claim_id=claim_row.id,
|
||||
article_id=article_row.id,
|
||||
relevance_score=item.get("relevance_score"),
|
||||
snippet=item.get("snippet"),
|
||||
)
|
||||
)
|
||||
linked_articles += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Promoted %s: claim_id=%d articles+%d links+%d by=%s",
|
||||
request_id,
|
||||
claim_row.id,
|
||||
created_articles,
|
||||
linked_articles,
|
||||
username,
|
||||
)
|
||||
|
||||
return {
|
||||
"claim_id": claim_row.id,
|
||||
"claim_hash": claim_hash,
|
||||
"articles_created": created_articles,
|
||||
"articles_linked": linked_articles,
|
||||
"promoted_by": username,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_auto_summary(evidence_items: list[dict]) -> str | None:
|
||||
"""Build a basic auto-summary from evidence titles when no LLM summary exists."""
|
||||
if not evidence_items:
|
||||
return None
|
||||
titles = [e.get("title") for e in evidence_items if e.get("title")]
|
||||
if not titles:
|
||||
return None
|
||||
return " · ".join(titles[:3])[:800]
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""Audit log endpoint — chronological mutation history."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.db.models import AuditLog
|
||||
from dashboard.db.session import get_session
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def list_audit(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
username: str | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Paginated audit log entries, newest first."""
|
||||
where_clauses = []
|
||||
if username:
|
||||
where_clauses.append(AuditLog.username == username)
|
||||
if action:
|
||||
where_clauses.append(AuditLog.action == action)
|
||||
|
||||
base = select(AuditLog)
|
||||
if where_clauses:
|
||||
for c in where_clauses:
|
||||
base = base.where(c)
|
||||
|
||||
# Total
|
||||
from sqlalchemy import func
|
||||
|
||||
count_q = select(func.count(AuditLog.id))
|
||||
if where_clauses:
|
||||
for c in where_clauses:
|
||||
count_q = count_q.where(c)
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
base.order_by(desc(AuditLog.timestamp)).limit(limit).offset(offset)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
"timestamp": r.timestamp.isoformat() if r.timestamp else None,
|
||||
"username": r.username,
|
||||
"action": r.action,
|
||||
"target": r.target,
|
||||
"old_value": r.old_value,
|
||||
"new_value": r.new_value,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"""Thin proxy from /api/brain/* → didibrain-api.
|
||||
|
||||
Why a proxy: the React SPA is served from dashboard:51300 and the brain admin
|
||||
endpoints live at brain:8090. Calling brain directly from the browser would
|
||||
require CORS + separate base URL. Easier to forward through dashboard FastAPI
|
||||
— same origin, same auth surface.
|
||||
|
||||
Auth model: GET is read-only admin behind the VPN (no token). Mutations
|
||||
(POST/PATCH/DELETE) require dashboard bearer token via verify_bearer_token,
|
||||
matching /api/config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import DashboardSettings, SettingsCache
|
||||
from dashboard.db.models import User
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("brain_proxy")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Whitelist of admin endpoints exposed by brain through this proxy.
|
||||
# We do NOT expose /v1/analysis_atom/lookup, POST /v1/analysis_atom (the write
|
||||
# endpoint), or PATCH for force-gold via the proxy — those are agent-v3 only,
|
||||
# direct path. The proxy is only for dashboard-driven admin browsing.
|
||||
_ALLOWED_PREFIXES = (
|
||||
"/v1/analysis_atom/list",
|
||||
"/v1/analysis_atom/stats",
|
||||
"/v1/analysis_atom/", # GET / DELETE individual atom
|
||||
"/v1/verification_cache/list",
|
||||
"/v1/verification_cache/", # GET / DELETE individual cache row
|
||||
# Phase D2 — fact status admin browser, audit log, cache ops
|
||||
"/v1/fact_status/list",
|
||||
"/v1/fact_status/", # GET / PATCH individual fact + /:id/versions
|
||||
"/v1/cache/audit_log",
|
||||
"/v1/cache/invalidate",
|
||||
# Phase D2 — temporal canonicalize (admin can preview rewrite)
|
||||
"/v1/canonicalize",
|
||||
"/v1/taxonomy",
|
||||
"/v1/taxonomy/reload",
|
||||
"/health",
|
||||
)
|
||||
|
||||
|
||||
def _is_allowed(path: str) -> bool:
|
||||
return any(path == p or path.startswith(p) for p in _ALLOWED_PREFIXES)
|
||||
|
||||
|
||||
def _settings() -> DashboardSettings:
|
||||
return SettingsCache.get()
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/brain/{full_path:path}",
|
||||
methods=["GET"],
|
||||
tags=["Brain"],
|
||||
)
|
||||
async def brain_proxy_read(full_path: str, request: Request) -> Response:
|
||||
"""Read-only proxy /api/brain/<path> → {brain_url}/<path>."""
|
||||
return await _do_proxy(full_path, request)
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/brain/{full_path:path}",
|
||||
methods=["POST", "PATCH", "DELETE"],
|
||||
tags=["Brain"],
|
||||
)
|
||||
async def brain_proxy_mutate(
|
||||
full_path: str,
|
||||
request: Request,
|
||||
_principal: User | str | None = Depends(verify_bearer_token),
|
||||
) -> Response:
|
||||
"""Mutating proxy — requires bearer token (same as /api/config mutations)."""
|
||||
return await _do_proxy(full_path, request)
|
||||
|
||||
|
||||
async def _do_proxy(full_path: str, request: Request) -> Response:
|
||||
settings = _settings()
|
||||
target_path = "/" + full_path.lstrip("/")
|
||||
|
||||
if not _is_allowed(target_path):
|
||||
raise HTTPException(status_code=404, detail=f"Not a proxied path: {target_path}")
|
||||
|
||||
method = request.method.upper()
|
||||
|
||||
upstream = settings.brain_url.rstrip("/") + target_path
|
||||
body = await request.body()
|
||||
fwd_headers = {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() in ("content-type", "accept", "x-request-id")
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
r = await client.request(
|
||||
method=method,
|
||||
url=upstream,
|
||||
params=dict(request.query_params),
|
||||
content=body if body else None,
|
||||
headers=fwd_headers,
|
||||
)
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning("brain_proxy_timeout path=%s err=%s", target_path, e)
|
||||
raise HTTPException(status_code=504, detail="brain upstream timeout")
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("brain_proxy_error path=%s err=%s", target_path, e)
|
||||
raise HTTPException(status_code=502, detail=f"brain upstream error: {e}")
|
||||
|
||||
pass_headers = {
|
||||
k: v
|
||||
for k, v in r.headers.items()
|
||||
if k.lower() not in ("transfer-encoding", "content-encoding", "connection")
|
||||
}
|
||||
return Response(
|
||||
content=r.content,
|
||||
status_code=r.status_code,
|
||||
headers=pass_headers,
|
||||
media_type=r.headers.get("content-type"),
|
||||
)
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"""DB-backed catalog of AI models / extractors with CRUD (Val 2).
|
||||
|
||||
Satisfies the caiet requirement to administer models/extractors "conform datelor
|
||||
din baza de date". List is read-only; create/update/delete require auth and are
|
||||
audit-logged (same pattern as config mutations).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||
from dashboard.db.models import AuditLog, CatalogEntry, User
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("catalog")
|
||||
# RBAC: protected when auth is configured; mutations additionally resolve the
|
||||
# principal for audit. Open in dev when no auth is configured.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
_KINDS = {"model", "extractor"}
|
||||
|
||||
|
||||
class CatalogIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: str = Field(default="model", description="model | extractor")
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
display_name: str | None = None
|
||||
service: str = Field(..., min_length=1, max_length=32)
|
||||
capabilities: Any | None = None
|
||||
context_length: int | None = Field(default=None, ge=0)
|
||||
supports_cpu: bool = False
|
||||
supports_gpu: bool = False
|
||||
quantization: str | None = None
|
||||
endpoint: str | None = None
|
||||
limits: Any | None = None
|
||||
cost_per_token: float | None = Field(default=None, ge=0)
|
||||
enabled: bool = True
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CatalogPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = None
|
||||
capabilities: Any | None = None
|
||||
context_length: int | None = Field(default=None, ge=0)
|
||||
supports_cpu: bool | None = None
|
||||
supports_gpu: bool | None = None
|
||||
quantization: str | None = None
|
||||
endpoint: str | None = None
|
||||
limits: Any | None = None
|
||||
cost_per_token: float | None = Field(default=None, ge=0)
|
||||
enabled: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def _serialize(e: CatalogEntry) -> dict[str, Any]:
|
||||
return {
|
||||
"id": e.id,
|
||||
"kind": e.kind,
|
||||
"name": e.name,
|
||||
"display_name": e.display_name,
|
||||
"service": e.service,
|
||||
"capabilities": e.capabilities,
|
||||
"context_length": e.context_length,
|
||||
"supports_cpu": e.supports_cpu,
|
||||
"supports_gpu": e.supports_gpu,
|
||||
"quantization": e.quantization,
|
||||
"endpoint": e.endpoint,
|
||||
"limits": e.limits,
|
||||
"cost_per_token": float(e.cost_per_token) if e.cost_per_token is not None else None,
|
||||
"enabled": e.enabled,
|
||||
"notes": e.notes,
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
"updated_by": e.updated_by,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def list_catalog(
|
||||
kind: str | None = None,
|
||||
service: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List catalog entries, optionally filtered by kind/service."""
|
||||
|
||||
stmt = select(CatalogEntry)
|
||||
if kind:
|
||||
stmt = stmt.where(CatalogEntry.kind == kind)
|
||||
if service:
|
||||
stmt = stmt.where(CatalogEntry.service == service)
|
||||
stmt = stmt.order_by(CatalogEntry.service, CatalogEntry.name)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return {"items": [_serialize(r) for r in rows], "total": len(rows)}
|
||||
|
||||
|
||||
@router.post("/catalog")
|
||||
async def create_catalog(
|
||||
body: CatalogIn,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a catalog entry (auth + audit)."""
|
||||
|
||||
if body.kind not in _KINDS:
|
||||
raise HTTPException(status_code=422, detail=f"kind must be one of {_KINDS}")
|
||||
dup = (
|
||||
await session.execute(
|
||||
select(CatalogEntry).where(
|
||||
CatalogEntry.service == body.service, CatalogEntry.name == body.name
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dup is not None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"{body.service}/{body.name} already exists"
|
||||
)
|
||||
|
||||
username = get_username(principal)
|
||||
entry = CatalogEntry(**body.model_dump(), updated_by=username)
|
||||
session.add(entry)
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.create",
|
||||
target=f"{body.service}/{body.name}",
|
||||
old_value={}, new_value=body.model_dump())
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
logger.info("Catalog entry %s/%s created by %s", body.service, body.name, username)
|
||||
return _serialize(entry)
|
||||
|
||||
|
||||
@router.put("/catalog/{entry_id}")
|
||||
async def update_catalog(
|
||||
entry_id: int,
|
||||
body: CatalogPatch,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update mutable fields of a catalog entry (auth + audit)."""
|
||||
|
||||
entry = await session.get(CatalogEntry, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=422, detail="no fields to update")
|
||||
old = _serialize(entry)
|
||||
for field, value in changes.items():
|
||||
setattr(entry, field, value)
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
username = get_username(principal)
|
||||
entry.updated_by = username
|
||||
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.update",
|
||||
target=f"{entry.service}/{entry.name}", old_value=old, new_value=changes)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return _serialize(entry)
|
||||
|
||||
|
||||
@router.delete("/catalog/{entry_id}")
|
||||
async def delete_catalog(
|
||||
entry_id: int,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete a catalog entry (auth + audit)."""
|
||||
|
||||
entry = await session.get(CatalogEntry, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||
target = f"{entry.service}/{entry.name}"
|
||||
old = _serialize(entry)
|
||||
username = get_username(principal)
|
||||
await session.delete(entry)
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.delete", target=target,
|
||||
old_value=old, new_value={})
|
||||
)
|
||||
await session.commit()
|
||||
return {"deleted": entry_id, "target": target}
|
||||
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
|||
"""Health endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from sqlalchemy import text
|
||||
|
||||
from dashboard.db.session import get_session_factory
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception as e:
|
||||
return {"status": "degraded", "db": False, "error": str(e)}
|
||||
return {"status": "healthy", "db": db_ok}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready(request: Request) -> dict:
|
||||
return {
|
||||
"ready": hasattr(request.app.state, "settings")
|
||||
and hasattr(request.app.state, "registry")
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""Request history endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def list_history(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
module: str | None = Query(default=None),
|
||||
tier: str | None = Query(default=None),
|
||||
provider: str | None = Query(default=None),
|
||||
endpoint: str | None = Query(default=None),
|
||||
hours: int = Query(default=168, ge=1, le=720), # default 7 days
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""List recent requests with filters."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||
if module:
|
||||
q = q.where(RequestHistory.module == module)
|
||||
if tier:
|
||||
q = q.where(RequestHistory.tier == tier)
|
||||
if provider:
|
||||
q = q.where(RequestHistory.provider == provider)
|
||||
if endpoint:
|
||||
q = q.where(RequestHistory.endpoint == endpoint)
|
||||
q = q.order_by(desc(RequestHistory.created_at)).limit(limit).offset(offset)
|
||||
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
items = [_row_to_dict(r) for r in rows]
|
||||
return {"items": items, "count": len(items), "offset": offset}
|
||||
|
||||
|
||||
@router.get("/history/{key}")
|
||||
async def get_history(
|
||||
key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Get one request by either numeric id (UI uses this) or request_id string."""
|
||||
# Numeric path → row primary key. String path → request_id (for service-to-service
|
||||
# tooling that already knows the UUID it sent on POST /api/ingest/event).
|
||||
try:
|
||||
numeric_id = int(key)
|
||||
q = select(RequestHistory).where(RequestHistory.id == numeric_id)
|
||||
except ValueError:
|
||||
q = select(RequestHistory).where(RequestHistory.request_id == key)
|
||||
row = (await session.execute(q)).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return _row_to_dict(row, full=True)
|
||||
|
||||
|
||||
def _row_to_dict(row: RequestHistory, full: bool = False) -> dict:
|
||||
out = {
|
||||
"id": row.id,
|
||||
"request_id": row.request_id,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"module": row.module,
|
||||
"tier": row.tier,
|
||||
"endpoint": row.endpoint,
|
||||
"provider": row.provider,
|
||||
"claim": row.claim,
|
||||
"query": row.query,
|
||||
"duration_ms": row.duration_ms,
|
||||
"status_code": row.status_code,
|
||||
"results_count": row.results_count,
|
||||
"evidence_count": row.evidence_count,
|
||||
"error": row.error,
|
||||
"cost_usd": float(row.cost_usd) if row.cost_usd is not None else None,
|
||||
"user_id": row.user_id,
|
||||
}
|
||||
if full:
|
||||
out["stages"] = row.stages
|
||||
out["raw_request"] = row.raw_request
|
||||
out["raw_response"] = row.raw_response
|
||||
return out
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
"""Event ingest endpoint — receives request events from web-api."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.pricing import estimate_cost
|
||||
|
||||
logger = get_logger("ingest")
|
||||
|
||||
# Service-to-service ingest — no auth (VPN-internal, web-api → dashboard)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IngestEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
request_id: str = Field(max_length=64)
|
||||
# Source service. Senders that don't pass it default to 'web' for back-compat
|
||||
# with the original DashboardEventSink in the web-api module.
|
||||
module: str = Field(default="web", max_length=16)
|
||||
tier: str = Field(default="free", max_length=16)
|
||||
endpoint: str = Field(max_length=64)
|
||||
provider: str | None = Field(default=None, max_length=32)
|
||||
claim: str | None = None
|
||||
query: str | None = None
|
||||
duration_ms: int | None = None
|
||||
status_code: int | None = None
|
||||
results_count: int | None = None
|
||||
evidence_count: int | None = None
|
||||
error: str | None = None
|
||||
cost_usd: float | None = None
|
||||
user_id: str | None = Field(default=None, max_length=128)
|
||||
stages: list[dict[str, Any]] | None = None
|
||||
raw_request: dict[str, Any] | None = None
|
||||
raw_response: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/ingest/event")
|
||||
async def ingest_event(
|
||||
event: IngestEvent,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Receive a single request event from web-api middleware."""
|
||||
# Trim bulky fields
|
||||
claim = event.claim[:2000] if event.claim else None
|
||||
query = event.query[:2000] if event.query else None
|
||||
error = event.error[:2000] if event.error else None
|
||||
|
||||
# Compute cost if the sender didn't provide it
|
||||
cost = event.cost_usd
|
||||
if cost is None:
|
||||
cost = estimate_cost(event.endpoint, event.tier, event.provider)
|
||||
|
||||
row = RequestHistory(
|
||||
request_id=event.request_id,
|
||||
module=event.module,
|
||||
tier=event.tier,
|
||||
endpoint=event.endpoint,
|
||||
provider=event.provider,
|
||||
claim=claim,
|
||||
query=query,
|
||||
duration_ms=event.duration_ms,
|
||||
status_code=event.status_code,
|
||||
results_count=event.results_count,
|
||||
evidence_count=event.evidence_count,
|
||||
error=error,
|
||||
cost_usd=cost,
|
||||
user_id=event.user_id,
|
||||
stages=event.stages,
|
||||
raw_request=event.raw_request,
|
||||
raw_response=event.raw_response,
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning("Ingest failed for %s: %s", event.request_id, e)
|
||||
return {"stored": False, "error": str(e)}
|
||||
return {"stored": True, "id": row.id}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"""Central AI monitoring panel (Val 1).
|
||||
|
||||
Aggregates, in one place, data that already exists across the platform but was
|
||||
not surfaced in the UI:
|
||||
- health of all AI services (concurrent probes)
|
||||
- RabbitMQ queue depths (management API)
|
||||
- latency percentiles p50/p90/p99 (Prometheus)
|
||||
|
||||
Every source is fail-open: a missing/unreachable source yields a disabled or
|
||||
empty section, never a 500 — the panel must never take the dashboard down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("monitoring")
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
def _service_endpoints() -> dict[str, str]:
|
||||
"""All AI services to monitor → health/info URL (env-overridable).
|
||||
|
||||
Prefers ``/v1/info`` where the service exposes it (richer payload), else
|
||||
``/health``. Override any entry with ``DASHBOARD_<ID>_HEALTH_URL``.
|
||||
"""
|
||||
|
||||
s = SettingsCache.get()
|
||||
|
||||
def envurl(key: str, default: str) -> str:
|
||||
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||
|
||||
return {
|
||||
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/health"),
|
||||
"rerank": envurl("rerank", "http://10.11.10.17:14200/health"),
|
||||
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||
"extractors": envurl("extractors", "http://10.11.10.17:54400/health"),
|
||||
"forensic": envurl("forensic", "http://10.11.10.17:54700/health"),
|
||||
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||
}
|
||||
|
||||
|
||||
async def _probe(client: httpx.AsyncClient, module_id: str, url: str) -> dict[str, Any]:
|
||||
"""Probe one service health endpoint; normalized, never raises."""
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
r = await client.get(url)
|
||||
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||
if r.status_code >= 500:
|
||||
status = "down"
|
||||
elif r.status_code >= 400:
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "healthy"
|
||||
info: Any = None
|
||||
if status == "healthy":
|
||||
try:
|
||||
info = r.json()
|
||||
except Exception: # noqa: BLE001 - body is best-effort
|
||||
info = None
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": status,
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"info": info,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": "timeout (>3s)",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": str(e)[:200],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/monitoring/services")
|
||||
async def monitoring_services() -> dict[str, Any]:
|
||||
"""Concurrent health of every AI service, plus a roll-up summary."""
|
||||
|
||||
endpoints = _service_endpoints()
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
results = await asyncio.gather(
|
||||
*(_probe(client, mid, url) for mid, url in endpoints.items())
|
||||
)
|
||||
summary = {"healthy": 0, "degraded": 0, "down": 0, "total": len(results)}
|
||||
for r in results:
|
||||
summary[r["status"]] = summary.get(r["status"], 0) + 1
|
||||
return {"services": list(results), "summary": summary}
|
||||
|
||||
|
||||
@router.get("/monitoring/queues")
|
||||
async def monitoring_queues() -> dict[str, Any]:
|
||||
"""RabbitMQ queue depths via the management API (fail-open if unset)."""
|
||||
|
||||
s = SettingsCache.get()
|
||||
if not s.rabbitmq_mgmt_url:
|
||||
return {"enabled": False, "queues": [], "reason": "rabbitmq_mgmt_url not set"}
|
||||
|
||||
url = f"{s.rabbitmq_mgmt_url.rstrip('/')}/api/queues"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=4.0) as client:
|
||||
r = await client.get(
|
||||
url, auth=(s.rabbitmq_mgmt_user, s.rabbitmq_mgmt_password)
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return {"enabled": True, "queues": [], "error": f"HTTP {r.status_code}"}
|
||||
queues = [
|
||||
{
|
||||
"name": q.get("name"),
|
||||
"vhost": q.get("vhost"),
|
||||
"messages": q.get("messages", 0),
|
||||
"ready": q.get("messages_ready", 0),
|
||||
"unacked": q.get("messages_unacknowledged", 0),
|
||||
"consumers": q.get("consumers", 0),
|
||||
"state": q.get("state"),
|
||||
}
|
||||
for q in (r.json() if isinstance(r.json(), list) else [])
|
||||
]
|
||||
queues.sort(key=lambda q: q["messages"], reverse=True)
|
||||
return {"enabled": True, "queues": queues}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("rabbitmq_probe_failed: %s", e)
|
||||
return {"enabled": True, "queues": [], "error": str(e)[:200]}
|
||||
|
||||
|
||||
async def _prom_quantile(
|
||||
client: httpx.AsyncClient, base: str, q: float
|
||||
) -> dict[str, float]:
|
||||
"""Query one latency quantile per Prometheus job."""
|
||||
|
||||
expr = (
|
||||
f"histogram_quantile({q}, sum(rate("
|
||||
f"http_request_duration_seconds_bucket[5m])) by (le, job))"
|
||||
)
|
||||
r = await client.get(f"{base}/api/v1/query", params={"query": expr})
|
||||
out: dict[str, float] = {}
|
||||
if r.status_code != 200:
|
||||
return out
|
||||
for series in r.json().get("data", {}).get("result", []):
|
||||
job = series.get("metric", {}).get("job")
|
||||
val = series.get("value", [None, None])[1]
|
||||
if job and val is not None:
|
||||
try:
|
||||
out[job] = round(float(val) * 1000, 1) # seconds → ms
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/monitoring/latency")
|
||||
async def monitoring_latency() -> dict[str, Any]:
|
||||
"""p50/p90/p99 request latency per service from Prometheus (fail-open)."""
|
||||
|
||||
s = SettingsCache.get()
|
||||
if not s.prometheus_url:
|
||||
return {"enabled": False, "services": [], "reason": "prometheus_url not set"}
|
||||
|
||||
base = s.prometheus_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
p50, p90, p99 = await asyncio.gather(
|
||||
_prom_quantile(client, base, 0.5),
|
||||
_prom_quantile(client, base, 0.9),
|
||||
_prom_quantile(client, base, 0.99),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("prometheus_query_failed: %s", e)
|
||||
return {"enabled": True, "services": [], "error": str(e)[:200]}
|
||||
|
||||
jobs = sorted(set(p50) | set(p90) | set(p99))
|
||||
services = [
|
||||
{
|
||||
"job": job,
|
||||
"p50_ms": p50.get(job),
|
||||
"p90_ms": p90.get(job),
|
||||
"p99_ms": p99.get(job),
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
return {"enabled": True, "services": services}
|
||||
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"""HTML pages served by Jinja2."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_registry
|
||||
from dashboard.api.routes.config import KNOWN_KEYS, _coerce_and_validate
|
||||
from dashboard.db.models import (
|
||||
ArticlesArchive,
|
||||
AuditLog,
|
||||
ClaimArticle,
|
||||
ClaimsArchive,
|
||||
ConfigOverride,
|
||||
RequestHistory,
|
||||
)
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def overview(
|
||||
request: Request,
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
provider_stats = await registry.get_all()
|
||||
summary = await _get_summary(session, hours=24)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="overview.html",
|
||||
context={
|
||||
"providers": [s.to_dict() for s in provider_stats],
|
||||
"summary": summary,
|
||||
"active_page": "overview",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_class=HTMLResponse)
|
||||
async def history_page(
|
||||
request: Request,
|
||||
tier: str | None = Query(default=None),
|
||||
provider: str | None = Query(default=None),
|
||||
endpoint: str | None = Query(default=None),
|
||||
hours: int = Query(default=168, ge=1, le=720),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||
if tier:
|
||||
q = q.where(RequestHistory.tier == tier)
|
||||
if provider:
|
||||
q = q.where(RequestHistory.provider == provider)
|
||||
if endpoint:
|
||||
q = q.where(RequestHistory.endpoint == endpoint)
|
||||
q = q.order_by(desc(RequestHistory.created_at)).limit(limit)
|
||||
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="history.html",
|
||||
context={
|
||||
"items": rows,
|
||||
"filters": {
|
||||
"tier": tier or "",
|
||||
"provider": provider or "",
|
||||
"endpoint": endpoint or "",
|
||||
"hours": hours,
|
||||
},
|
||||
"active_page": "history",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{request_id}", response_class=HTMLResponse)
|
||||
async def history_detail(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
q = select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||
row = (await session.execute(q)).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="history_detail.html",
|
||||
context={
|
||||
"item": row,
|
||||
"active_page": "history",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers", response_class=HTMLResponse)
|
||||
async def providers_page(
|
||||
request: Request,
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> HTMLResponse:
|
||||
stats = await registry.get_all(force=True)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="providers.html",
|
||||
context={
|
||||
"providers": [s.to_dict() for s in stats],
|
||||
"active_page": "providers",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cost", response_class=HTMLResponse)
|
||||
async def cost_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> HTMLResponse:
|
||||
"""Cost dashboard — spend breakdowns + projections."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since_24h = now - timedelta(hours=24)
|
||||
since_7d = now - timedelta(days=7)
|
||||
since_30d = now - timedelta(days=30)
|
||||
|
||||
async def _sum_cost(since: datetime) -> float:
|
||||
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
return float((await session.execute(q)).scalar() or 0)
|
||||
|
||||
cost_24h = await _sum_cost(since_24h)
|
||||
cost_7d = await _sum_cost(since_7d)
|
||||
cost_30d = await _sum_cost(since_30d)
|
||||
|
||||
# Projected monthly based on last 24h burn rate
|
||||
projected_monthly = round(cost_24h * 30, 2)
|
||||
|
||||
# Cost by provider (30d)
|
||||
provider_q = (
|
||||
select(
|
||||
RequestHistory.provider,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
.order_by(desc("cost"))
|
||||
)
|
||||
by_provider = [
|
||||
{"provider": row.provider, "count": row.count, "cost": float(row.cost or 0)}
|
||||
for row in (await session.execute(provider_q)).all()
|
||||
]
|
||||
|
||||
# Cost by tier (30d)
|
||||
tier_q = (
|
||||
select(
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since_30d)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
by_tier = [
|
||||
{"tier": row.tier, "count": row.count, "cost": float(row.cost or 0)}
|
||||
for row in (await session.execute(tier_q)).all()
|
||||
]
|
||||
|
||||
# Top 10 most expensive requests
|
||||
top_q = (
|
||||
select(RequestHistory)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.cost_usd.is_not(None),
|
||||
)
|
||||
.order_by(desc(RequestHistory.cost_usd))
|
||||
.limit(10)
|
||||
)
|
||||
top = (await session.execute(top_q)).scalars().all()
|
||||
|
||||
# Provider quota vs budget
|
||||
provider_stats = await registry.get_all()
|
||||
budget_bars = []
|
||||
for p in provider_stats:
|
||||
if p.quota_limit:
|
||||
budget_bars.append(
|
||||
{
|
||||
"name": p.display_name,
|
||||
"kind": p.kind,
|
||||
"used": p.quota_used or 0,
|
||||
"limit": p.quota_limit,
|
||||
"percent": p.quota_percent_used or 0,
|
||||
"unit": p.quota_unit,
|
||||
"plan_price": p.plan_price_monthly,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="cost.html",
|
||||
context={
|
||||
"cost_24h": round(cost_24h, 4),
|
||||
"cost_7d": round(cost_7d, 4),
|
||||
"cost_30d": round(cost_30d, 4),
|
||||
"projected_monthly": projected_monthly,
|
||||
"by_provider": by_provider,
|
||||
"by_tier": by_tier,
|
||||
"top": top,
|
||||
"budgets": budget_bars,
|
||||
"active_page": "cost",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/archive", response_class=HTMLResponse)
|
||||
async def archive_page(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Browse archived claims."""
|
||||
stmt = select(ClaimsArchive)
|
||||
if q:
|
||||
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit)
|
||||
|
||||
claims = (await session.execute(stmt)).scalars().all()
|
||||
total = (
|
||||
await session.execute(select(func.count(ClaimsArchive.id)))
|
||||
).scalar() or 0
|
||||
articles_total = (
|
||||
await session.execute(select(func.count(ArticlesArchive.id)))
|
||||
).scalar() or 0
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="archive.html",
|
||||
context={
|
||||
"claims": claims,
|
||||
"query": q or "",
|
||||
"total_claims": total,
|
||||
"total_articles": articles_total,
|
||||
"active_page": "archive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/archive/{claim_id}", response_class=HTMLResponse)
|
||||
async def archive_detail(
|
||||
request: Request,
|
||||
claim_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Detail of a single archived claim with its articles."""
|
||||
claim = (
|
||||
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||
).scalar_one_or_none()
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
links_stmt = (
|
||||
select(ClaimArticle, ArticlesArchive)
|
||||
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||
.where(ClaimArticle.claim_id == claim_id)
|
||||
)
|
||||
links = (await session.execute(links_stmt)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="archive_detail.html",
|
||||
context={
|
||||
"claim": claim,
|
||||
"articles": [
|
||||
{"article": article, "link": link} for link, article in links
|
||||
],
|
||||
"active_page": "archive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_page(
|
||||
request: Request,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Audit log page — shows who changed what, when."""
|
||||
q = select(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="audit.html",
|
||||
context={"entries": rows, "active_page": "audit"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Runtime configuration page — toggle providers, strategy, models."""
|
||||
overrides_rows = (await session.execute(select(ConfigOverride))).scalars().all()
|
||||
overrides = {r.key: r for r in overrides_rows}
|
||||
|
||||
items = []
|
||||
categories: dict[str, list[dict]] = {
|
||||
"providers": [],
|
||||
"routing": [],
|
||||
"llm": [],
|
||||
"tiers": [],
|
||||
}
|
||||
for key, meta in KNOWN_KEYS.items():
|
||||
row = overrides.get(key)
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": row.value if row else meta["default"],
|
||||
"is_override": row is not None,
|
||||
"updated_at": row.updated_at if row else None,
|
||||
"updated_by": row.updated_by if row else None,
|
||||
}
|
||||
items.append(entry)
|
||||
categories.setdefault(meta.get("category", "other"), []).append(entry)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="config.html",
|
||||
context={
|
||||
"categories": categories,
|
||||
"active_page": "config",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/{key}", response_class=HTMLResponse)
|
||||
async def config_update(
|
||||
request: Request,
|
||||
key: str,
|
||||
value: str = Form(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""HTMX form handler — update a config key and return the row fragment."""
|
||||
if key not in KNOWN_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Unknown key")
|
||||
|
||||
meta = KNOWN_KEYS[key]
|
||||
coerced = _coerce_and_validate(key, value, meta)
|
||||
|
||||
existing = (
|
||||
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||
).scalar_one_or_none()
|
||||
|
||||
old_value = existing.value if existing else meta["default"]
|
||||
username = request.headers.get("X-User", "dashboard-ui")
|
||||
|
||||
if existing is None:
|
||||
session.add(
|
||||
ConfigOverride(
|
||||
key=key,
|
||||
value=coerced,
|
||||
updated_by=username,
|
||||
description=meta.get("description"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.value = coerced
|
||||
existing.updated_by = username
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
session.add(
|
||||
AuditLog(
|
||||
username=username,
|
||||
action="config.set",
|
||||
target=key,
|
||||
old_value=old_value,
|
||||
new_value=coerced,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": coerced,
|
||||
"is_override": True,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
"updated_by": username,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/config_row.html",
|
||||
context={"entry": entry},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/{key}/reset", response_class=HTMLResponse)
|
||||
async def config_reset(
|
||||
request: Request,
|
||||
key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""HTMX — revert a config key to its default."""
|
||||
if key not in KNOWN_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Unknown key")
|
||||
|
||||
existing = (
|
||||
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||
).scalar_one_or_none()
|
||||
old_value = existing.value if existing else None
|
||||
|
||||
if existing is not None:
|
||||
await session.execute(
|
||||
delete(ConfigOverride).where(ConfigOverride.key == key)
|
||||
)
|
||||
session.add(
|
||||
AuditLog(
|
||||
username=request.headers.get("X-User", "dashboard-ui"),
|
||||
action="config.reset",
|
||||
target=key,
|
||||
old_value=old_value,
|
||||
new_value=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
meta = KNOWN_KEYS[key]
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": meta["default"],
|
||||
"is_override": False,
|
||||
"updated_at": None,
|
||||
"updated_by": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/config_row.html",
|
||||
context={"entry": entry},
|
||||
)
|
||||
|
||||
|
||||
async def _get_summary(session: AsyncSession, hours: int = 24) -> dict:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
total_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total = (await session.execute(total_q)).scalar() or 0
|
||||
|
||||
tier_q = (
|
||||
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
tiers = dict((await session.execute(tier_q)).all())
|
||||
|
||||
err_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.status_code >= 400,
|
||||
)
|
||||
errors = (await session.execute(err_q)).scalar() or 0
|
||||
|
||||
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
avg_duration = float((await session.execute(avg_q)).scalar() or 0)
|
||||
|
||||
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"errors": errors,
|
||||
"error_rate": round((errors / total * 100), 1) if total else 0.0,
|
||||
"avg_duration_ms": round(avg_duration, 0),
|
||||
"total_cost": round(total_cost, 4),
|
||||
"by_tier": {k or "unknown": v for k, v in tiers.items()},
|
||||
"hours": hours,
|
||||
}
|
||||
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Module health proxy — pings each module's health endpoint, returns normalized status."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("proxy")
|
||||
# RBAC: protected when auth is configured; open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
# Module → health endpoint URL.
|
||||
# Reads optional env overrides first; falls back to settings or known defaults.
|
||||
# Set DASHBOARD_<module>_HEALTH_URL to override (e.g. DASHBOARD_EMBEDDINGS_HEALTH_URL=http://10.11.10.17:14100).
|
||||
def _module_endpoints() -> dict[str, str]:
|
||||
s = SettingsCache.get()
|
||||
|
||||
def envurl(key: str, default: str) -> str:
|
||||
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||
|
||||
return {
|
||||
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/v1/info"),
|
||||
"rerank": envurl("rerank", "http://10.11.10.17:14200/v1/info"),
|
||||
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proxy/{module_id}/health")
|
||||
async def module_health(module_id: str) -> dict[str, Any]:
|
||||
"""Ping a module's health endpoint. Normalized response: status, latency, error."""
|
||||
endpoints = _module_endpoints()
|
||||
if module_id not in endpoints:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown module: {module_id}")
|
||||
|
||||
url = endpoints[module_id]
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
r = await client.get(url)
|
||||
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||
if r.status_code >= 500:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"error": r.text[:200] if r.text else None,
|
||||
}
|
||||
if r.status_code >= 400:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "degraded",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"error": r.text[:200] if r.text else None,
|
||||
}
|
||||
body: Any = None
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception: # noqa: BLE001 — broad on purpose, body is best-effort
|
||||
body = None
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "healthy",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"info": body,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": "timeout (>3s)",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": str(e)[:200],
|
||||
}
|
||||
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""Live stats endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_registry, verify_bearer_token
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/stats/providers")
|
||||
async def providers_stats(
|
||||
force: bool = Query(default=False),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> dict:
|
||||
"""Return live stats for all providers (cached)."""
|
||||
stats = await registry.get_all(force=force)
|
||||
return {
|
||||
"providers": [s.to_dict() for s in stats],
|
||||
"last_refresh": registry.last_refresh_iso,
|
||||
"age_seconds": registry.age_seconds,
|
||||
"cache_ttl_seconds": registry.settings.provider_stats_cache_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def summary_stats(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Aggregated counters over the last N hours."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
# Total requests
|
||||
total_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total = (await session.execute(total_q)).scalar() or 0
|
||||
|
||||
# By tier
|
||||
tier_q = (
|
||||
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
tiers = dict((await session.execute(tier_q)).all())
|
||||
|
||||
# By provider
|
||||
provider_q = (
|
||||
select(RequestHistory.provider, func.count(RequestHistory.id))
|
||||
.where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
)
|
||||
providers = dict((await session.execute(provider_q)).all())
|
||||
|
||||
# By endpoint
|
||||
endpoint_q = (
|
||||
select(RequestHistory.endpoint, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.endpoint)
|
||||
)
|
||||
endpoints = dict((await session.execute(endpoint_q)).all())
|
||||
|
||||
# Avg duration
|
||||
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
avg_duration = (await session.execute(avg_q)).scalar()
|
||||
|
||||
# Error rate
|
||||
err_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.status_code >= 400,
|
||||
)
|
||||
errors = (await session.execute(err_q)).scalar() or 0
|
||||
|
||||
# Total cost
|
||||
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"total_requests": total,
|
||||
"total_errors": errors,
|
||||
"error_rate": round(errors / total, 4) if total else 0,
|
||||
"avg_duration_ms": round(float(avg_duration or 0), 2),
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"by_tier": tiers,
|
||||
"by_provider": providers,
|
||||
"by_endpoint": endpoints,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/cost")
|
||||
async def cost_stats(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> dict:
|
||||
"""Cost rollups (24h / 7d / 30d) + breakdowns + budget bars."""
|
||||
from sqlalchemy import desc
|
||||
|
||||
from dashboard.db.models import RequestHistory
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
since_24h = now - timedelta(hours=24)
|
||||
since_7d = now - timedelta(days=7)
|
||||
since_30d = now - timedelta(days=30)
|
||||
|
||||
async def _sum(since: datetime) -> float:
|
||||
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
return float((await session.execute(q)).scalar() or 0)
|
||||
|
||||
cost_24h = await _sum(since_24h)
|
||||
cost_7d = await _sum(since_7d)
|
||||
cost_30d = await _sum(since_30d)
|
||||
|
||||
# By provider (30d) — only paid providers (cost > 0). Brain/agent_v3 emit
|
||||
# rows with cost=0 (no upstream provider), they would otherwise clutter the
|
||||
# chart with empty bars labelled "v3", "get", etc.
|
||||
prov_q = (
|
||||
select(
|
||||
RequestHistory.provider,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
.having(func.coalesce(func.sum(RequestHistory.cost_usd), 0) > 0)
|
||||
.order_by(desc("cost"))
|
||||
)
|
||||
by_provider = [
|
||||
{"provider": r.provider, "count": r.count, "cost": float(r.cost or 0)}
|
||||
for r in (await session.execute(prov_q)).all()
|
||||
]
|
||||
|
||||
# By tier (30d)
|
||||
tier_q = (
|
||||
select(
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since_30d)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
by_tier = [
|
||||
{"tier": r.tier, "count": r.count, "cost": float(r.cost or 0)}
|
||||
for r in (await session.execute(tier_q)).all()
|
||||
]
|
||||
|
||||
# Top expensive (30d) — exclude $0 rows. "Most expensive" of free/zero-cost
|
||||
# rows is meaningless and crowds the table with brain/agent_v3 entries.
|
||||
top_q = (
|
||||
select(
|
||||
RequestHistory.id,
|
||||
RequestHistory.created_at,
|
||||
RequestHistory.tier,
|
||||
RequestHistory.provider,
|
||||
RequestHistory.endpoint,
|
||||
RequestHistory.cost_usd,
|
||||
RequestHistory.duration_ms,
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.cost_usd.is_not(None),
|
||||
RequestHistory.cost_usd > 0,
|
||||
)
|
||||
.order_by(desc(RequestHistory.cost_usd))
|
||||
.limit(10)
|
||||
)
|
||||
top = [
|
||||
{
|
||||
"id": r.id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"tier": r.tier,
|
||||
"provider": r.provider,
|
||||
"endpoint": r.endpoint,
|
||||
"cost": float(r.cost_usd or 0),
|
||||
"duration_ms": r.duration_ms,
|
||||
}
|
||||
for r in (await session.execute(top_q)).all()
|
||||
]
|
||||
|
||||
# Budget bars from live provider stats
|
||||
provider_stats = await registry.get_all()
|
||||
budgets = []
|
||||
for p in provider_stats:
|
||||
if p.quota_limit:
|
||||
budgets.append(
|
||||
{
|
||||
"name": p.display_name,
|
||||
"kind": p.kind,
|
||||
"used": p.quota_used or 0,
|
||||
"limit": p.quota_limit,
|
||||
"percent": p.quota_percent_used or 0,
|
||||
"unit": p.quota_unit,
|
||||
"plan_price": p.plan_price_monthly,
|
||||
}
|
||||
)
|
||||
|
||||
# Smart projection: weighted blend of 7d and 30d daily averages.
|
||||
# If we have 30d of data, prefer that average × 30. Else 7d × 30/7. Fallback to 24h × 30.
|
||||
daily_30d = cost_30d / 30 if cost_30d > 0 else 0
|
||||
daily_7d = cost_7d / 7 if cost_7d > 0 else 0
|
||||
daily_24h = cost_24h
|
||||
if daily_30d > 0 and daily_7d > 0:
|
||||
# Weighted: 30d gives stability, 7d catches recent shifts
|
||||
daily_blend = (daily_30d * 0.4) + (daily_7d * 0.6)
|
||||
confidence = "stable"
|
||||
basis = "blend(30d=40%, 7d=60%)"
|
||||
elif daily_7d > 0:
|
||||
daily_blend = daily_7d
|
||||
confidence = "moderate"
|
||||
basis = "7d_avg"
|
||||
else:
|
||||
daily_blend = daily_24h
|
||||
confidence = "rough"
|
||||
basis = "24h_only"
|
||||
projected_monthly = round(daily_blend * 30, 2)
|
||||
|
||||
# Trend: last 7d vs previous 7d → "increasing", "decreasing", "stable"
|
||||
cost_7d_prev = await _sum(now - timedelta(days=14)) - cost_7d
|
||||
if cost_7d > 0 and cost_7d_prev > 0:
|
||||
trend_pct = ((cost_7d - cost_7d_prev) / cost_7d_prev) * 100
|
||||
if trend_pct > 10:
|
||||
trend = "increasing"
|
||||
elif trend_pct < -10:
|
||||
trend = "decreasing"
|
||||
else:
|
||||
trend = "stable"
|
||||
trend_pct = round(trend_pct, 1)
|
||||
else:
|
||||
trend = "unknown"
|
||||
trend_pct = None
|
||||
|
||||
return {
|
||||
"cost_24h": round(cost_24h, 4),
|
||||
"cost_7d": round(cost_7d, 4),
|
||||
"cost_30d": round(cost_30d, 4),
|
||||
"projected_monthly": projected_monthly,
|
||||
"projection_basis": basis,
|
||||
"projection_confidence": confidence,
|
||||
"trend": trend,
|
||||
"trend_pct": trend_pct,
|
||||
"by_provider": by_provider,
|
||||
"by_tier": by_tier,
|
||||
"top": top,
|
||||
"budgets": budgets,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/timeline")
|
||||
async def timeline_stats(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Hourly request counts for charts."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
q = (
|
||||
select(
|
||||
func.date_trunc("hour", RequestHistory.created_at).label("hour"),
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.avg(RequestHistory.duration_ms).label("avg_ms"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by("hour", RequestHistory.tier)
|
||||
.order_by("hour")
|
||||
)
|
||||
rows = (await session.execute(q)).all()
|
||||
|
||||
buckets: list[dict] = []
|
||||
for row in rows:
|
||||
buckets.append(
|
||||
{
|
||||
"hour": row.hour.isoformat() if row.hour else None,
|
||||
"tier": row.tier,
|
||||
"count": row.count,
|
||||
"avg_ms": round(float(row.avg_ms or 0), 2),
|
||||
}
|
||||
)
|
||||
return {"timeline": buckets, "window_hours": hours}
|
||||
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""User authentication — Bearer tokens mapped to named users in the DB.
|
||||
|
||||
Dashboard runs in an internal VPN so this is intentionally simple:
|
||||
a shared table of (username, token_hash) rows with a role column for
|
||||
future ACL expansion. Tokens are compared via constant-time hashing.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.db.models import User
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("auth")
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a raw token with SHA-256 for storage."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""Generate a fresh URL-safe token (32 bytes of entropy)."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def find_user_by_token(session: AsyncSession, token: str) -> User | None:
|
||||
"""Look up a user by raw token (hashes + constant-time compare).
|
||||
|
||||
Returns None when the token is unknown or the user is disabled.
|
||||
"""
|
||||
token_hash = hash_token(token)
|
||||
rows = (await session.execute(select(User))).scalars().all()
|
||||
for user in rows:
|
||||
if hmac.compare_digest(user.token_hash, token_hash):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
session: AsyncSession,
|
||||
username: str,
|
||||
token: str,
|
||||
email: str | None = None,
|
||||
role: str = "admin",
|
||||
) -> User:
|
||||
"""Create or update a user with the given token."""
|
||||
existing = (
|
||||
await session.execute(select(User).where(User.username == username))
|
||||
).scalar_one_or_none()
|
||||
|
||||
token_hash = hash_token(token)
|
||||
|
||||
if existing is None:
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
token_hash=token_hash,
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
logger.info("Created user %s (role=%s)", username, role)
|
||||
else:
|
||||
existing.token_hash = token_hash
|
||||
if email is not None:
|
||||
existing.email = email
|
||||
existing.role = role
|
||||
user = existing
|
||||
logger.info("Updated user %s (role=%s)", username, role)
|
||||
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def record_login(session: AsyncSession, user: User) -> None:
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""CLI utilities for admin tasks (create user, list users, etc.).
|
||||
|
||||
Usage (inside the container):
|
||||
python -m dashboard.cli create-user didi
|
||||
python -m dashboard.cli list-users
|
||||
python -m dashboard.cli delete-user didi
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from dashboard.auth import generate_token, upsert_user
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.db.models import User
|
||||
from dashboard.db.session import close_engine, get_session_factory, init_engine
|
||||
|
||||
|
||||
async def _create_user(username: str, email: str | None, role: str) -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
token = generate_token()
|
||||
user = await upsert_user(session, username, token, email=email, role=role)
|
||||
print("User created:")
|
||||
print(f" id: {user.id}")
|
||||
print(f" username: {user.username}")
|
||||
print(f" role: {user.role}")
|
||||
print(f" email: {user.email or '—'}")
|
||||
print()
|
||||
print("Bearer token (SAVE THIS — it is not shown again):")
|
||||
print(f" {token}")
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _list_users() -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
rows = (await session.execute(select(User).order_by(User.id))).scalars().all()
|
||||
if not rows:
|
||||
print("No users yet.")
|
||||
await close_engine()
|
||||
return
|
||||
print(f"{'ID':<4} {'USERNAME':<20} {'ROLE':<10} {'EMAIL':<30} LAST LOGIN")
|
||||
print("-" * 100)
|
||||
for u in rows:
|
||||
last = u.last_login.strftime("%Y-%m-%d %H:%M") if u.last_login else "never"
|
||||
print(
|
||||
f"{u.id:<4} {u.username:<20} {u.role:<10} {(u.email or '—'):<30} {last}"
|
||||
)
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _delete_user(username: str) -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
delete(User).where(User.username == username)
|
||||
)
|
||||
await session.commit()
|
||||
if result.rowcount:
|
||||
print(f"Deleted user {username}")
|
||||
else:
|
||||
print(f"User {username} not found")
|
||||
await close_engine()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="dashboard.cli", description="Dashboard admin")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_create = sub.add_parser("create-user", help="Create or update a user")
|
||||
p_create.add_argument("username")
|
||||
p_create.add_argument("--email", default=None)
|
||||
p_create.add_argument("--role", default="admin", choices=["admin", "viewer"])
|
||||
|
||||
sub.add_parser("list-users", help="List all users")
|
||||
|
||||
p_delete = sub.add_parser("delete-user", help="Delete a user by username")
|
||||
p_delete.add_argument("username")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "create-user":
|
||||
asyncio.run(_create_user(args.username, args.email, args.role))
|
||||
elif args.command == "list-users":
|
||||
asyncio.run(_list_users())
|
||||
elif args.command == "delete-user":
|
||||
asyncio.run(_delete_user(args.username))
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Dashboard module configuration."""
|
||||
|
||||
import threading
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class DashboardSettings(BaseSettings):
|
||||
"""Dashboard settings loaded from environment.
|
||||
|
||||
All variables use the DASHBOARD_ prefix.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="DASHBOARD_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="forbid",
|
||||
env_ignore_empty=True,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# REQUIRED - Database
|
||||
# ==========================================================================
|
||||
|
||||
database_url: str = Field(
|
||||
description="PostgreSQL async URL (e.g., postgresql+asyncpg://user:pass@host:5432/db)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# REQUIRED - Server
|
||||
# ==========================================================================
|
||||
|
||||
host: str = Field(default="0.0.0.0")
|
||||
port: int = Field(default=51300)
|
||||
external_url: str = Field(
|
||||
description="External URL for OpenAPI (e.g., http://localhost:51300)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# OPTIONAL - Provider API keys (for live quota fetching)
|
||||
# ==========================================================================
|
||||
|
||||
serpapi_api_key: str | None = Field(default=None)
|
||||
tavily_api_key: str | None = Field(default=None)
|
||||
exa_api_key: str | None = Field(default=None)
|
||||
linkup_api_key: str | None = Field(default=None)
|
||||
brave_api_key: str | None = Field(default=None)
|
||||
openrouter_api_key: str | None = Field(default=None)
|
||||
|
||||
# ==========================================================================
|
||||
# OPTIONAL - Upstream service URLs for health checks
|
||||
# ==========================================================================
|
||||
|
||||
web_api_url: str = Field(
|
||||
default="http://didiAI-web-api:51100",
|
||||
description="Web API base URL for health checks",
|
||||
)
|
||||
searxng_url: str = Field(
|
||||
default="http://didiAI-web-searxng:8080",
|
||||
description="SearXNG base URL for health check",
|
||||
)
|
||||
llm_api_url: str = Field(
|
||||
default="http://didiAI-llm-api:14011",
|
||||
description="LLM Inference API base URL",
|
||||
)
|
||||
vllm_qwen_url: str = Field(
|
||||
default="http://didiAI-vllm-qwen3.5:14001",
|
||||
description="Local vLLM Qwen base URL",
|
||||
)
|
||||
llamacpp_urls: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: [
|
||||
"http://10.11.10.18:14001",
|
||||
"http://10.11.10.19:14001",
|
||||
],
|
||||
description="Comma-separated list of llama.cpp server URLs",
|
||||
)
|
||||
brain_url: str = Field(
|
||||
default="http://didibrain-api:8090",
|
||||
description="didi-brain base URL — proxied via /api/brain/* for the SPA",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Observability sources (AI monitoring panel — Val 1)
|
||||
# ==========================================================================
|
||||
|
||||
prometheus_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Prometheus base URL (e.g. http://prometheus:9090). Empty disables the "
|
||||
"latency-percentiles panel; health/queues still work."
|
||||
),
|
||||
)
|
||||
rabbitmq_mgmt_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"RabbitMQ management API base URL (e.g. "
|
||||
"http://staging-dataLayer-rabbitmq:15672). Empty disables the queue panel."
|
||||
),
|
||||
)
|
||||
rabbitmq_mgmt_user: str = Field(
|
||||
default="guest", description="RabbitMQ management API user"
|
||||
)
|
||||
rabbitmq_mgmt_password: str = Field(
|
||||
default="guest", description="RabbitMQ management API password"
|
||||
)
|
||||
|
||||
@field_validator("llamacpp_urls", mode="before")
|
||||
@classmethod
|
||||
def parse_llamacpp_urls(cls, v: str | list[str] | None) -> list[str]:
|
||||
if v is None or v == "":
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
return [u.strip() for u in v.split(",") if u.strip()]
|
||||
return list(v)
|
||||
|
||||
# ==========================================================================
|
||||
# History & retention
|
||||
# ==========================================================================
|
||||
|
||||
history_retention_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=365,
|
||||
description="How many days of request history to keep",
|
||||
)
|
||||
provider_stats_cache_seconds: int = Field(
|
||||
default=30,
|
||||
ge=5,
|
||||
le=3600,
|
||||
description="Cache duration for live provider stats",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Auth — bearer token (legacy) + Keycloak JWT (preferred)
|
||||
# ==========================================================================
|
||||
|
||||
api_tokens: Annotated[frozenset[str] | None, NoDecode] = Field(
|
||||
default=None,
|
||||
description="Bearer tokens for dashboard API (comma-separated)",
|
||||
)
|
||||
|
||||
# Keycloak SSO. When `keycloak_url` is set, JWT validation is enabled
|
||||
# alongside the legacy bearer token check. JWTs win first; if absent, the
|
||||
# request falls back to the legacy bearer dance.
|
||||
keycloak_url: str | None = Field(
|
||||
default=None,
|
||||
description="Keycloak base URL (e.g., https://sso.clossers.com). Empty disables JWT auth.",
|
||||
)
|
||||
keycloak_realm: str = Field(
|
||||
default="didi-clients",
|
||||
description="Keycloak realm",
|
||||
)
|
||||
keycloak_client_id: str = Field(
|
||||
default="ai-platform-dashboard",
|
||||
description="Keycloak client_id (audience claim) for this dashboard",
|
||||
)
|
||||
keycloak_required_role: str = Field(
|
||||
default="admin",
|
||||
description=(
|
||||
"Realm role required to access this dashboard. Default `admin` matches "
|
||||
"the same role used by DIDI admin-dashboard, so a single Keycloak admin "
|
||||
"role grants access to both. Override via env if you want a separate role."
|
||||
),
|
||||
)
|
||||
staging_mode: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When true, all auth checks pass — for local dev only. "
|
||||
"MUST be false in production."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("api_tokens", mode="before")
|
||||
@classmethod
|
||||
def parse_api_tokens(cls, v: str | list[str] | None) -> frozenset[str] | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
tokens = [t.strip() for t in v.split(",") if t.strip()]
|
||||
return frozenset(tokens) if tokens else None
|
||||
return frozenset(v) if v else None
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
return bool(self.api_tokens)
|
||||
|
||||
# ==========================================================================
|
||||
# Observability
|
||||
# ==========================================================================
|
||||
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
|
||||
default="INFO"
|
||||
)
|
||||
log_json: bool = Field(default=False)
|
||||
|
||||
|
||||
class SettingsCache:
|
||||
"""Thread-safe settings cache."""
|
||||
|
||||
_instance: DashboardSettings | None = None
|
||||
_lock: threading.Lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> DashboardSettings:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = DashboardSettings()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
with cls._lock:
|
||||
cls._instance = None
|
||||
|
||||
@classmethod
|
||||
def set(cls, settings: DashboardSettings) -> None:
|
||||
with cls._lock:
|
||||
cls._instance = settings
|
||||
|
||||
|
||||
def get_settings() -> DashboardSettings:
|
||||
return SettingsCache.get()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Database layer."""
|
||||
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""SQLAlchemy models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
|
||||
class RequestHistory(Base):
|
||||
"""Individual request log (30-day rolling)."""
|
||||
|
||||
__tablename__ = "request_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
request_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# Request metadata
|
||||
# `module` distinguishes which AI platform service emitted the event:
|
||||
# web — fact-checking module (DashboardEventSink in modules/web/)
|
||||
# brain — knowledge graph + cache module (modules/didi_brain/)
|
||||
# agent_v3 — backend orchestrator (TypeScript)
|
||||
# `tier` is web-flavored (free/premium) — for non-web modules it stays "n/a".
|
||||
module: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
|
||||
tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
|
||||
endpoint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
# Query content
|
||||
claim: Mapped[str | None] = mapped_column(Text)
|
||||
query: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Timing
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Results
|
||||
status_code: Mapped[int | None] = mapped_column(Integer)
|
||||
results_count: Mapped[int | None] = mapped_column(Integer)
|
||||
evidence_count: Mapped[int | None] = mapped_column(Integer)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Costs
|
||||
cost_usd: Mapped[float | None] = mapped_column(Numeric(12, 6))
|
||||
|
||||
# User tracking (from backend)
|
||||
user_id: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
# Stages breakdown (for gather endpoint)
|
||||
stages: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
# Raw data (trimmed)
|
||||
raw_request: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
raw_response: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_history_created", created_at.desc()),
|
||||
Index("ix_history_tier_created", tier, created_at.desc()),
|
||||
Index("ix_history_provider", provider, created_at.desc()),
|
||||
Index("ix_history_endpoint", endpoint, created_at.desc()),
|
||||
Index("ix_history_module_created", module, created_at.desc()),
|
||||
)
|
||||
|
||||
|
||||
class ProviderStatsHourly(Base):
|
||||
"""Hourly rollups for fast dashboard queries."""
|
||||
|
||||
__tablename__ = "provider_stats_hourly"
|
||||
|
||||
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
hour: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
|
||||
|
||||
request_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
avg_duration_ms: Mapped[float] = mapped_column(
|
||||
Numeric(12, 2), default=0, nullable=False
|
||||
)
|
||||
total_cost_usd: Mapped[float] = mapped_column(
|
||||
Numeric(12, 6), default=0, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ConfigOverride(Base):
|
||||
"""Runtime configuration overrides."""
|
||||
|
||||
__tablename__ = "config_overrides"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class ConfigSchemaOverride(Base):
|
||||
"""Runtime schema entries — augments hardcoded KNOWN_KEYS without redeploy.
|
||||
|
||||
Each row represents a config key registered at runtime. The metadata JSON
|
||||
matches the same shape as hardcoded entries: type, default, module,
|
||||
category, label, description, restart_required, plus optional min/max/options.
|
||||
"""
|
||||
|
||||
__tablename__ = "config_schema_override"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
metadata_json: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Dashboard users (for auth + audit log)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(128))
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
role: Mapped[str] = mapped_column(String(16), default="admin")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Audit log for config changes."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
target: Mapped[str | None] = mapped_column(String(256))
|
||||
old_value: Mapped[Any] = mapped_column(JSON)
|
||||
new_value: Mapped[Any] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (Index("ix_audit_timestamp", timestamp.desc()),)
|
||||
|
||||
|
||||
class CatalogEntry(Base):
|
||||
"""DB-backed catalog of AI models / extractors (caiet: Modul Dashboard).
|
||||
|
||||
Satisfies the requirement to administer "modele și extractoare conform datelor
|
||||
din baza de date" with full CRUD: capabilities, context length, CPU/GPU
|
||||
support, quantization, endpoint, limits and per-token cost. Seeded/refreshed
|
||||
from each service's ``/v1/info`` but editable independently.
|
||||
"""
|
||||
|
||||
__tablename__ = "catalog_entry"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="model")
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
# Owning service: llm / embeddings / rerank / audio / video / extractors / web
|
||||
service: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
capabilities: Mapped[Any | None] = mapped_column(JSON)
|
||||
context_length: Mapped[int | None] = mapped_column(Integer)
|
||||
supports_cpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
supports_gpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
quantization: Mapped[str | None] = mapped_column(String(32))
|
||||
endpoint: Mapped[str | None] = mapped_column(String(256))
|
||||
limits: Mapped[Any | None] = mapped_column(JSON)
|
||||
cost_per_token: Mapped[float | None] = mapped_column(Numeric(16, 10))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_catalog_kind_service", kind, service),
|
||||
Index("uq_catalog_service_name", service, name, unique=True),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Archive tables (for future claims-api module)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ClaimsArchive(Base):
|
||||
"""Permanent storage for verified claims."""
|
||||
|
||||
__tablename__ = "claims_archive"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
claim: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
claim_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
source_request_id: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
# Verdict
|
||||
verdict: Mapped[str | None] = mapped_column(String(32))
|
||||
confidence: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Context
|
||||
primary_country: Mapped[str | None] = mapped_column(String(8))
|
||||
detected_language: Mapped[str | None] = mapped_column(String(8))
|
||||
entities: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
# Metadata
|
||||
promoted_by: Mapped[str | None] = mapped_column(String(128))
|
||||
tags: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
|
||||
articles: Mapped[list["ClaimArticle"]] = relationship(
|
||||
back_populates="claim_ref", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ArticlesArchive(Base):
|
||||
"""Permanent storage for article full-text."""
|
||||
|
||||
__tablename__ = "articles_archive"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
url_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
canonical_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
title: Mapped[str | None] = mapped_column(Text)
|
||||
full_text: Mapped[str | None] = mapped_column(Text)
|
||||
publisher: Mapped[str | None] = mapped_column(String(256))
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
retrieved_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
extraction_method: Mapped[str | None] = mapped_column(String(16))
|
||||
credibility_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
|
||||
claims: Mapped[list["ClaimArticle"]] = relationship(back_populates="article_ref")
|
||||
|
||||
__table_args__ = (Index("ix_articles_url_hash", url_hash),)
|
||||
|
||||
|
||||
class ClaimArticle(Base):
|
||||
"""Many-to-many: claims to articles with relevance."""
|
||||
|
||||
__tablename__ = "claim_articles"
|
||||
|
||||
claim_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("claims_archive.id"), primary_key=True
|
||||
)
|
||||
article_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("articles_archive.id"), primary_key=True
|
||||
)
|
||||
relevance_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
snippet: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
claim_ref: Mapped["ClaimsArchive"] = relationship(back_populates="articles")
|
||||
article_ref: Mapped["ArticlesArchive"] = relationship(back_populates="claims")
|
||||
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Database session management."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import Base
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("db.session")
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def init_engine(settings: DashboardSettings) -> AsyncEngine:
|
||||
"""Create the async engine and session factory."""
|
||||
global _engine, _session_factory
|
||||
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
_session_factory = async_sessionmaker(
|
||||
_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
logger.info("Database engine initialized")
|
||||
return _engine
|
||||
|
||||
|
||||
async def create_all_tables() -> None:
|
||||
"""Create all tables if they don't exist."""
|
||||
if _engine is None:
|
||||
raise RuntimeError("Engine not initialized")
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables ensured")
|
||||
|
||||
|
||||
async def close_engine() -> None:
|
||||
"""Close the database engine."""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields a session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Session factory not initialized")
|
||||
async with _session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
"""Return the configured session factory."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Session factory not initialized")
|
||||
return _session_factory
|
||||
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO", json_output: bool = False) -> None:
|
||||
"""Configure root logger."""
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
formatter: logging.Formatter
|
||||
if json_output:
|
||||
formatter = logging.Formatter(
|
||||
'{"time":"%(asctime)s","level":"%(levelname)s",'
|
||||
'"logger":"%(name)s","msg":"%(message)s"}'
|
||||
)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(log_level)
|
||||
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get named logger."""
|
||||
return logging.getLogger(f"dashboard.{name}")
|
||||
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Per-provider pricing heuristics for cost estimation.
|
||||
|
||||
These rates are approximations used to compute an estimated cost per
|
||||
request at ingest time. Real billing lives on the provider side — the
|
||||
dashboard just needs a "good enough" number for internal reporting.
|
||||
|
||||
Pricing cheat-sheet (as of 2026-04):
|
||||
SerpAPI Starter: $25/month / 1000 searches = $0.025 per search
|
||||
Tavily: free tier = $0
|
||||
Brave: free (Data for AI tier) = $0
|
||||
LinkUp: pay-as-you-go, unknown = $0 (placeholder)
|
||||
SearXNG: self-hosted = $0
|
||||
OpenRouter (gemini-3.1-flash-lite-preview):
|
||||
$0.25/M input, $1.50/M output
|
||||
— a typical gather uses ~4k input + ~800 output tokens
|
||||
≈ $0.0022 per gather
|
||||
|
||||
Keep this module free of IO so it can be imported in routes and tests.
|
||||
"""
|
||||
|
||||
# Per-request cost in USD for search providers
|
||||
SEARCH_COST_USD: dict[str, float] = {
|
||||
"serpapi": 0.025,
|
||||
"tavily": 0.0,
|
||||
"brave": 0.0,
|
||||
"linkup": 0.0,
|
||||
"exa": 0.01, # rough — pay-as-you-go
|
||||
"searxng": 0.0,
|
||||
"paid-rotation": 0.025, # worst-case assume SerpAPI was rotated
|
||||
}
|
||||
|
||||
# Per-request cost in USD for full gather pipelines (includes context + evidence LLM)
|
||||
GATHER_LLM_COST_USD: dict[str, float] = {
|
||||
"free": 0.0, # runs on local Qwen
|
||||
"premium": 0.003, # ~3 OpenRouter calls (context + evidence + summary)
|
||||
}
|
||||
|
||||
|
||||
def estimate_search_cost(provider: str | None) -> float:
|
||||
"""Return estimated cost for a single /v1/search call."""
|
||||
if provider is None:
|
||||
return 0.0
|
||||
return SEARCH_COST_USD.get(provider.lower(), 0.0)
|
||||
|
||||
|
||||
def estimate_gather_cost(tier: str | None, provider: str | None) -> float:
|
||||
"""Return estimated cost for a full /v1/gather pipeline."""
|
||||
search = estimate_search_cost(provider)
|
||||
llm = GATHER_LLM_COST_USD.get((tier or "free").lower(), 0.0)
|
||||
return round(search + llm, 6)
|
||||
|
||||
|
||||
def estimate_cost(endpoint: str, tier: str | None, provider: str | None) -> float:
|
||||
"""Dispatch by endpoint."""
|
||||
if endpoint == "/v1/gather":
|
||||
return estimate_gather_cost(tier, provider)
|
||||
if endpoint == "/v1/search":
|
||||
return estimate_search_cost(provider)
|
||||
return 0.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Live provider stats fetchers."""
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"""Base types for provider stats."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderStats:
|
||||
"""Live stats for a single provider."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
kind: str # "search" | "llm" | "internal"
|
||||
healthy: bool
|
||||
message: str | None = None
|
||||
|
||||
# Quota / billing
|
||||
plan_name: str | None = None
|
||||
plan_price_monthly: float | None = None
|
||||
quota_limit: float | None = None
|
||||
quota_used: float | None = None
|
||||
quota_unit: str = "requests" # "requests" | "usd" | "credits"
|
||||
quota_period: str = "month"
|
||||
|
||||
# Rate limits
|
||||
rate_limit: str | None = None
|
||||
|
||||
# Last error
|
||||
last_error: str | None = None
|
||||
|
||||
# Extra provider-specific fields
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def quota_remaining(self) -> float | None:
|
||||
if self.quota_limit is None or self.quota_used is None:
|
||||
return None
|
||||
return max(0.0, self.quota_limit - self.quota_used)
|
||||
|
||||
@property
|
||||
def quota_percent_used(self) -> float | None:
|
||||
if self.quota_limit is None or self.quota_used is None or self.quota_limit == 0:
|
||||
return None
|
||||
return round((self.quota_used / self.quota_limit) * 100, 1)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"kind": self.kind,
|
||||
"healthy": self.healthy,
|
||||
"message": self.message,
|
||||
"plan_name": self.plan_name,
|
||||
"plan_price_monthly": self.plan_price_monthly,
|
||||
"quota_limit": self.quota_limit,
|
||||
"quota_used": self.quota_used,
|
||||
"quota_remaining": self.quota_remaining,
|
||||
"quota_percent_used": self.quota_percent_used,
|
||||
"quota_unit": self.quota_unit,
|
||||
"quota_period": self.quota_period,
|
||||
"rate_limit": self.rate_limit,
|
||||
"last_error": self.last_error,
|
||||
"extra": self.extra,
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""Brave Search live stats fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_brave_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="brave",
|
||||
display_name="Brave Search",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Brave has no dedicated account endpoint — probe with rate limit headers
|
||||
resp = await client.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": "test", "count": 1},
|
||||
headers={"X-Subscription-Token": api_key},
|
||||
)
|
||||
|
||||
stats.healthy = resp.status_code == 200
|
||||
headers = resp.headers
|
||||
|
||||
# Parse rate limit headers (format: "second, month")
|
||||
rl_limit = headers.get("x-ratelimit-limit", "")
|
||||
rl_remaining = headers.get("x-ratelimit-remaining", "")
|
||||
|
||||
if rl_limit:
|
||||
parts = [p.strip() for p in rl_limit.split(",")]
|
||||
if len(parts) >= 1:
|
||||
stats.rate_limit = f"{parts[0]}/second"
|
||||
|
||||
if rl_remaining and rl_limit:
|
||||
rem_parts = [p.strip() for p in rl_remaining.split(",")]
|
||||
lim_parts = [p.strip() for p in rl_limit.split(",")]
|
||||
if len(rem_parts) >= 2 and len(lim_parts) >= 2:
|
||||
try:
|
||||
monthly_limit = float(lim_parts[1])
|
||||
monthly_remaining = float(rem_parts[1])
|
||||
if monthly_limit > 0:
|
||||
stats.quota_limit = monthly_limit
|
||||
stats.quota_used = monthly_limit - monthly_remaining
|
||||
stats.quota_unit = "requests"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
stats.plan_name = "Free / Data for AI"
|
||||
stats.extra = {
|
||||
"rate_limit_raw": rl_limit,
|
||||
"remaining_raw": rl_remaining,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"""Internal service health checks (SearXNG, vLLM, llama.cpp, web-api)."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def _probe(url: str, timeout: float = 5.0) -> tuple[bool, str | None]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(url)
|
||||
return resp.status_code == 200, None
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
async def fetch_searxng_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="searxng",
|
||||
display_name="SearXNG",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
plan_name="Self-hosted",
|
||||
quota_limit=None,
|
||||
quota_used=None,
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/healthz")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_web_api_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="web_api",
|
||||
display_name="Web API",
|
||||
kind="internal",
|
||||
healthy=False,
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/health")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_vllm_qwen_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="vllm_qwen",
|
||||
display_name="Qwen3.5-35B-A3B (local vLLM)",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
plan_name="Local GPU",
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/v1/models")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_llamacpp_stats(urls: list[str]) -> list[ProviderStats]:
|
||||
results = []
|
||||
for i, url in enumerate(urls, 1):
|
||||
stats = ProviderStats(
|
||||
name=f"llamacpp_{i}",
|
||||
display_name=f"llama.cpp #{i} ({url})",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
plan_name="External GPU",
|
||||
)
|
||||
healthy, err = await _probe(f"{url}/v1/models")
|
||||
stats.healthy = healthy
|
||||
if healthy:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{url}/v1/models")
|
||||
data = resp.json()
|
||||
models = data.get("models") or data.get("data") or []
|
||||
if models:
|
||||
first = models[0]
|
||||
stats.extra = {"model": str(first.get("name") or first.get("id", ""))}
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
results.append(stats)
|
||||
return results
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""LinkUp live stats (limited — no billing endpoint)."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_linkup_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="linkup",
|
||||
display_name="LinkUp",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
# LinkUp has no public billing endpoint; we only probe health
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.linkup.so/v1/search",
|
||||
json={"q": "test", "depth": "standard", "outputType": "searchResults"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
stats.healthy = resp.status_code == 200
|
||||
stats.message = None if stats.healthy else f"HTTP {resp.status_code}"
|
||||
stats.plan_name = "Standard"
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""OpenRouter live credits fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_openrouter_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="openrouter",
|
||||
display_name="OpenRouter",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data", {})
|
||||
|
||||
stats.healthy = True
|
||||
stats.quota_limit = float(data.get("total_credits") or 0)
|
||||
stats.quota_used = float(data.get("total_usage") or 0)
|
||||
stats.quota_unit = "usd"
|
||||
stats.quota_period = "total"
|
||||
stats.plan_name = "Pay-as-you-go"
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
"""Fetch available models from OpenRouter for config dropdown."""
|
||||
if not api_key:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data", [])
|
||||
|
||||
models: list[dict] = []
|
||||
for m in data:
|
||||
pricing = m.get("pricing", {})
|
||||
try:
|
||||
prompt_per_m = float(pricing.get("prompt", "0")) * 1_000_000
|
||||
completion_per_m = float(pricing.get("completion", "0")) * 1_000_000
|
||||
except (ValueError, TypeError):
|
||||
prompt_per_m = 0.0
|
||||
completion_per_m = 0.0
|
||||
models.append(
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name", m.get("id")),
|
||||
"context_length": m.get("context_length", 0),
|
||||
"prompt_cost_per_m": round(prompt_per_m, 2),
|
||||
"completion_cost_per_m": round(completion_per_m, 2),
|
||||
}
|
||||
)
|
||||
return models
|
||||
except Exception:
|
||||
return []
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""Provider registry — aggregates all live stats with caching."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.providers.base import ProviderStats
|
||||
from dashboard.providers.brave import fetch_brave_stats
|
||||
from dashboard.providers.internal import (
|
||||
fetch_llamacpp_stats,
|
||||
fetch_searxng_stats,
|
||||
fetch_vllm_qwen_stats,
|
||||
fetch_web_api_stats,
|
||||
)
|
||||
from dashboard.providers.linkup import fetch_linkup_stats
|
||||
from dashboard.providers.openrouter import fetch_openrouter_stats
|
||||
from dashboard.providers.serpapi import fetch_serpapi_stats
|
||||
from dashboard.providers.tavily import fetch_tavily_stats
|
||||
|
||||
logger = get_logger("providers.registry")
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Caches provider stats with configurable TTL."""
|
||||
|
||||
def __init__(self, settings: DashboardSettings) -> None:
|
||||
self.settings = settings
|
||||
self._cache: dict[str, ProviderStats] = {}
|
||||
self._cache_time: float = 0.0
|
||||
self._cache_wall: datetime | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def last_refresh_iso(self) -> str | None:
|
||||
return self._cache_wall.isoformat() if self._cache_wall else None
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float | None:
|
||||
if not self._cache_wall:
|
||||
return None
|
||||
return (datetime.now(timezone.utc) - self._cache_wall).total_seconds()
|
||||
|
||||
async def get_all(self, force: bool = False) -> list[ProviderStats]:
|
||||
"""Fetch all provider stats (cached)."""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
age = now - self._cache_time
|
||||
if (
|
||||
not force
|
||||
and self._cache
|
||||
and age < self.settings.provider_stats_cache_seconds
|
||||
):
|
||||
return list(self._cache.values())
|
||||
|
||||
results = await self._fetch_all()
|
||||
self._cache = {s.name: s for s in results}
|
||||
self._cache_time = now
|
||||
self._cache_wall = datetime.now(timezone.utc)
|
||||
return results
|
||||
|
||||
async def _fetch_all(self) -> list[ProviderStats]:
|
||||
tasks = [
|
||||
fetch_serpapi_stats(self.settings.serpapi_api_key),
|
||||
fetch_tavily_stats(self.settings.tavily_api_key),
|
||||
fetch_brave_stats(self.settings.brave_api_key),
|
||||
fetch_linkup_stats(self.settings.linkup_api_key),
|
||||
fetch_openrouter_stats(self.settings.openrouter_api_key),
|
||||
fetch_searxng_stats(self.settings.searxng_url),
|
||||
fetch_web_api_stats(self.settings.web_api_url),
|
||||
fetch_vllm_qwen_stats(self.settings.vllm_qwen_url),
|
||||
]
|
||||
|
||||
gathered = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
stats: list[ProviderStats] = []
|
||||
for i, r in enumerate(gathered):
|
||||
if isinstance(r, BaseException):
|
||||
logger.warning("Provider fetch #%d failed: %s", i, r)
|
||||
continue
|
||||
stats.append(r) # type: ignore[arg-type]
|
||||
|
||||
# llama.cpp servers (variable count)
|
||||
try:
|
||||
llamacpp = await fetch_llamacpp_stats(self.settings.llamacpp_urls)
|
||||
stats.extend(llamacpp)
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp fetch failed: %s", e)
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""SerpAPI live quota fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_serpapi_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="serpapi",
|
||||
display_name="SerpAPI",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://serpapi.com/account",
|
||||
params={"api_key": api_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
stats.healthy = data.get("account_status", "").lower() == "active"
|
||||
stats.plan_name = data.get("plan_name")
|
||||
stats.plan_price_monthly = float(data.get("plan_monthly_price") or 0)
|
||||
stats.quota_limit = float(data.get("searches_per_month") or 0)
|
||||
stats.quota_used = float(data.get("this_month_usage") or 0)
|
||||
stats.quota_unit = "searches"
|
||||
stats.rate_limit = f"{data.get('account_rate_limit_per_hour', 0)}/hour"
|
||||
stats.extra = {
|
||||
"email": str(data.get("account_email", "")),
|
||||
"extra_credits": str(data.get("extra_credits", 0)),
|
||||
"this_hour": str(data.get("this_hour_searches", 0)),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""Tavily live usage fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_tavily_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="tavily",
|
||||
display_name="Tavily",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.tavily.com/usage",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
# Tavily fronts api.tavily.com with AWS WAF; some IPs / regions get a
|
||||
# 202 challenge with an empty body (header x-amzn-waf-action=challenge).
|
||||
# That's not a JSON error — the API just refused us. Surface it clearly
|
||||
# instead of pretending the response was malformed JSON.
|
||||
if resp.headers.get("x-amzn-waf-action"):
|
||||
stats.message = (
|
||||
f"Tavily WAF challenge ({resp.headers['x-amzn-waf-action']}) — "
|
||||
"live quota probe blocked from this IP"
|
||||
)
|
||||
stats.last_error = stats.message
|
||||
return stats
|
||||
|
||||
# Empty body or non-JSON content-type → can't parse, but quota itself
|
||||
# may still be fine. Treat as "tracking unavailable" not a hard error.
|
||||
if not resp.content:
|
||||
stats.message = f"Empty body from Tavily ({resp.status_code})"
|
||||
stats.last_error = stats.message
|
||||
return stats
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
key_data = data.get("key", {})
|
||||
account_data = data.get("account", {})
|
||||
|
||||
stats.healthy = True
|
||||
stats.plan_name = account_data.get("current_plan")
|
||||
stats.quota_limit = float(account_data.get("plan_limit") or 0)
|
||||
stats.quota_used = float(account_data.get("plan_usage") or 0)
|
||||
stats.quota_unit = "requests"
|
||||
stats.extra = {
|
||||
"search": str(account_data.get("search_usage", 0)),
|
||||
"crawl": str(account_data.get("crawl_usage", 0)),
|
||||
"extract": str(account_data.get("extract_usage", 0)),
|
||||
"key_usage": str(key_data.get("usage", 0)),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Background task for data retention."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session_factory
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("retention")
|
||||
|
||||
|
||||
async def purge_old_history(days: int) -> int:
|
||||
"""Delete history rows older than N days."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
delete(RequestHistory).where(RequestHistory.created_at < cutoff)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def retention_loop(settings: DashboardSettings) -> None:
|
||||
"""Run purge once per hour."""
|
||||
while True:
|
||||
try:
|
||||
deleted = await purge_old_history(settings.history_retention_days)
|
||||
if deleted:
|
||||
logger.info(
|
||||
"Purged %d rows older than %d days",
|
||||
deleted,
|
||||
settings.history_retention_days,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Retention job failed: %s", e)
|
||||
await asyncio.sleep(3600)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Archive — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Claims Archive</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Permanently stored claims + articles. Consumed by the future claims-api
|
||||
to serve gather responses without network search.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-white">{{ total_claims }}</p>
|
||||
<p class="text-slate-400">claims</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-white">{{ total_articles }}</p>
|
||||
<p class="text-slate-400">articles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="mb-6">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Search claims..."
|
||||
class="w-full bg-slate-900 text-white text-sm rounded-md border border-slate-800 px-4 py-2.5 focus:outline-none focus:border-indigo-600">
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for claim in claims %}
|
||||
<a href="/archive/{{ claim.id }}" class="block bg-slate-900 rounded-xl border border-slate-800 p-4 hover:border-slate-700 transition">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-white font-medium">{{ claim.claim }}</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if claim.verdict %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800 shrink-0">
|
||||
{{ claim.verdict }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center text-slate-500">
|
||||
No claims archived yet. Promote a gather from the <a href="/history" class="text-indigo-400 hover:text-indigo-300">History</a> page.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Claim #{{ claim.id }} — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/archive" class="text-sm text-slate-400 hover:text-white">← Back to archive</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-xl font-bold text-white">{{ claim.claim }}</h1>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
{% if claim.source_request_id %}
|
||||
<a href="/history/{{ claim.source_request_id }}" class="text-indigo-400 hover:text-indigo-300">source request →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if claim.summary %}
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Summary</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-slate-200">{{ claim.summary }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if claim.entities %}
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Entities</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 grid grid-cols-3 gap-4">
|
||||
{% for key in ['persons', 'institutions', 'locations'] %}
|
||||
<div>
|
||||
<p class="uppercase text-slate-500 font-semibold mb-1">{{ key }}</p>
|
||||
<ul class="space-y-1">
|
||||
{% for item in (claim.entities.get(key) or []) %}<li>{{ item }}</li>{% else %}<li class="text-slate-600">—</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Articles ({{ articles|length }})</h2>
|
||||
<div class="space-y-3">
|
||||
{% for link in articles %}
|
||||
{% set a = link.article %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ a.url }}" target="_blank" rel="noopener" class="text-white font-medium hover:text-indigo-300">{{ a.title or a.url }}</a>
|
||||
<p class="text-xs text-slate-500 mt-0.5 truncate">{{ a.publisher or a.url }}</p>
|
||||
</div>
|
||||
{% if link.link.relevance_score %}
|
||||
<span class="shrink-0 text-xs font-mono text-slate-400">rel: {{ '%.2f'|format(link.link.relevance_score|float) }}</span>
|
||||
{% endif %}
|
||||
{% if a.credibility_score %}
|
||||
<span class="shrink-0 text-xs font-mono text-slate-400">cred: {{ '%.2f'|format(a.credibility_score|float) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if link.link.snippet %}
|
||||
<p class="text-xs text-slate-300 italic">"{{ link.link.snippet[:300] }}{% if link.link.snippet|length > 300 %}…{% endif %}"</p>
|
||||
{% endif %}
|
||||
{% if a.full_text %}
|
||||
<details class="mt-2 text-xs text-slate-400">
|
||||
<summary class="cursor-pointer hover:text-white">View full text ({{ a.full_text|length }} chars)</summary>
|
||||
<pre class="mt-2 p-3 bg-slate-800/50 rounded whitespace-pre-wrap text-[11px]">{{ a.full_text[:3000] }}{% if a.full_text|length > 3000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit Log — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Audit Log</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Who changed what, when. Covers config changes and auth events.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="overflow-x-auto scrollbar-thin">
|
||||
<table class="min-w-full divide-y divide-slate-800">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">User</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Action</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Target</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">From</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800 text-sm">
|
||||
{% for entry in entries %}
|
||||
<tr class="hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 text-slate-400 font-mono text-xs whitespace-nowrap">
|
||||
{{ entry.timestamp.strftime('%m-%d %H:%M:%S') if entry.timestamp else '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-white font-medium">{{ entry.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% set action_color = {
|
||||
'config.set': 'bg-indigo-900/50 text-indigo-300 border-indigo-800',
|
||||
'config.reset': 'bg-amber-900/50 text-amber-300 border-amber-800',
|
||||
'config.delete': 'bg-rose-900/50 text-rose-300 border-rose-800',
|
||||
} %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full border {{ action_color.get(entry.action, 'bg-slate-800 text-slate-300 border-slate-700') }}">
|
||||
{{ entry.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.target or '—' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ entry.old_value|tojson if entry.old_value is not none else '—' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.new_value|tojson if entry.new_value is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-12 text-center text-slate-500">No audit entries yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-950">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}didiAI Dashboard{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full text-slate-200">
|
||||
<div class="min-h-full">
|
||||
<nav class="bg-slate-900 border-b border-slate-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center font-bold">d</div>
|
||||
<span class="text-white font-semibold tracking-tight">didiAI Dashboard</span>
|
||||
</div>
|
||||
<div class="ml-10 flex items-baseline space-x-2">
|
||||
{% set nav_items = [
|
||||
('overview', '/', 'Overview'),
|
||||
('providers', '/providers', 'Providers'),
|
||||
('history', '/history', 'History'),
|
||||
('archive', '/archive', 'Archive'),
|
||||
('cost', '/cost', 'Cost'),
|
||||
('config', '/config', 'Config'),
|
||||
('audit', '/audit', 'Audit'),
|
||||
] %}
|
||||
{% for key, href, label in nav_items %}
|
||||
<a href="{{ href }}" class="rounded-md px-3 py-2 text-sm font-medium transition
|
||||
{% if active_page == key %}bg-slate-800 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Configuration — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Runtime Configuration</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Live settings pulled by web-api every 30 seconds — no restart required.
|
||||
Override values replace env defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% set category_info = {
|
||||
'providers': {'icon': '🔌', 'title': 'Paid Providers', 'desc': 'Toggle premium-tier search engines on and off'},
|
||||
'routing': {'icon': '🔀', 'title': 'Routing Strategy', 'desc': 'How premium-tier picks providers per request'},
|
||||
'llm': {'icon': '🧠', 'title': 'LLM Models', 'desc': 'Which model OpenRouter calls use'},
|
||||
'tiers': {'icon': '🎚', 'title': 'Tier Limits', 'desc': 'Default result counts per tier'},
|
||||
} %}
|
||||
|
||||
{% for cat_key, entries in categories.items() %}
|
||||
{% set info = category_info.get(cat_key, {'icon': '⚙', 'title': cat_key.title(), 'desc': ''}) %}
|
||||
<section class="mb-10">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>{{ info.icon }}</span> {{ info.title }}
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ info.desc }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{% for entry in entries %}
|
||||
{% include "partials/config_row.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<div class="mt-12 p-4 bg-slate-900 border border-slate-800 rounded-xl text-xs text-slate-400">
|
||||
<p class="font-semibold text-slate-300 mb-1">ℹ How this works</p>
|
||||
<p>Changes here are stored in the dashboard database. The web-api container polls <code class="text-indigo-300">/api/config</code> every 30 seconds and merges overrides into its in-memory config. No restart is needed — your toggle or model change takes effect within ~30s on the next request.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
153
ai_platform/modules/dashboard/src/dashboard/templates/cost.html
Normal file
153
ai_platform/modules/dashboard/src/dashboard/templates/cost.html
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Cost — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Cost Tracking</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Estimated spend across search + LLM providers. Real billing lives with each provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Last 24h</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">${{ "%.4f"|format(cost_24h) }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">estimated</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Last 7 days</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(cost_7d) }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">rolling</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Last 30 days</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(cost_30d) }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">rolling</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-indigo-700/40 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-300">Projected monthly</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(projected_monthly) }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">24h burn × 30</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider quotas -->
|
||||
<section class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">Provider Budgets (live)</h2>
|
||||
<div class="space-y-3">
|
||||
{% for b in budgets %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-medium text-sm">{{ b.name }}</span>
|
||||
<span class="text-xs text-slate-500">{{ b.kind }}</span>
|
||||
{% if b.plan_price %}<span class="text-xs text-slate-500">· ${{ "%.0f"|format(b.plan_price) }}/mo</span>{% endif %}
|
||||
</div>
|
||||
<span class="text-xs font-mono text-slate-400">
|
||||
{% if b.unit == 'usd' %}${% endif %}{{ "%.2f"|format(b.used) }} / {% if b.unit == 'usd' %}${% endif %}{{ "%.0f"|format(b.limit) }} ({{ b.percent }}%)
|
||||
</span>
|
||||
</div>
|
||||
{% set color = 'bg-emerald-500' %}
|
||||
{% if b.percent >= 90 %}{% set color = 'bg-rose-500' %}
|
||||
{% elif b.percent >= 70 %}{% set color = 'bg-amber-500' %}
|
||||
{% elif b.percent >= 50 %}{% set color = 'bg-sky-500' %}
|
||||
{% endif %}
|
||||
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-2 {{ color }} transition-all" style="width: {{ b.percent }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Breakdown tables -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">By Provider (30d)</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Provider</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Requests</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{% for row in by_provider %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-white">{{ row.provider }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ row.count }}</td>
|
||||
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(row.cost) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">By Tier (30d)</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Tier</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Requests</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{% for row in by_tier %}
|
||||
<tr>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full
|
||||
{% if row.tier == 'premium' %}bg-fuchsia-900/50 text-fuchsia-300 border border-fuchsia-800
|
||||
{% else %}bg-sky-900/50 text-sky-300 border border-sky-800{% endif %}">
|
||||
{{ row.tier }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ row.count }}</td>
|
||||
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(row.cost) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Top expensive -->
|
||||
<section class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">Top 10 Most Expensive (30d)</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Tier</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Query</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{% for item in top %}
|
||||
<tr class="hover:bg-slate-800/30 cursor-pointer" onclick="window.location='/history/{{ item.request_id }}'">
|
||||
<td class="px-4 py-2 text-slate-400 font-mono text-xs whitespace-nowrap">{{ item.created_at.strftime('%m-%d %H:%M') if item.created_at else '—' }}</td>
|
||||
<td class="px-4 py-2 text-slate-400">{{ item.tier }}</td>
|
||||
<td class="px-4 py-2 text-slate-300 max-w-md truncate">{{ item.claim or item.query or '—' }}</td>
|
||||
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(item.cost_usd|float) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}History — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Request History</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Last {{ filters.hours }} hours — {{ items|length }} results</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="bg-slate-900 rounded-xl border border-slate-800 p-4 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">Tier</label>
|
||||
<select name="tier" class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
|
||||
<option value="">All</option>
|
||||
<option value="free" {% if filters.tier == 'free' %}selected{% endif %}>Free</option>
|
||||
<option value="premium" {% if filters.tier == 'premium' %}selected{% endif %}>Premium</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">Provider</label>
|
||||
<input type="text" name="provider" value="{{ filters.provider }}" placeholder="any"
|
||||
class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">Endpoint</label>
|
||||
<input type="text" name="endpoint" value="{{ filters.endpoint }}" placeholder="/v1/gather"
|
||||
class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">Window (hours)</label>
|
||||
<select name="hours" class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
|
||||
{% for h in [1, 6, 24, 72, 168, 720] %}
|
||||
<option value="{{ h }}" {% if filters.hours == h %}selected{% endif %}>{{ h }}h</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-md transition">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="overflow-x-auto scrollbar-thin">
|
||||
<table class="min-w-full divide-y divide-slate-800">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Tier</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Endpoint</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Provider</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Query</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Results</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Duration</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-300 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800 text-sm">
|
||||
{% for item in items %}
|
||||
<tr class="hover:bg-slate-800/30 cursor-pointer" onclick="window.location='/history/{{ item.request_id }}'">
|
||||
<td class="px-4 py-3 text-slate-400 font-mono text-xs whitespace-nowrap">
|
||||
{{ item.created_at.strftime('%m-%d %H:%M:%S') if item.created_at else '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full
|
||||
{% if item.tier == 'premium' %}bg-fuchsia-900/50 text-fuchsia-300 border border-fuchsia-800
|
||||
{% else %}bg-sky-900/50 text-sky-300 border border-sky-800{% endif %}">
|
||||
{{ item.tier }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ item.endpoint }}</td>
|
||||
<td class="px-4 py-3 text-slate-400">{{ item.provider or '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-300 max-w-xs truncate">{{ item.claim or item.query or '—' }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-300">{{ item.results_count if item.results_count is not none else '—' }}</td>
|
||||
<td class="px-4 py-3 text-right font-mono text-xs
|
||||
{% if item.duration_ms and item.duration_ms > 30000 %}text-amber-400
|
||||
{% elif item.duration_ms and item.duration_ms > 60000 %}text-rose-400
|
||||
{% else %}text-slate-300{% endif %}">
|
||||
{{ '%.1fs'|format(item.duration_ms/1000) if item.duration_ms else '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-mono text-xs text-slate-400">
|
||||
{% if item.cost_usd %}${{ '%.4f'|format(item.cost_usd|float) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if item.status_code and item.status_code < 400 %}
|
||||
<span class="text-emerald-400">✓</span>
|
||||
{% elif item.status_code %}
|
||||
<span class="text-rose-400" title="HTTP {{ item.status_code }}">✗</span>
|
||||
{% else %}
|
||||
<span class="text-slate-500">?</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" class="px-4 py-12 text-center text-slate-500">No requests in the selected window.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Request {{ item.request_id[:8] }} — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<a href="/history" class="text-sm text-slate-400 hover:text-white">← Back to history</a>
|
||||
{% if item.endpoint == '/v1/gather' and item.raw_response %}
|
||||
<button
|
||||
hx-post="/api/archive/promote/{{ item.request_id }}"
|
||||
hx-headers='{"Authorization": "Bearer dashboard-ui"}'
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Promote this gather to the permanent archive?"
|
||||
class="text-xs px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition">
|
||||
📥 Promote to archive
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white font-mono">{{ item.request_id }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{{ item.created_at.strftime('%Y-%m-%d %H:%M:%S UTC') if item.created_at else '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<p class="text-xs uppercase text-slate-400">Tier</p>
|
||||
<p class="mt-1 text-lg font-semibold
|
||||
{% if item.tier == 'premium' %}text-fuchsia-400{% else %}text-sky-400{% endif %}">
|
||||
{{ item.tier }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<p class="text-xs uppercase text-slate-400">Endpoint</p>
|
||||
<p class="mt-1 text-sm font-mono text-white">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<p class="text-xs uppercase text-slate-400">Duration</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">
|
||||
{{ '%.1fs'|format(item.duration_ms/1000) if item.duration_ms else '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<p class="text-xs uppercase text-slate-400">Status</p>
|
||||
<p class="mt-1 text-lg font-semibold
|
||||
{% if item.status_code and item.status_code < 400 %}text-emerald-400
|
||||
{% elif item.status_code %}text-rose-400{% else %}text-slate-400{% endif %}">
|
||||
{{ item.status_code or '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claim/query -->
|
||||
{% if item.claim or item.query %}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Query / Claim</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-slate-200">
|
||||
{{ item.claim or item.query }}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stages (gather only) -->
|
||||
{% if item.stages %}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Pipeline Stages</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Stage</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Success</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Items</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{% for stage in item.stages %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-white font-medium">{{ stage.stage }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{% if stage.success %}
|
||||
<span class="text-emerald-400">✓</span>
|
||||
{% else %}
|
||||
<span class="text-rose-400">✗ {{ stage.error or '' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ stage.items_processed }}</td>
|
||||
<td class="px-4 py-2 text-right font-mono text-xs text-slate-300">
|
||||
{{ '%.0f'|format(stage.duration_ms) }}ms
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Raw data -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{% if item.raw_request %}
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Request</h2>
|
||||
<pre class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 overflow-x-auto scrollbar-thin">{{ item.raw_request | tojson(indent=2) }}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if item.raw_response %}
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Response</h2>
|
||||
<pre class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 overflow-x-auto scrollbar-thin max-h-96">{{ item.raw_response | tojson(indent=2) }}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.error %}
|
||||
<section class="mt-6">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Error</h2>
|
||||
<div class="bg-rose-900/20 border border-rose-800 rounded-xl p-4 text-rose-300 text-sm">{{ item.error }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Overview — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Overview</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Live status of search providers, LLMs, and recent activity (last 24h)</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Requests (24h)</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{{ summary.total }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Free: {{ summary.by_tier.get('free', 0) }} · Premium: {{ summary.by_tier.get('premium', 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Avg Response</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{{ (summary.avg_duration_ms/1000)|round(1) }}<span class="text-lg text-slate-400">s</span></p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ summary.avg_duration_ms|int }}ms mean</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Error Rate</p>
|
||||
<p class="mt-2 text-3xl font-bold
|
||||
{% if summary.error_rate >= 5 %}text-rose-400{% elif summary.error_rate >= 1 %}text-amber-400{% else %}text-emerald-400{% endif %}">
|
||||
{{ summary.error_rate }}%
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ summary.errors }} errors total</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-400">Cost (24h)</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(summary.total_cost) }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">estimated</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider grid -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Providers</h2>
|
||||
<button hx-get="/providers" hx-target="body" class="text-xs text-slate-400 hover:text-white">View details →</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{% for provider in providers %}
|
||||
{% include "partials/provider_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
{% set key = entry.key %}
|
||||
{% set meta = entry.meta %}
|
||||
{% set value = entry.value %}
|
||||
{% set is_override = entry.is_override %}
|
||||
<div id="cfg-{{ key|replace('.','-') }}" class="bg-slate-900 rounded-xl border {% if is_override %}border-indigo-700/60{% else %}border-slate-800{% endif %} p-4 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-white text-sm">{{ meta.label }}</h4>
|
||||
{% if is_override %}
|
||||
<span class="inline-flex px-2 py-0.5 text-[10px] rounded-full bg-indigo-900/50 text-indigo-300 border border-indigo-800">override</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ meta.description }}</p>
|
||||
<p class="text-[10px] text-slate-500 mt-1 font-mono">{{ key }}</p>
|
||||
{% if entry.updated_at %}
|
||||
<p class="text-[10px] text-slate-600 mt-0.5">Changed by {{ entry.updated_by or 'unknown' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if meta.type == 'bool' %}
|
||||
<form hx-post="/config/{{ key }}"
|
||||
hx-target="#cfg-{{ key|replace('.','-') }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center">
|
||||
<input type="hidden" name="value" value="{{ 'false' if value else 'true' }}">
|
||||
<button type="submit" role="switch" aria-checked="{{ 'true' if value else 'false' }}"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition
|
||||
{% if value %}bg-emerald-500{% else %}bg-slate-700{% endif %}">
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition
|
||||
{% if value %}translate-x-6{% else %}translate-x-1{% endif %}"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% elif meta.type == 'enum' %}
|
||||
<form hx-post="/config/{{ key }}"
|
||||
hx-target="#cfg-{{ key|replace('.','-') }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center gap-2">
|
||||
<select name="value" onchange="this.form.requestSubmit()"
|
||||
class="bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5">
|
||||
{% for opt in meta.options %}
|
||||
<option value="{{ opt }}" {% if opt == value %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% elif meta.type == 'int' %}
|
||||
<form hx-post="/config/{{ key }}"
|
||||
hx-target="#cfg-{{ key|replace('.','-') }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center gap-2">
|
||||
<input type="number" name="value" value="{{ value }}"
|
||||
min="{{ meta.get('min', 0) }}" max="{{ meta.get('max', 1000) }}"
|
||||
class="w-20 bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5 text-right">
|
||||
<button type="submit" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md">save</button>
|
||||
</form>
|
||||
|
||||
{% elif meta.type == 'string' or meta.type == 'csv' %}
|
||||
<form hx-post="/config/{{ key }}"
|
||||
hx-target="#cfg-{{ key|replace('.','-') }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center gap-2">
|
||||
<input type="text" name="value" value="{{ value }}"
|
||||
class="w-64 bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5 font-mono">
|
||||
<button type="submit" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md">save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if is_override %}
|
||||
<form hx-post="/config/{{ key }}/reset"
|
||||
hx-target="#cfg-{{ key|replace('.','-') }}"
|
||||
hx-swap="outerHTML">
|
||||
<button type="submit" class="text-xs text-slate-400 hover:text-rose-300 px-2"
|
||||
title="Reset to default: {{ meta.default }}">↺</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{% set kind_colors = {
|
||||
'search': 'from-sky-500 to-indigo-600',
|
||||
'llm': 'from-fuchsia-500 to-purple-600',
|
||||
'internal': 'from-emerald-500 to-teal-600'
|
||||
} %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 hover:border-slate-700 transition">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br {{ kind_colors.get(provider.kind, 'from-slate-500 to-slate-700') }} flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ provider.display_name[:2]|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white">{{ provider.display_name }}</h3>
|
||||
<p class="text-xs text-slate-400">{{ provider.kind }}{% if provider.plan_name %} · {{ provider.plan_name }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if provider.healthy %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-emerald-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span> Healthy
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-rose-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span> Down
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if provider.quota_limit %}
|
||||
<div class="space-y-1.5 mb-3">
|
||||
<div class="flex items-baseline justify-between text-xs">
|
||||
<span class="text-slate-400">
|
||||
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.2f"|format(provider.quota_used or 0) }}
|
||||
/
|
||||
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.0f"|format(provider.quota_limit) }}
|
||||
{% if provider.quota_unit != 'usd' %}{{ provider.quota_unit }}{% endif %}
|
||||
</span>
|
||||
<span class="font-mono text-slate-300">{{ provider.quota_percent_used or 0 }}%</span>
|
||||
</div>
|
||||
{% include "partials/quota_bar.html" %}
|
||||
</div>
|
||||
{% elif provider.kind == 'internal' or provider.kind == 'llm' %}
|
||||
<div class="text-xs text-slate-500 mb-3">{{ provider.plan_name or 'Self-hosted' }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500 pt-2 border-t border-slate-800">
|
||||
{% if provider.rate_limit %}<span>⚡ {{ provider.rate_limit }}</span>{% endif %}
|
||||
{% if provider.plan_price_monthly %}<span>💰 ${{ "%.0f"|format(provider.plan_price_monthly) }}/mo</span>{% endif %}
|
||||
{% if provider.message %}<span class="text-amber-400">⚠ {{ provider.message }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{% set percent = provider.quota_percent_used or 0 %}
|
||||
{% set color = 'bg-emerald-500' %}
|
||||
{% if percent >= 90 %}{% set color = 'bg-rose-500' %}
|
||||
{% elif percent >= 70 %}{% set color = 'bg-amber-500' %}
|
||||
{% elif percent >= 50 %}{% set color = 'bg-sky-500' %}
|
||||
{% endif %}
|
||||
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-2 {{ color }} transition-all" style="width: {{ percent }}%"></div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Providers — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Providers</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Live quota, billing, and health status — refreshed from each provider's API</p>
|
||||
</div>
|
||||
<button onclick="location.reload()" class="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-white rounded-lg border border-slate-700 transition">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Group by kind -->
|
||||
{% set search_providers = providers | selectattr("kind", "equalto", "search") | list %}
|
||||
{% set llm_providers = providers | selectattr("kind", "equalto", "llm") | list %}
|
||||
{% set internal_providers = providers | selectattr("kind", "equalto", "internal") | list %}
|
||||
|
||||
{% if search_providers %}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🔍 Search Providers</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for provider in search_providers %}
|
||||
{% include "partials/provider_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if llm_providers %}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🧠 LLM Providers</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for provider in llm_providers %}
|
||||
{% include "partials/provider_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if internal_providers %}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🏠 Internal Services</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for provider in internal_providers %}
|
||||
{% include "partials/provider_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Extras table -->
|
||||
<section class="mt-8">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">Raw Details</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-800">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Provider</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Plan</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Quota</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Rate Limit</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800 text-sm">
|
||||
{% for provider in providers %}
|
||||
<tr class="hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 font-medium text-white">{{ provider.display_name }}</td>
|
||||
<td class="px-4 py-3 text-slate-400">{{ provider.plan_name or '—' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">
|
||||
{% if provider.quota_limit %}
|
||||
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.2f"|format(provider.quota_used or 0) }} / {% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.0f"|format(provider.quota_limit) }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">{{ provider.rate_limit or '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if provider.healthy %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800">healthy</span>
|
||||
{% else %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-rose-900/50 text-rose-300 border border-rose-800">down</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
0
ai_platform/modules/dashboard/tests/__init__.py
Normal file
0
ai_platform/modules/dashboard/tests/__init__.py
Normal file
96
ai_platform/modules/dashboard/tests/test_catalog.py
Normal file
96
ai_platform/modules/dashboard/tests/test_catalog.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""CRUD tests for the DB-backed model/extractor catalog (Val 2).
|
||||
|
||||
Uses an in-memory SQLite async DB — no Postgres needed. Route handlers are
|
||||
called directly with an injected session + principal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from dashboard.api.routes import catalog
|
||||
from dashboard.db.models import Base
|
||||
|
||||
# Prefer a real Postgres (matches production) when TEST_DATABASE_URL is set;
|
||||
# fall back to in-memory SQLite for quick local runs.
|
||||
_DB_URL = os.environ.get("TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||
|
||||
# HTTPException is imported lazily so the module imports even without fastapi
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session():
|
||||
engine = create_async_engine(_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as s:
|
||||
yield s
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_crud_lifecycle(session):
|
||||
created = await catalog.create_catalog(
|
||||
catalog.CatalogIn(
|
||||
name="qwen3.5", service="llm", supports_gpu=True, context_length=32768,
|
||||
quantization="awq",
|
||||
),
|
||||
principal="tester",
|
||||
session=session,
|
||||
)
|
||||
assert created["name"] == "qwen3.5"
|
||||
assert created["supports_gpu"] is True
|
||||
eid = created["id"]
|
||||
|
||||
listed = await catalog.list_catalog(session=session)
|
||||
assert listed["total"] == 1
|
||||
|
||||
filtered = await catalog.list_catalog(service="embeddings", session=session)
|
||||
assert filtered["total"] == 0
|
||||
|
||||
updated = await catalog.update_catalog(
|
||||
eid, catalog.CatalogPatch(enabled=False, notes="retired"),
|
||||
principal="tester", session=session,
|
||||
)
|
||||
assert updated["enabled"] is False
|
||||
assert updated["notes"] == "retired"
|
||||
|
||||
deleted = await catalog.delete_catalog(eid, principal="tester", session=session)
|
||||
assert deleted["deleted"] == eid
|
||||
assert (await catalog.list_catalog(session=session))["total"] == 0
|
||||
|
||||
|
||||
async def test_duplicate_rejected(session):
|
||||
await catalog.create_catalog(
|
||||
catalog.CatalogIn(name="bge-m3", service="embeddings"),
|
||||
principal="t", session=session,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await catalog.create_catalog(
|
||||
catalog.CatalogIn(name="bge-m3", service="embeddings"),
|
||||
principal="t", session=session,
|
||||
)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
async def test_invalid_kind_rejected(session):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await catalog.create_catalog(
|
||||
catalog.CatalogIn(name="x", service="llm", kind="bogus"),
|
||||
principal="t", session=session,
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
async def test_update_missing_404(session):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await catalog.update_catalog(
|
||||
999, catalog.CatalogPatch(enabled=True), principal="t", session=session
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
118
ai_platform/modules/dashboard/tests/test_monitoring.py
Normal file
118
ai_platform/modules/dashboard/tests/test_monitoring.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Tests for the central AI monitoring routes (Val 1).
|
||||
|
||||
External sources (service health, RabbitMQ, Prometheus) are mocked with respx /
|
||||
monkeypatch — no real infra needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from dashboard.api.routes import monitoring
|
||||
from dashboard.config import DashboardSettings, SettingsCache
|
||||
|
||||
|
||||
def _settings(**overrides) -> DashboardSettings:
|
||||
base = {
|
||||
"database_url": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"external_url": "http://localhost:51300",
|
||||
}
|
||||
base.update(overrides)
|
||||
s = DashboardSettings(**base)
|
||||
SettingsCache.set(s)
|
||||
return s
|
||||
|
||||
|
||||
# --- services aggregation -------------------------------------------------
|
||||
|
||||
|
||||
async def test_services_summary(monkeypatch):
|
||||
_settings()
|
||||
|
||||
async def fake_probe(client, mid, url):
|
||||
status = "down" if mid == "video" else "healthy"
|
||||
return {"module": mid, "url": url, "status": status, "latency_ms": 1.0}
|
||||
|
||||
monkeypatch.setattr(monitoring, "_probe", fake_probe)
|
||||
out = await monitoring.monitoring_services()
|
||||
|
||||
assert out["summary"]["total"] == len(monitoring._service_endpoints())
|
||||
assert out["summary"]["down"] == 1
|
||||
assert out["summary"]["healthy"] == out["summary"]["total"] - 1
|
||||
assert any(s["module"] == "video" and s["status"] == "down" for s in out["services"])
|
||||
|
||||
|
||||
# --- RabbitMQ queues ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_queues_disabled_when_unset():
|
||||
_settings(rabbitmq_mgmt_url=None)
|
||||
out = await monitoring.monitoring_queues()
|
||||
assert out["enabled"] is False
|
||||
assert out["queues"] == []
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_queues_parsed_and_sorted():
|
||||
_settings(rabbitmq_mgmt_url="http://rmq:15672")
|
||||
respx.get("http://rmq:15672/api/queues").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"name": "low", "vhost": "/", "messages": 2, "messages_ready": 2,
|
||||
"messages_unacknowledged": 0, "consumers": 1, "state": "running"},
|
||||
{"name": "busy", "vhost": "/", "messages": 50, "messages_ready": 48,
|
||||
"messages_unacknowledged": 2, "consumers": 3, "state": "running"},
|
||||
],
|
||||
)
|
||||
)
|
||||
out = await monitoring.monitoring_queues()
|
||||
assert out["enabled"] is True
|
||||
# sorted by messages desc
|
||||
assert [q["name"] for q in out["queues"]] == ["busy", "low"]
|
||||
assert out["queues"][0]["unacked"] == 2
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_queues_failopen_on_error():
|
||||
_settings(rabbitmq_mgmt_url="http://rmq:15672")
|
||||
respx.get("http://rmq:15672/api/queues").mock(side_effect=httpx.ConnectError("x"))
|
||||
out = await monitoring.monitoring_queues()
|
||||
assert out["enabled"] is True
|
||||
assert out["queues"] == []
|
||||
assert "error" in out
|
||||
|
||||
|
||||
# --- Prometheus latency ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_latency_disabled_when_unset():
|
||||
_settings(prometheus_url=None)
|
||||
out = await monitoring.monitoring_latency()
|
||||
assert out["enabled"] is False
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_latency_parsed_per_job():
|
||||
_settings(prometheus_url="http://prom:9090")
|
||||
# Same response for all three quantile queries → equal p50/p90/p99.
|
||||
respx.get("http://prom:9090/api/v1/query").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"job": "llm"}, "value": [0, "0.250"]},
|
||||
{"metric": {"job": "web"}, "value": [0, "1.500"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
out = await monitoring.monitoring_latency()
|
||||
assert out["enabled"] is True
|
||||
by_job = {s["job"]: s for s in out["services"]}
|
||||
assert by_job["llm"]["p50_ms"] == 250.0 # 0.250s → 250ms
|
||||
assert by_job["web"]["p99_ms"] == 1500.0
|
||||
38
ai_platform/modules/dashboard/tests/test_rbac.py
Normal file
38
ai_platform/modules/dashboard/tests/test_rbac.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""RBAC wiring invariants (Val 2).
|
||||
|
||||
Asserts that human-facing read routers carry the auth dependency, while the
|
||||
service-to-service routers (config polling, ingest) stay open — breaking the
|
||||
latter would cut config propagation / event ingestion to the AI services.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dashboard.api.routes import (
|
||||
audit,
|
||||
catalog,
|
||||
config,
|
||||
history,
|
||||
ingest,
|
||||
monitoring,
|
||||
proxy,
|
||||
stats,
|
||||
)
|
||||
|
||||
|
||||
def test_human_read_routers_are_protected():
|
||||
for r in (
|
||||
monitoring.router,
|
||||
stats.router,
|
||||
history.router,
|
||||
audit.router,
|
||||
proxy.router,
|
||||
catalog.router,
|
||||
):
|
||||
assert len(r.dependencies) >= 1, "expected router-level auth dependency"
|
||||
|
||||
|
||||
def test_service_routers_stay_open():
|
||||
# config (polled by AI services) and ingest (service-to-service) must NOT
|
||||
# gain router-level auth — that would break the platform.
|
||||
assert len(config.router.dependencies) == 0
|
||||
assert len(ingest.router.dependencies) == 0
|
||||
13
ai_platform/modules/dashboard/web/index.html
Normal file
13
ai_platform/modules/dashboard/web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin-ai/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DIDI AI Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3215
ai_platform/modules/dashboard/web/package-lock.json
generated
Normal file
3215
ai_platform/modules/dashboard/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
ai_platform/modules/dashboard/web/package.json
Normal file
33
ai_platform/modules/dashboard/web/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "didi-ai-platform-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.1",
|
||||
"@mui/material": "^7.0.1",
|
||||
"@mui/x-data-grid": "^8.0.0",
|
||||
"@react-keycloak/web": "^3.4.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"keycloak-js": "^26.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"recharts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
79
ai_platform/modules/dashboard/web/src/App.tsx
Normal file
79
ai_platform/modules/dashboard/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Route, Routes } from 'react-router-dom';
|
||||
import AppShell from '@/layout/AppShell';
|
||||
import Overview from '@/pages/Overview';
|
||||
import Stub from '@/pages/Stub';
|
||||
import ModulePage from '@/pages/modules/ModulePage';
|
||||
import Unauthorized from '@/pages/Unauthorized';
|
||||
import ProtectedRoute from '@/auth/ProtectedRoute';
|
||||
import BrainAtoms from '@/pages/brain/Atoms';
|
||||
import BrainCache from '@/pages/brain/Cache';
|
||||
import BrainStats from '@/pages/brain/Stats';
|
||||
import BrainTaxonomy from '@/pages/brain/Taxonomy';
|
||||
import BrainFacts from '@/pages/brain/Facts';
|
||||
import BrainInvalidate from '@/pages/brain/Invalidate';
|
||||
import History from '@/pages/insights/History';
|
||||
import Cost from '@/pages/insights/Cost';
|
||||
import Providers from '@/pages/insights/Providers';
|
||||
import Archive from '@/pages/insights/Archive';
|
||||
import AuditLog from '@/pages/system/AuditLog';
|
||||
import Catalog from '@/pages/system/Catalog';
|
||||
import Settings from '@/pages/system/Settings';
|
||||
import LiveStatus from '@/pages/operations/Live';
|
||||
import Monitoring from '@/pages/operations/Monitoring';
|
||||
import SchemaOverrides from '@/pages/system/SchemaOverrides';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Overview />} />
|
||||
|
||||
{/* Operations */}
|
||||
<Route path="operations/live" element={<LiveStatus />} />
|
||||
<Route path="operations/monitoring" element={<Monitoring />} />
|
||||
|
||||
{/* Modules — single generic page driven by moduleId */}
|
||||
<Route path="modules/web" element={<ModulePage moduleId="web" />} />
|
||||
<Route path="modules/llm" element={<ModulePage moduleId="llm" />} />
|
||||
<Route path="modules/embeddings" element={<ModulePage moduleId="embeddings" />} />
|
||||
<Route path="modules/rerank" element={<ModulePage moduleId="rerank" />} />
|
||||
<Route path="modules/audio" element={<ModulePage moduleId="audio" />} />
|
||||
<Route path="modules/video" element={<ModulePage moduleId="video" />} />
|
||||
<Route path="modules/catalog" element={<ModulePage moduleId="catalog" />} />
|
||||
<Route path="modules/gateway" element={<ModulePage moduleId="gateway" />} />
|
||||
|
||||
{/* Brain Admin */}
|
||||
<Route path="brain/atoms" element={<BrainAtoms />} />
|
||||
<Route path="brain/cache" element={<BrainCache />} />
|
||||
<Route path="brain/facts" element={<BrainFacts />} />
|
||||
<Route path="brain/invalidate" element={<BrainInvalidate />} />
|
||||
<Route path="brain/stats" element={<BrainStats />} />
|
||||
<Route path="brain/taxonomy" element={<BrainTaxonomy />} />
|
||||
<Route path="brain/settings" element={<ModulePage moduleId="brain" />} />
|
||||
|
||||
{/* Insights */}
|
||||
<Route path="insights/history" element={<History />} />
|
||||
<Route path="insights/cost" element={<Cost />} />
|
||||
<Route path="insights/providers" element={<Providers />} />
|
||||
<Route path="insights/archive" element={<Archive />} />
|
||||
|
||||
{/* System */}
|
||||
<Route path="system/catalog" element={<Catalog />} />
|
||||
<Route path="system/audit" element={<AuditLog />} />
|
||||
<Route path="system/settings" element={<Settings />} />
|
||||
<Route path="system/schema" element={<SchemaOverrides />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Stub title="Not found" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
100
ai_platform/modules/dashboard/web/src/api/client.ts
Normal file
100
ai_platform/modules/dashboard/web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Thin fetch wrapper. All API calls go to the same FastAPI on /api/*.
|
||||
// Auth source priority:
|
||||
// 1. Keycloak token (if available via window-level singleton)
|
||||
// 2. localStorage 'didi.ai_platform.bearer' (legacy/dev)
|
||||
//
|
||||
// API base path: when SPA is served at /admin-ai/, FastAPI also exposes
|
||||
// the JSON API at /admin-ai/api/*. We use Vite's BASE_URL (which == the
|
||||
// SPA's mount path) so the fetch URL is always sibling to the SPA root,
|
||||
// regardless of whether the user reached it directly or through the
|
||||
// reverse-proxy on admin-dashboard nginx.
|
||||
|
||||
import { keycloak } from '@/auth/keycloak';
|
||||
|
||||
const TOKEN_LS_KEY = 'didi.ai_platform.bearer';
|
||||
|
||||
// Vite injects this at build time. e.g. '/admin-ai/'. Strip trailing slash
|
||||
// so we can prepend cleanly.
|
||||
const BASE_PATH = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
// Absolute URLs (http://...) pass through unchanged.
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
// Already prefixed with BASE_PATH? leave as-is.
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH + '/')) return path;
|
||||
// Otherwise, prepend BASE_PATH.
|
||||
return `${BASE_PATH}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
export function getBearerToken(): string | null {
|
||||
// Keycloak instance is the source of truth when initialized
|
||||
if (keycloak.token) return keycloak.token;
|
||||
return localStorage.getItem(TOKEN_LS_KEY);
|
||||
}
|
||||
|
||||
export function setBearerToken(token: string | null): void {
|
||||
if (token === null) {
|
||||
localStorage.removeItem(TOKEN_LS_KEY);
|
||||
} else {
|
||||
localStorage.setItem(TOKEN_LS_KEY, token);
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||
const token = getBearerToken();
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...((opts.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
|
||||
const res = await fetch(resolveUrl(path), { ...opts, headers, body });
|
||||
if (!res.ok) {
|
||||
let detail: string;
|
||||
try {
|
||||
const j = await res.json();
|
||||
detail = j.detail ?? JSON.stringify(j);
|
||||
} catch {
|
||||
detail = await res.text();
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Notify auth context — it'll trigger logout/re-login
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
}
|
||||
const err = new Error(`${res.status} ${res.statusText}: ${detail}`);
|
||||
(err as Error & { status: number }).status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) return (await res.json()) as T;
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Convenience helpers
|
||||
export const apiGet = <T>(path: string) => api<T>(path, { method: 'GET' });
|
||||
export const apiPut = <T>(path: string, body: unknown) => api<T>(path, { method: 'PUT', body });
|
||||
export const apiPost = <T>(path: string, body?: unknown) =>
|
||||
api<T>(path, { method: 'POST', body });
|
||||
export const apiPatch = <T>(path: string, body: unknown) =>
|
||||
api<T>(path, { method: 'PATCH', body });
|
||||
export const apiDelete = <T>(path: string) => api<T>(path, { method: 'DELETE' });
|
||||
|
||||
// Brain admin client — lives at didibrain-api:8090 BUT the dashboard FastAPI
|
||||
// proxies /api/brain/*, so we just use api*.
|
||||
export const BRAIN_BASE = (() => {
|
||||
return (
|
||||
(import.meta.env.VITE_BRAIN_URL as string | undefined) ??
|
||||
'http://localhost:8090'
|
||||
);
|
||||
})();
|
||||
168
ai_platform/modules/dashboard/web/src/auth/AuthContext.tsx
Normal file
168
ai_platform/modules/dashboard/web/src/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Box, CircularProgress, Stack, Typography } from '@mui/material';
|
||||
|
||||
import { keycloak, REQUIRED_ROLE, STAGING_MODE } from '@/auth/keycloak';
|
||||
|
||||
interface KeycloakUser {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
preferred_username?: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
initialized: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: KeycloakUser | null;
|
||||
token: string | undefined;
|
||||
hasRole: (role: string) => boolean;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
stagingMode: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const TOKEN_LS_KEY = 'didi.ai_platform.bearer';
|
||||
|
||||
interface ProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: ProviderProps) {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_LS_KEY);
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
}
|
||||
if (keycloak.authenticated) {
|
||||
keycloak.logout();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (STAGING_MODE) {
|
||||
console.warn(
|
||||
'[Auth] STAGING_MODE — Keycloak bypassed. DO NOT use in production.',
|
||||
);
|
||||
setAuthenticated(true);
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const ok = await keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256',
|
||||
});
|
||||
if (cancelled) return;
|
||||
setAuthenticated(ok);
|
||||
if (ok && keycloak.token) {
|
||||
localStorage.setItem(TOKEN_LS_KEY, keycloak.token);
|
||||
// Refresh every 30s if token expires within 70s
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
keycloak
|
||||
.updateToken(70)
|
||||
.then((refreshed) => {
|
||||
if (refreshed && keycloak.token) {
|
||||
localStorage.setItem(TOKEN_LS_KEY, keycloak.token);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn('[Auth] token refresh failed → re-login');
|
||||
keycloak.login();
|
||||
});
|
||||
}, 30_000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Auth] init failed', e);
|
||||
if (!cancelled) setAuthenticated(false);
|
||||
} finally {
|
||||
if (!cancelled) setInitialized(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (refreshIntervalRef.current) clearInterval(refreshIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// React to 401 events from api/client.ts
|
||||
useEffect(() => {
|
||||
if (STAGING_MODE) return;
|
||||
const handler = () => {
|
||||
console.log('[Auth] auth:unauthorized → logout');
|
||||
logout();
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', handler);
|
||||
return () => window.removeEventListener('auth:unauthorized', handler);
|
||||
}, [logout]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
initialized,
|
||||
isAuthenticated: authenticated,
|
||||
user: (keycloak.tokenParsed as KeycloakUser | undefined) ?? null,
|
||||
token: keycloak.token,
|
||||
hasRole: (role: string) => {
|
||||
if (STAGING_MODE) return true;
|
||||
return (
|
||||
keycloak.hasRealmRole(role) ||
|
||||
keycloak.hasResourceRole(role) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
login: () => keycloak.login(),
|
||||
logout,
|
||||
stagingMode: STAGING_MODE,
|
||||
};
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading authentication…
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export { REQUIRED_ROLE };
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
|
||||
import { useAuth, REQUIRED_ROLE } from '@/auth/AuthContext';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
requiredRole?: string;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children, requiredRole }: Props) {
|
||||
const { initialized, isAuthenticated, hasRole } = useAuth();
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
minHeight: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Keycloak with onLoad=login-required already redirects, so this branch
|
||||
// is mostly for staging mode + double-safety.
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const role = requiredRole ?? REQUIRED_ROLE;
|
||||
if (role && !hasRole(role)) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
17
ai_platform/modules/dashboard/web/src/auth/keycloak.ts
Normal file
17
ai_platform/modules/dashboard/web/src/auth/keycloak.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Keycloak from 'keycloak-js';
|
||||
|
||||
// Single shared Keycloak instance (module singleton).
|
||||
// Configured via Vite envs (VITE_KEYCLOAK_URL etc.) or same-origin /auth.
|
||||
const url = import.meta.env.VITE_KEYCLOAK_URL ?? '/auth';
|
||||
const realm = import.meta.env.VITE_KEYCLOAK_REALM ?? 'didi-clients';
|
||||
const clientId =
|
||||
import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ai-platform-dashboard';
|
||||
|
||||
export const STAGING_MODE =
|
||||
import.meta.env.VITE_STAGING_MODE === 'true' ||
|
||||
import.meta.env.VITE_STAGING_MODE === '1';
|
||||
|
||||
export const REQUIRED_ROLE =
|
||||
import.meta.env.VITE_KEYCLOAK_REQUIRED_ROLE ?? 'admin';
|
||||
|
||||
export const keycloak = new Keycloak({ url, realm, clientId });
|
||||
243
ai_platform/modules/dashboard/web/src/components/ConfigField.tsx
Normal file
243
ai_platform/modules/dashboard/web/src/components/ConfigField.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
|
||||
import type { ConfigItem, ConfigSchema } from '@/types/config';
|
||||
|
||||
interface Props {
|
||||
configKey: string;
|
||||
meta: ConfigSchema;
|
||||
item: ConfigItem;
|
||||
onSave: (value: unknown) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
function csvToArray(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.map(String);
|
||||
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function ConfigField({ configKey, meta, item, onSave, onReset, saving }: Props) {
|
||||
const [draft, setDraft] = useState<unknown>(item.value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-sync if upstream value changes (e.g., after refetch)
|
||||
useEffect(() => {
|
||||
setDraft(item.value);
|
||||
}, [item.value]);
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(item.value);
|
||||
|
||||
const validate = (v: unknown): string | null => {
|
||||
if (meta.type === 'int' || meta.type === 'float') {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return 'Not a number';
|
||||
if (meta.min !== undefined && n < meta.min) return `Min ${meta.min}`;
|
||||
if (meta.max !== undefined && n > meta.max) return `Max ${meta.max}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const err = validate(draft);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
let value = draft;
|
||||
if (meta.type === 'int') value = Number(draft);
|
||||
if (meta.type === 'float') value = Number(draft);
|
||||
if (meta.type === 'csv') value = csvToArray(draft).join(',');
|
||||
await onSave(value);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
switch (meta.type) {
|
||||
case 'bool':
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(draft)}
|
||||
onChange={(e) => setDraft(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
);
|
||||
case 'enum':
|
||||
return (
|
||||
<Select
|
||||
value={String(draft ?? '')}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 200 }}
|
||||
disabled={saving}
|
||||
>
|
||||
{(meta.options ?? []).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
case 'int':
|
||||
case 'float':
|
||||
return (
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
value={draft ?? ''}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
inputProps={{
|
||||
min: meta.min,
|
||||
max: meta.max,
|
||||
step: meta.type === 'float' ? 0.01 : 1,
|
||||
}}
|
||||
sx={{ width: 160 }}
|
||||
disabled={saving}
|
||||
error={Boolean(error)}
|
||||
helperText={error ?? ''}
|
||||
/>
|
||||
);
|
||||
case 'csv':
|
||||
return (
|
||||
<TextField
|
||||
size="small"
|
||||
value={Array.isArray(draft) ? draft.join(',') : String(draft ?? '')}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="comma,separated,values"
|
||||
sx={{ minWidth: 320 }}
|
||||
disabled={saving}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={4}
|
||||
maxRows={20}
|
||||
size="small"
|
||||
value={String(draft ?? '')}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
sx={{ minWidth: 480, width: '100%' }}
|
||||
disabled={saving}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
default:
|
||||
return (
|
||||
<TextField
|
||||
size="small"
|
||||
value={String(draft ?? '')}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
sx={{ minWidth: 320 }}
|
||||
disabled={saving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isMultiline = meta.type === 'text';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMultiline ? 'column' : { xs: 'column', md: 'row' },
|
||||
gap: 2,
|
||||
alignItems: isMultiline ? 'stretch' : { xs: 'flex-start', md: 'center' },
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: '0 0 320px', minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{meta.label}
|
||||
</Typography>
|
||||
{meta.restart_required && (
|
||||
<Tooltip title="Restart required for this change to take effect">
|
||||
<Chip
|
||||
size="small"
|
||||
label="restart"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
icon={<RestartAltIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ height: 20, '& .MuiChip-label': { fontSize: 10, px: 0.75 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{item.is_override && (
|
||||
<Tooltip title={`Default: ${JSON.stringify(meta.default)}`}>
|
||||
<Chip
|
||||
size="small"
|
||||
label="override"
|
||||
color="primary"
|
||||
sx={{ height: 20, '& .MuiChip-label': { fontSize: 10, px: 0.75 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
|
||||
{meta.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontFamily: 'monospace', color: '#666' }}>
|
||||
{configKey}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{renderInput()}
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={0.5} sx={{ flex: '0 0 auto' }}>
|
||||
<Tooltip title={dirty ? 'Save changes' : 'No changes'}>
|
||||
<span>
|
||||
<IconButton size="small" color="primary" onClick={handleSave} disabled={!dirty || saving}>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={item.is_override ? 'Reset to default' : 'Already at default'}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
disabled={!item.is_override || saving}
|
||||
color="warning"
|
||||
>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{error && !['int', 'float'].includes(meta.type) && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', color: 'error.main' }}>
|
||||
<WarningAmberIcon fontSize="small" />
|
||||
<Typography variant="caption">{error}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
36
ai_platform/modules/dashboard/web/src/components/KpiTile.tsx
Normal file
36
ai_platform/modules/dashboard/web/src/components/KpiTile.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Card, CardContent, Typography } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Shared KPI tile (previously duplicated locally in Live/Overview). Use for
|
||||
* consistent metric tiles across pages.
|
||||
*/
|
||||
export default function KpiTile({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
color?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h2" sx={{ mt: 0.5, color: color ?? 'primary.main' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import ConfigField from '@/components/ConfigField';
|
||||
import { apiDelete, apiGet, apiPut } from '@/api/client';
|
||||
import type { ConfigItem, ConfigResponse, ConfigSchema } from '@/types/config';
|
||||
|
||||
interface Props {
|
||||
/** Module name to filter on (matches `module` field in schema). */
|
||||
moduleId: string;
|
||||
/**
|
||||
* Optional category sort order. Categories not listed here go alphabetically
|
||||
* after the configured ones.
|
||||
*/
|
||||
categoryOrder?: string[];
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
items: Array<{ key: string; meta: ConfigSchema; item: ConfigItem }>;
|
||||
}
|
||||
|
||||
export default function ModuleConfigForm({ moduleId, categoryOrder }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiGet<ConfigResponse>('/api/config'),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
|
||||
apiPut<{ key: string; value: unknown }>(`/api/config/${encodeURIComponent(key)}`, {
|
||||
value,
|
||||
}),
|
||||
onSuccess: (_d, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setFeedback({ kind: 'ok', msg: `Saved ${vars.key}` });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
|
||||
throw e;
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (key: string) => apiDelete(`/api/config/${encodeURIComponent(key)}`),
|
||||
onSuccess: (_d, key) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setFeedback({ kind: 'ok', msg: `Reverted ${key} to default` });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
|
||||
},
|
||||
});
|
||||
|
||||
const groups: CategoryGroup[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const filtered = Object.entries(data.schema)
|
||||
.filter(([_, meta]) => meta.module === moduleId)
|
||||
.map(([key, meta]) => ({
|
||||
key,
|
||||
meta,
|
||||
item: data.items[key],
|
||||
}));
|
||||
|
||||
const byCategory = new Map<string, typeof filtered>();
|
||||
for (const f of filtered) {
|
||||
const cat = f.meta.category;
|
||||
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
||||
byCategory.get(cat)!.push(f);
|
||||
}
|
||||
|
||||
const ordered: CategoryGroup[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (categoryOrder) {
|
||||
for (const cat of categoryOrder) {
|
||||
if (byCategory.has(cat)) {
|
||||
ordered.push({ category: cat, items: byCategory.get(cat)! });
|
||||
seen.add(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cat of [...byCategory.keys()].sort()) {
|
||||
if (!seen.has(cat)) {
|
||||
ordered.push({ category: cat, items: byCategory.get(cat)! });
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}, [data, moduleId, categoryOrder]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <Alert severity="error">Failed to load config: {String(error)}</Alert>;
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
No config keys defined for module "{moduleId}". (Either the module has no
|
||||
editable knobs, or schema isn't loaded yet.)
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const totalKeys = groups.reduce((n, g) => n + g.items.length, 0);
|
||||
const totalOverrides = groups.reduce(
|
||||
(n, g) => n + g.items.filter((i) => i.item.is_override).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<Chip label={`${totalKeys} keys`} size="small" />
|
||||
<Chip
|
||||
label={`${totalOverrides} overrides`}
|
||||
color={totalOverrides > 0 ? 'primary' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{feedback && (
|
||||
<Alert
|
||||
severity={feedback.kind === 'ok' ? 'success' : 'error'}
|
||||
onClose={() => setFeedback(null)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{feedback.msg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<Accordion key={group.category} defaultExpanded sx={{ mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h3" sx={{ textTransform: 'capitalize' }}>
|
||||
{group.category.replace(/_/g, ' ')}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${group.items.length}`}
|
||||
sx={{ ml: 2, height: 20, '& .MuiChip-label': { fontSize: 10 } }}
|
||||
/>
|
||||
{group.items.some((i) => i.item.is_override) && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="primary"
|
||||
label="has overrides"
|
||||
sx={{
|
||||
ml: 1,
|
||||
height: 20,
|
||||
'& .MuiChip-label': { fontSize: 10 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
{group.items.map(({ key, meta, item }) => (
|
||||
<ConfigField
|
||||
key={key}
|
||||
configKey={key}
|
||||
meta={meta}
|
||||
item={item}
|
||||
saving={saveMutation.isPending || resetMutation.isPending}
|
||||
onSave={async (value) => {
|
||||
await saveMutation.mutateAsync({ key, value });
|
||||
}}
|
||||
onReset={async () => {
|
||||
await resetMutation.mutateAsync(key);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Chip } from '@mui/material';
|
||||
import type { ChipProps } from '@mui/material';
|
||||
|
||||
/**
|
||||
* Shared health-status chip. Centralizes the green/amber/red mapping that was
|
||||
* previously copy-pasted across pages (Live, ModulePage, Atoms) — single source
|
||||
* of truth for status colors and labels.
|
||||
*/
|
||||
export type HealthStatus = 'healthy' | 'degraded' | 'down' | 'unknown' | string;
|
||||
|
||||
const COLOR: Record<string, ChipProps['color']> = {
|
||||
healthy: 'success',
|
||||
degraded: 'warning',
|
||||
down: 'error',
|
||||
unknown: 'default',
|
||||
};
|
||||
|
||||
export default function StatusChip({
|
||||
status,
|
||||
size = 'small',
|
||||
label,
|
||||
}: {
|
||||
status: HealthStatus;
|
||||
size?: ChipProps['size'];
|
||||
label?: string;
|
||||
}) {
|
||||
const color = COLOR[status] ?? 'default';
|
||||
return <Chip label={label ?? status} color={color} size={size} />;
|
||||
}
|
||||
307
ai_platform/modules/dashboard/web/src/layout/AppShell.tsx
Normal file
307
ai_platform/modules/dashboard/web/src/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { useState } from 'react';
|
||||
import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
CssBaseline,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import SortIcon from '@mui/icons-material/Sort';
|
||||
import VideoFileIcon from '@mui/icons-material/VideoFile';
|
||||
import HubIcon from '@mui/icons-material/Hub';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import InsightsIcon from '@mui/icons-material/Insights';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import PaidIcon from '@mui/icons-material/Paid';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import ListAltIcon from '@mui/icons-material/ListAlt';
|
||||
import FactCheckIcon from '@mui/icons-material/FactCheck';
|
||||
import SchemaIcon from '@mui/icons-material/Schema';
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||
|
||||
const DRAWER_WIDTH = 260;
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: 'Operations',
|
||||
items: [
|
||||
{ label: 'Overview', to: '/', icon: <DashboardIcon fontSize="small" /> },
|
||||
{ label: 'AI Monitoring', to: '/operations/monitoring', icon: <HealthAndSafetyIcon fontSize="small" /> },
|
||||
{ label: 'Live Status', to: '/operations/live', icon: <MonitorHeartIcon fontSize="small" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Modules',
|
||||
items: [
|
||||
{ label: 'Web Search', to: '/modules/web', icon: <LanguageIcon fontSize="small" /> },
|
||||
{ label: 'LLM Inference', to: '/modules/llm', icon: <PsychologyIcon fontSize="small" /> },
|
||||
{ label: 'Embeddings', to: '/modules/embeddings', icon: <GraphicEqIcon fontSize="small" /> },
|
||||
{ label: 'Rerank', to: '/modules/rerank', icon: <SortIcon fontSize="small" /> },
|
||||
{ label: 'Audio', to: '/modules/audio', icon: <RecordVoiceOverIcon fontSize="small" /> },
|
||||
{ label: 'Video Analysis', to: '/modules/video', icon: <VideoFileIcon fontSize="small" /> },
|
||||
{ label: 'Catalog', to: '/modules/catalog', icon: <HubIcon fontSize="small" /> },
|
||||
{ label: 'Gateway', to: '/modules/gateway', icon: <LockIcon fontSize="small" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Brain Admin',
|
||||
items: [
|
||||
{ label: 'Atoms', to: '/brain/atoms', icon: <MemoryIcon fontSize="small" /> },
|
||||
{ label: 'Verification Cache', to: '/brain/cache', icon: <StorageIcon fontSize="small" /> },
|
||||
{ label: 'Fact Status', to: '/brain/facts', icon: <FactCheckIcon fontSize="small" /> },
|
||||
{ label: 'Invalidate', to: '/brain/invalidate', icon: <DeleteSweepIcon fontSize="small" /> },
|
||||
{ label: 'Stats', to: '/brain/stats', icon: <InsightsIcon fontSize="small" /> },
|
||||
{ label: 'Taxonomy', to: '/brain/taxonomy', icon: <ListAltIcon fontSize="small" /> },
|
||||
{ label: 'Settings', to: '/brain/settings', icon: <SettingsIcon fontSize="small" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Insights',
|
||||
items: [
|
||||
{ label: 'History', to: '/insights/history', icon: <HistoryIcon fontSize="small" /> },
|
||||
{ label: 'Cost', to: '/insights/cost', icon: <PaidIcon fontSize="small" /> },
|
||||
{ label: 'Providers', to: '/insights/providers', icon: <HubIcon fontSize="small" /> },
|
||||
{ label: 'Archive', to: '/insights/archive', icon: <ArchiveIcon fontSize="small" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ label: 'Catalog', to: '/system/catalog', icon: <HubIcon fontSize="small" /> },
|
||||
{ label: 'Audit Log', to: '/system/audit', icon: <ListAltIcon fontSize="small" /> },
|
||||
{ label: 'Schema Overrides', to: '/system/schema', icon: <SchemaIcon fontSize="small" /> },
|
||||
{ label: 'Settings', to: '/system/settings', icon: <SettingsIcon fontSize="small" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AppShell() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [userMenuAnchor, setUserMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const location = useLocation();
|
||||
const { user, logout, stagingMode } = useAuth();
|
||||
const userEmail = user?.email ?? user?.preferred_username ?? '—';
|
||||
const userInitial = (userEmail[0] ?? '?').toUpperCase();
|
||||
|
||||
const drawer = (
|
||||
<Box>
|
||||
<Toolbar sx={{ px: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', py: 1 }}>
|
||||
<Typography variant="h2" sx={{ fontWeight: 700, lineHeight: 1 }}>
|
||||
DIDI
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
AI Platform Admin
|
||||
</Typography>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List sx={{ pt: 0 }} dense>
|
||||
{SECTIONS.map((section) => (
|
||||
<Box key={section.title}>
|
||||
<ListSubheader
|
||||
sx={{
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.secondary',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 2,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</ListSubheader>
|
||||
{section.items.map((item) => {
|
||||
const selected =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to);
|
||||
return (
|
||||
<ListItem key={item.to} disablePadding>
|
||||
<ListItemButton
|
||||
component={RouterLink}
|
||||
to={item.to}
|
||||
selected={selected}
|
||||
sx={{
|
||||
mx: 1,
|
||||
borderRadius: 1.5,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'common.white',
|
||||
'& .MuiListItemIcon-root': { color: 'common.white' },
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: 'text.secondary' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{ fontSize: 13 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<CssBaseline />
|
||||
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { sm: `${DRAWER_WIDTH}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h3" sx={{ flexGrow: 1, fontWeight: 500 }}>
|
||||
AI Platform
|
||||
</Typography>
|
||||
|
||||
{stagingMode && (
|
||||
<Chip
|
||||
label="STAGING"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ mr: 2, fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title={userEmail}>
|
||||
<IconButton
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'primary.main', fontSize: 14 }}>
|
||||
{userInitial}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={userMenuAnchor}
|
||||
open={Boolean(userMenuAnchor)}
|
||||
onClose={() => setUserMenuAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1, minWidth: 220 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Signed in as
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{userEmail}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserMenuAnchor(null);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<LogoutIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
Log out
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { sm: DRAWER_WIDTH }, flexShrink: { sm: 0 } }}
|
||||
>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': { width: DRAWER_WIDTH },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box' },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
ai_platform/modules/dashboard/web/src/main.tsx
Normal file
35
ai_platform/modules/dashboard/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
|
||||
import App from '@/App';
|
||||
import { theme } from '@/theme';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/admin-ai">
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
230
ai_platform/modules/dashboard/web/src/pages/Overview.tsx
Normal file
230
ai_platform/modules/dashboard/web/src/pages/Overview.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { Box, Card, CardContent, Chip, CircularProgress, Grid, Typography } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from '@/api/client';
|
||||
|
||||
interface SummaryResponse {
|
||||
window_hours: number;
|
||||
total_requests: number;
|
||||
total_errors: number;
|
||||
error_rate: number;
|
||||
avg_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
by_tier?: Record<string, number>;
|
||||
by_provider?: Record<string, number>;
|
||||
by_endpoint?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ConfigResponse {
|
||||
items: Record<string, { value: unknown; default: unknown; is_override: boolean }>;
|
||||
schema: Record<string, { module: string; restart_required: boolean }>;
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
color = 'primary.main',
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h2" sx={{ mt: 0.5, color }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const summary = useQuery({
|
||||
queryKey: ['summary', 24],
|
||||
queryFn: () => apiGet<SummaryResponse>('/api/stats/summary?hours=24'),
|
||||
});
|
||||
|
||||
const config = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiGet<ConfigResponse>('/api/config'),
|
||||
});
|
||||
|
||||
const totalKeys = config.data ? Object.keys(config.data.items).length : 0;
|
||||
const overrides = config.data
|
||||
? Object.values(config.data.items).filter((i) => i.is_override).length
|
||||
: 0;
|
||||
const moduleCount = config.data
|
||||
? new Set(Object.values(config.data.schema).map((s) => s.module)).size
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h1" sx={{ mb: 1 }}>
|
||||
Operations Overview
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Last 24h activity across the AI platform.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Requests (24h)"
|
||||
value={
|
||||
summary.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
(summary.data?.total_requests ?? 0).toLocaleString()
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Error rate"
|
||||
value={
|
||||
summary.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : summary.data?.error_rate !== undefined ? (
|
||||
`${(summary.data.error_rate * 100).toFixed(1)}%`
|
||||
) : (
|
||||
'n/a'
|
||||
)
|
||||
}
|
||||
color={
|
||||
(summary.data?.error_rate ?? 0) > 0.05 ? 'error.main' : 'success.main'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Avg duration"
|
||||
value={
|
||||
summary.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : summary.data?.avg_duration_ms !== undefined ? (
|
||||
`${Math.round(summary.data.avg_duration_ms)} ms`
|
||||
) : (
|
||||
'n/a'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Total cost"
|
||||
value={
|
||||
summary.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
`$${(summary.data?.total_cost_usd ?? 0).toFixed(4)}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h2" sx={{ mt: 4, mb: 2 }}>
|
||||
Configuration surface
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Live keys"
|
||||
value={config.isLoading ? <CircularProgress size={20} /> : totalKeys}
|
||||
hint={`${moduleCount} modules covered`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatTile
|
||||
label="Active overrides"
|
||||
value={config.isLoading ? <CircularProgress size={20} /> : overrides}
|
||||
hint={overrides ? 'editable from Modules' : 'all defaults'}
|
||||
color={overrides ? 'warning.main' : 'text.primary'}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Quick links
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label="Live Status"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/operations/live"
|
||||
/>
|
||||
<Chip
|
||||
label="History"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/insights/history"
|
||||
/>
|
||||
<Chip
|
||||
label="Cost"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/insights/cost"
|
||||
/>
|
||||
<Chip
|
||||
label="Providers"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/insights/providers"
|
||||
/>
|
||||
<Chip
|
||||
label="Brain Atoms"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/brain/atoms"
|
||||
/>
|
||||
<Chip
|
||||
label="Audit Log"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/system/audit"
|
||||
/>
|
||||
<Chip
|
||||
label="Schema Overrides"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
component="a"
|
||||
href="/admin-ai/system/schema"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
|
||||
Quick navigation to common operator screens.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
28
ai_platform/modules/dashboard/web/src/pages/Stub.tsx
Normal file
28
ai_platform/modules/dashboard/web/src/pages/Stub.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Box, Card, CardContent, Chip, Typography } from '@mui/material';
|
||||
import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
|
||||
export default function Stub({ title }: { title: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h1" sx={{ mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ConstructionIcon color="warning" />
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ mb: 0.5 }}>
|
||||
Coming soon
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
This page will be wired up in a later phase.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label="C.5–C.7" size="small" color="primary" sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
47
ai_platform/modules/dashboard/web/src/pages/Unauthorized.tsx
Normal file
47
ai_platform/modules/dashboard/web/src/pages/Unauthorized.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Box, Button, Paper, Stack, Typography } from '@mui/material';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
|
||||
import { REQUIRED_ROLE, useAuth } from '@/auth/AuthContext';
|
||||
|
||||
export default function Unauthorized() {
|
||||
const { user, logout } = useAuth();
|
||||
const email = user?.email ?? user?.preferred_username ?? 'unknown';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Paper sx={{ p: 6, maxWidth: 540, textAlign: 'center' }} elevation={3}>
|
||||
<BlockIcon sx={{ fontSize: 72, color: 'error.main', mb: 2 }} />
|
||||
<Typography variant="h2" gutterBottom>
|
||||
403 — Access Denied
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Your account does not have permission to access the AI Platform admin
|
||||
dashboard.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Logged in as <strong>{email}</strong>. Required realm role:{' '}
|
||||
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>
|
||||
{REQUIRED_ROLE}
|
||||
</Box>
|
||||
.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} justifyContent="center">
|
||||
<Button variant="outlined" onClick={() => (window.location.href = '/')}>
|
||||
Go to public site
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={() => logout()}>
|
||||
Log out
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
443
ai_platform/modules/dashboard/web/src/pages/brain/Atoms.tsx
Normal file
443
ai_platform/modules/dashboard/web/src/pages/brain/Atoms.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StarsIcon from '@mui/icons-material/Stars';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiDelete, apiGet, apiPatch } from '@/api/client';
|
||||
import type { AtomDetail, AtomListItem, AtomListResponse } from '@/types/brain';
|
||||
|
||||
const TIER_COLOR: Record<string, 'success' | 'default' | 'warning'> = {
|
||||
gold: 'success',
|
||||
silver: 'default',
|
||||
bronze: 'warning',
|
||||
};
|
||||
|
||||
function fmtDate(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
function AtomDetailDrawer({
|
||||
atomId,
|
||||
onClose,
|
||||
}: {
|
||||
atomId: number | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const open = atomId !== null;
|
||||
const queryClient = useQueryClient();
|
||||
const [forceGoldOpen, setForceGoldOpen] = useState(false);
|
||||
const [correctionsJson, setCorrectionsJson] = useState('{}');
|
||||
const [initialJson, setInitialJson] = useState('{}');
|
||||
const [correctionsErr, setCorrectionsErr] = useState<string | null>(null);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['brain', 'atom', atomId],
|
||||
queryFn: () => apiGet<AtomDetail>(`/api/brain/v1/analysis_atom/${atomId}`),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const expireMut = useMutation({
|
||||
mutationFn: () => apiDelete<{ ok: boolean }>(`/api/brain/v1/analysis_atom/${atomId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'atoms'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'stats'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
|
||||
>
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h2">Atom #{atomId}</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{detail.isLoading && <CircularProgress size={24} />}
|
||||
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
|
||||
|
||||
{detail.data && (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip label={detail.data.component} size="small" />
|
||||
<Chip
|
||||
label={detail.data.cache_tier}
|
||||
size="small"
|
||||
color={TIER_COLOR[detail.data.cache_tier] ?? 'default'}
|
||||
/>
|
||||
<Chip label={`tier: ${detail.data.tier}`} size="small" variant="outlined" />
|
||||
<Chip
|
||||
label={`hits: ${detail.data.hit_count}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{detail.data.human_validated && (
|
||||
<Chip label="human validated" size="small" color="success" />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Content preview
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{detail.data.content_preview ?? '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Hashes
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
content_hash: {detail.data.content_hash}
|
||||
<br />
|
||||
prompt_hash: {detail.data.prompt_hash}
|
||||
<br />
|
||||
framework_version: {detail.data.framework_version ?? '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Timestamps
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
created: {fmtDate(detail.data.created_at)}
|
||||
<br />
|
||||
updated: {fmtDate(detail.data.updated_at)}
|
||||
<br />
|
||||
expires: {fmtDate(detail.data.expires_at)}
|
||||
<br />
|
||||
last hit: {fmtDate(detail.data.last_hit_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Result (processed)
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(detail.data.result_processed, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{detail.data.result_raw && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Result (raw LLM)
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(detail.data.result_raw, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ pt: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<StarsIcon />}
|
||||
disabled={detail.data.cache_tier === 'gold'}
|
||||
onClick={() => {
|
||||
const initial = JSON.stringify(detail.data!.result_processed, null, 2);
|
||||
setCorrectionsJson(initial);
|
||||
setInitialJson(initial);
|
||||
setCorrectionsErr(null);
|
||||
setForceGoldOpen(true);
|
||||
}}
|
||||
>
|
||||
{detail.data.cache_tier === 'gold' ? 'Already gold' : 'Force gold'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => {
|
||||
if (confirm(`Mark atom #${atomId} as expired?`)) {
|
||||
expireMut.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={expireMut.isPending}
|
||||
>
|
||||
Mark expired
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={forceGoldOpen}
|
||||
onClose={() => setForceGoldOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
>
|
||||
<DialogTitle>Force atom #{atomId} to gold</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Promotes this silver atom to <strong>gold</strong> (human_validated=true,
|
||||
expires_at=NULL). Leave the JSON unchanged to <em>approve as-is</em> (LLM result
|
||||
kept, no corrections recorded). Edit it to apply corrections — the diff is
|
||||
stored in <code>human_corrections</code>.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={12}
|
||||
fullWidth
|
||||
value={correctionsJson}
|
||||
onChange={(e) => setCorrectionsJson(e.target.value)}
|
||||
error={Boolean(correctionsErr)}
|
||||
helperText={correctionsErr ?? 'Must be valid JSON'}
|
||||
sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setForceGoldOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={async () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(correctionsJson);
|
||||
} catch (e) {
|
||||
setCorrectionsErr(`Invalid JSON: ${e instanceof Error ? e.message : e}`);
|
||||
return;
|
||||
}
|
||||
setCorrectionsErr(null);
|
||||
const wasEdited = correctionsJson.trim() !== initialJson.trim();
|
||||
try {
|
||||
await apiPatch(`/api/brain/v1/analysis_atom/${atomId}`, {
|
||||
human_validated: true,
|
||||
human_corrections: wasEdited ? parsed : null,
|
||||
result_processed: wasEdited ? parsed : null,
|
||||
validator_user_id: 'admin-ui',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['brain'] });
|
||||
setForceGoldOpen(false);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setCorrectionsErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{correctionsJson.trim() !== initialJson.trim()
|
||||
? 'Promote with corrections'
|
||||
: 'Approve as-is'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BrainAtoms() {
|
||||
const queryClient = useQueryClient();
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [component, setComponent] = useState('all');
|
||||
const [tier, setTier] = useState('all');
|
||||
const [freshness, setFreshness] = useState('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [drawerId, setDrawerId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useQuery({
|
||||
queryKey: ['brain', 'atoms', component, tier, freshness, q, pagination.page, pagination.pageSize],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(pagination.page + 1));
|
||||
params.set('page_size', String(pagination.pageSize));
|
||||
if (component !== 'all') params.set('component', component);
|
||||
if (tier !== 'all') params.set('tier', tier);
|
||||
if (freshness !== 'all') params.set('freshness', freshness);
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
return apiGet<AtomListResponse>(`/api/brain/v1/analysis_atom/list?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: GridColDef<AtomListItem>[] = useMemo(
|
||||
() => [
|
||||
{ field: 'atom_id', headerName: 'ID', width: 80 },
|
||||
{
|
||||
field: 'component',
|
||||
headerName: 'Component',
|
||||
width: 130,
|
||||
renderCell: (p) => <Chip label={p.value} size="small" />,
|
||||
},
|
||||
{
|
||||
field: 'cache_tier',
|
||||
headerName: 'Tier',
|
||||
width: 100,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
label={p.value}
|
||||
size="small"
|
||||
color={TIER_COLOR[p.value as string] ?? 'default'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'content_preview',
|
||||
headerName: 'Preview',
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
renderCell: (p) => (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{p.value ?? '—'}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'llm_confidence',
|
||||
headerName: 'Conf.',
|
||||
width: 80,
|
||||
valueFormatter: (v: number | null) => (v !== null ? v.toFixed(0) : '—'),
|
||||
},
|
||||
{ field: 'hit_count', headerName: 'Hits', width: 70 },
|
||||
{
|
||||
field: 'updated_at',
|
||||
headerName: 'Updated',
|
||||
width: 160,
|
||||
valueFormatter: (v: string) => fmtDate(v),
|
||||
},
|
||||
{
|
||||
field: 'expires_at',
|
||||
headerName: 'Expires',
|
||||
width: 160,
|
||||
valueFormatter: (v: string | null) => fmtDate(v),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="h1">Brain Atoms</Typography>
|
||||
<Chip label={`${data?.total ?? '…'} total`} size="small" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['brain', 'atoms'] })}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
select
|
||||
label="Component"
|
||||
value={component}
|
||||
onChange={(e) => setComponent(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="techniques">techniques</MenuItem>
|
||||
<MenuItem value="ai_tampered">ai_tampered</MenuItem>
|
||||
<MenuItem value="claims">claims</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Cache tier"
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="gold">gold</MenuItem>
|
||||
<MenuItem value="silver">silver</MenuItem>
|
||||
<MenuItem value="bronze">bronze</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Freshness"
|
||||
value={freshness}
|
||||
onChange={(e) => setFreshness(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="fresh">fresh</MenuItem>
|
||||
<MenuItem value="expiring">expiring (≤7d)</MenuItem>
|
||||
<MenuItem value="expired">expired</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Search (preview/hash)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
<Paper sx={{ height: 640 }}>
|
||||
<DataGrid
|
||||
rows={data?.items ?? []}
|
||||
getRowId={(row) => row.atom_id}
|
||||
columns={columns}
|
||||
loading={isLoading || isFetching}
|
||||
rowCount={data?.total ?? 0}
|
||||
paginationMode="server"
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
onRowClick={(p) => setDrawerId(p.row.atom_id as number)}
|
||||
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<AtomDetailDrawer atomId={drawerId} onClose={() => setDrawerId(null)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
426
ai_platform/modules/dashboard/web/src/pages/brain/Cache.tsx
Normal file
426
ai_platform/modules/dashboard/web/src/pages/brain/Cache.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Drawer,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiDelete, apiGet } from '@/api/client';
|
||||
import type {
|
||||
VerificationDetail,
|
||||
VerificationListItem,
|
||||
VerificationListResponse,
|
||||
} from '@/types/brain';
|
||||
|
||||
const VOLATILITY_COLOR: Record<string, 'error' | 'warning' | 'success' | 'default'> = {
|
||||
volatile: 'error',
|
||||
evolving: 'warning',
|
||||
stable: 'success',
|
||||
};
|
||||
|
||||
const STANCE_COLOR: Record<string, 'success' | 'error' | 'default'> = {
|
||||
SUPPORTS: 'success',
|
||||
CONTRADICTS: 'error',
|
||||
NEUTRAL: 'default',
|
||||
};
|
||||
|
||||
function fmtDate(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
function VerificationDetailDrawer({
|
||||
rowKey,
|
||||
onClose,
|
||||
}: {
|
||||
rowKey: { claim_hash: string; tier: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const open = rowKey !== null;
|
||||
const detail = useQuery({
|
||||
queryKey: ['brain', 'verification', 'detail', rowKey?.claim_hash, rowKey?.tier],
|
||||
queryFn: () =>
|
||||
apiGet<VerificationDetail>(
|
||||
`/api/brain/v1/verification_cache/${encodeURIComponent(rowKey!.claim_hash)}/${rowKey!.tier}`,
|
||||
),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const vp = (detail.data?.verification_processed ?? {}) as Record<string, unknown>;
|
||||
const sources = (vp.sources as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const reasoning = vp.reasoning as string | undefined;
|
||||
const confidence = vp.confidence as number | undefined;
|
||||
const agreement = vp.agreement_score as number | undefined;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { width: { xs: '100%', md: 760 } } }}
|
||||
>
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h2">Verification cache</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{detail.isLoading && <CircularProgress size={24} />}
|
||||
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
|
||||
|
||||
{detail.data && (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip label={detail.data.tier} size="small" color={detail.data.tier === 'premium' ? 'primary' : 'default'} />
|
||||
{detail.data.status && <Chip label={`status: ${detail.data.status}`} size="small" variant="outlined" />}
|
||||
{detail.data.volatility && (
|
||||
<Chip
|
||||
label={`volatility: ${detail.data.volatility}`}
|
||||
size="small"
|
||||
color={VOLATILITY_COLOR[detail.data.volatility] ?? 'default'}
|
||||
/>
|
||||
)}
|
||||
{confidence != null && <Chip label={`conf: ${confidence}`} size="small" variant="outlined" />}
|
||||
{agreement != null && <Chip label={`agreement: ${agreement}`} size="small" variant="outlined" />}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Claim hash</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{detail.data.claim_hash}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{detail.data.topic_codes.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Topics</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{detail.data.topic_codes.map((t) => (
|
||||
<Chip key={t} label={t} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Model + cache versioning</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
model: {detail.data.model ?? '—'}<br />
|
||||
prompt_hash: {detail.data.prompt_hash}<br />
|
||||
framework_version: {detail.data.framework_version ?? '—'}<br />
|
||||
schema_name: {detail.data.schema_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Timestamps</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
created: {fmtDate(detail.data.created_at)}<br />
|
||||
updated: {fmtDate(detail.data.updated_at)}<br />
|
||||
expires: {fmtDate(detail.data.expires_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{reasoning && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Reasoning</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{reasoning}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sources ({sources.length})
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
||||
{sources.map((s, idx) => {
|
||||
const url = String(s.url ?? '');
|
||||
const stance = String(s.stance ?? 'NEUTRAL').toUpperCase();
|
||||
const reliability = String(s.reliability ?? '');
|
||||
const quote = String(s.relevant_quote ?? '');
|
||||
return (
|
||||
<Paper key={idx} variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={stance}
|
||||
size="small"
|
||||
color={STANCE_COLOR[stance] ?? 'default'}
|
||||
/>
|
||||
{reliability && <Chip label={reliability} size="small" variant="outlined" />}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
display: 'block',
|
||||
mb: quote ? 0.5 : 0,
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</Typography>
|
||||
{quote && (
|
||||
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
“{quote}”
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{detail.data.evidence_urls.length > sources.length && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Evidence URLs (raw, {detail.data.evidence_urls.length})
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1, bgcolor: 'background.default' }}>
|
||||
{detail.data.evidence_urls.map((u, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ display: 'block', fontFamily: 'monospace' }}>
|
||||
{u}
|
||||
</Typography>
|
||||
))}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BrainCache() {
|
||||
const queryClient = useQueryClient();
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [tier, setTier] = useState('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [drawerKey, setDrawerKey] = useState<{ claim_hash: string; tier: string } | null>(null);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useQuery({
|
||||
queryKey: ['brain', 'verification', tier, q, pagination.page, pagination.pageSize],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(pagination.page + 1));
|
||||
params.set('page_size', String(pagination.pageSize));
|
||||
if (tier !== 'all') params.set('tier', tier);
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
return apiGet<VerificationListResponse>(`/api/brain/v1/verification_cache/list?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: ({ claim_hash, tier }: { claim_hash: string; tier: string }) =>
|
||||
apiDelete<{ ok: boolean }>(
|
||||
`/api/brain/v1/verification_cache/${encodeURIComponent(claim_hash)}/${tier}`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'verification'] });
|
||||
},
|
||||
});
|
||||
|
||||
const columns: GridColDef<VerificationListItem>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'claim_hash',
|
||||
headerName: 'Claim hash',
|
||||
width: 200,
|
||||
renderCell: (p) => (
|
||||
<Tooltip title={p.value as string}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{(p.value as string).slice(0, 16)}…
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'tier',
|
||||
headerName: 'Tier',
|
||||
width: 90,
|
||||
renderCell: (p) => (
|
||||
<Chip label={p.value} size="small" color={p.value === 'premium' ? 'primary' : 'default'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
width: 110,
|
||||
renderCell: (p) =>
|
||||
p.value ? <Chip label={p.value} size="small" variant="outlined" /> : '—',
|
||||
},
|
||||
{
|
||||
field: 'volatility',
|
||||
headerName: 'Volatility',
|
||||
width: 110,
|
||||
renderCell: (p) =>
|
||||
p.value ? (
|
||||
<Chip
|
||||
label={p.value}
|
||||
size="small"
|
||||
color={VOLATILITY_COLOR[p.value as string] ?? 'default'}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'topic_codes',
|
||||
headerName: 'Topics',
|
||||
width: 180,
|
||||
sortable: false,
|
||||
renderCell: (p) => {
|
||||
const arr = (p.value as string[]) ?? [];
|
||||
if (arr.length === 0) return '—';
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{arr.slice(0, 3).map((t) => (
|
||||
<Chip key={t} label={t} size="small" variant="outlined" />
|
||||
))}
|
||||
{arr.length > 3 && (
|
||||
<Tooltip title={arr.slice(3).join(', ')}>
|
||||
<Chip label={`+${arr.length - 3}`} size="small" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ field: 'model', headerName: 'Model', width: 220 },
|
||||
{
|
||||
field: 'evidence_url_count',
|
||||
headerName: 'Evidence',
|
||||
width: 90,
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
field: 'updated_at',
|
||||
headerName: 'Updated',
|
||||
width: 160,
|
||||
valueFormatter: (v: string) => fmtDate(v),
|
||||
},
|
||||
{
|
||||
field: 'expires_at',
|
||||
headerName: 'Expires',
|
||||
width: 160,
|
||||
valueFormatter: (v: string) => fmtDate(v),
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: '',
|
||||
width: 70,
|
||||
sortable: false,
|
||||
renderCell: (p) => (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
`Hard-delete verification entry for ${p.row.claim_hash.slice(0, 12)}… (${p.row.tier})?`,
|
||||
)
|
||||
) {
|
||||
deleteMut.mutate({ claim_hash: p.row.claim_hash, tier: p.row.tier });
|
||||
}
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
],
|
||||
[deleteMut],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="h1">Verification Cache</Typography>
|
||||
<Chip label={`${data?.total ?? '…'} entries`} size="small" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['brain', 'verification'] })}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
select
|
||||
label="Tier"
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="free">free</MenuItem>
|
||||
<MenuItem value="premium">premium</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Search (hash/model)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
<Paper sx={{ height: 640 }}>
|
||||
<DataGrid
|
||||
rows={data?.items ?? []}
|
||||
getRowId={(row) => `${row.claim_hash}-${row.tier}`}
|
||||
columns={columns}
|
||||
loading={isLoading || isFetching}
|
||||
rowCount={data?.total ?? 0}
|
||||
paginationMode="server"
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={(params) =>
|
||||
setDrawerKey({ claim_hash: params.row.claim_hash, tier: params.row.tier })
|
||||
}
|
||||
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<VerificationDetailDrawer rowKey={drawerKey} onClose={() => setDrawerKey(null)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
701
ai_platform/modules/dashboard/web/src/pages/brain/Facts.tsx
Normal file
701
ai_platform/modules/dashboard/web/src/pages/brain/Facts.tsx
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
/**
|
||||
* Brain Fact Status — Pilon 11 admin browser.
|
||||
*
|
||||
* Lets the operator inspect what brain "knows" about the world (subject/
|
||||
* predicate/object triples extracted from claims) and override truth values
|
||||
* when LLM extraction got it wrong, or lock high-stakes facts so the daily
|
||||
* auditor stops auto-flipping them.
|
||||
*
|
||||
* UX:
|
||||
* - Top row of filters: free-text entity search, predicate, truth chip,
|
||||
* locked-only toggle, topic selector.
|
||||
* - Main DataGrid: paginated list with quick-glance status icons.
|
||||
* - Click row → side drawer with: full triple, current truth, version
|
||||
* timeline, moderator override form (set_truth + lock + notes).
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Drawer,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Step,
|
||||
StepContent,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
Switch,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet, apiPatch } from '@/api/client';
|
||||
import type {
|
||||
FactStatusItem,
|
||||
FactStatusListResponse,
|
||||
FactStatusPatchRequest,
|
||||
FactStatusVersionsResponse,
|
||||
Volatility,
|
||||
} from '@/types/brain';
|
||||
|
||||
const VOLATILITY_COLOR: Record<
|
||||
Volatility,
|
||||
'error' | 'warning' | 'success'
|
||||
> = {
|
||||
volatile: 'error',
|
||||
evolving: 'warning',
|
||||
stable: 'success',
|
||||
};
|
||||
|
||||
function fmtDate(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
function TruthIcon({ truth }: { truth: boolean | null }) {
|
||||
if (truth === true) return <CheckIcon fontSize="small" color="success" />;
|
||||
if (truth === false) return <CloseIcon fontSize="small" color="error" />;
|
||||
return <HelpOutlineIcon fontSize="small" color="disabled" />;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Detail drawer — timeline + moderator override
|
||||
// ============================================================================
|
||||
|
||||
function FactDetailDrawer({
|
||||
factId,
|
||||
onClose,
|
||||
}: {
|
||||
factId: number | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const open = factId !== null;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [moderatorId, setModeratorId] = useState('');
|
||||
const [setTruth, setSetTruth] = useState<'unchanged' | 'true' | 'false'>(
|
||||
'unchanged',
|
||||
);
|
||||
const [confidence, setConfidence] = useState<number>(95);
|
||||
const [evidenceUrls, setEvidenceUrls] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [lockChange, setLockChange] = useState<'unchanged' | 'lock' | 'unlock'>(
|
||||
'unchanged',
|
||||
);
|
||||
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
||||
|
||||
const factQuery = useQuery({
|
||||
queryKey: ['brain', 'fact', factId],
|
||||
queryFn: () => apiGet<FactStatusItem>(`/api/brain/v1/fact_status/${factId}`),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: ['brain', 'fact', factId, 'versions'],
|
||||
queryFn: () =>
|
||||
apiGet<FactStatusVersionsResponse>(
|
||||
`/api/brain/v1/fact_status/${factId}/versions`,
|
||||
),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: FactStatusPatchRequest) =>
|
||||
apiPatch<FactStatusItem>(`/api/brain/v1/fact_status/${factId}`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'fact', factId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'facts'] });
|
||||
setSetTruth('unchanged');
|
||||
setLockChange('unchanged');
|
||||
setNotes('');
|
||||
setEvidenceUrls('');
|
||||
setSubmitErr(null);
|
||||
},
|
||||
onError: (e: Error) => setSubmitErr(e.message),
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
setSubmitErr(null);
|
||||
if (!moderatorId.trim()) {
|
||||
setSubmitErr('moderator_user_id required (your keycloak id)');
|
||||
return;
|
||||
}
|
||||
if (setTruth === 'unchanged' && lockChange === 'unchanged' && !notes.trim()) {
|
||||
setSubmitErr('Pick at least one action (truth, lock, or notes)');
|
||||
return;
|
||||
}
|
||||
const body: FactStatusPatchRequest = {
|
||||
moderator_user_id: moderatorId.trim(),
|
||||
};
|
||||
if (setTruth === 'true') body.set_truth = true;
|
||||
if (setTruth === 'false') body.set_truth = false;
|
||||
if (setTruth !== 'unchanged') {
|
||||
body.confidence = confidence;
|
||||
body.evidence_urls = evidenceUrls
|
||||
.split('\n')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (lockChange === 'lock') body.lock = true;
|
||||
if (lockChange === 'unlock') body.lock = false;
|
||||
if (notes.trim()) body.notes = notes.trim();
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const fact = factQuery.data;
|
||||
const versions = versionsQuery.data?.versions ?? [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
|
||||
>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6">
|
||||
Fact #{factId ?? '—'}
|
||||
</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{factQuery.isLoading && <CircularProgress size={20} />}
|
||||
{factQuery.error && (
|
||||
<Alert severity="error">{(factQuery.error as Error).message}</Alert>
|
||||
)}
|
||||
|
||||
{fact && (
|
||||
<>
|
||||
{/* Triple summary */}
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Triple
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
|
||||
<strong>{fact.subject}</strong> {fact.predicate}{' '}
|
||||
<strong>{fact.object}</strong>
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 1 }}>
|
||||
<TruthIcon truth={fact.current_truth} />
|
||||
<Typography variant="body2">
|
||||
Current truth:{' '}
|
||||
<strong>
|
||||
{fact.current_truth === null
|
||||
? 'unknown'
|
||||
: fact.current_truth
|
||||
? 'TRUE'
|
||||
: 'FALSE'}
|
||||
</strong>
|
||||
{fact.current_confidence !== null &&
|
||||
` (${fact.current_confidence.toFixed(0)}% confidence)`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }} flexWrap="wrap">
|
||||
{fact.volatility && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={fact.volatility}
|
||||
color={VOLATILITY_COLOR[fact.volatility]}
|
||||
/>
|
||||
)}
|
||||
{fact.moderator_locked && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<LockIcon />}
|
||||
label={`locked by ${fact.moderator_user_id ?? 'admin'}`}
|
||||
color="info"
|
||||
/>
|
||||
)}
|
||||
{fact.topic_codes.map((t) => (
|
||||
<Chip key={t} size="small" label={t} variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Last verified: {fmtDate(fact.last_verified_at)} • Next check:{' '}
|
||||
{fmtDate(fact.next_check_at)} • Created: {fmtDate(fact.created_at)}
|
||||
</Typography>
|
||||
{fact.moderator_notes && (
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>
|
||||
Moderator note: {fact.moderator_notes}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Version timeline */}
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Truth timeline
|
||||
</Typography>
|
||||
{versionsQuery.isLoading && <CircularProgress size={20} />}
|
||||
{versions.length === 0 && !versionsQuery.isLoading && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
No truth has been asserted yet. Use the form below to set one.
|
||||
</Alert>
|
||||
)}
|
||||
{versions.length > 0 && (
|
||||
<Stepper orientation="vertical" sx={{ mb: 2 }}>
|
||||
{versions.map((v) => (
|
||||
<Step key={v.version_id} active expanded>
|
||||
<StepLabel
|
||||
icon={<TruthIcon truth={v.truth_value} />}
|
||||
optional={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{v.created_by} • {fmtDate(v.created_at)}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<strong>{v.truth_value ? 'TRUE' : 'FALSE'}</strong>
|
||||
{v.confidence !== null && ` (${v.confidence.toFixed(0)}%)`}
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
valid {fmtDate(v.valid_from)} →{' '}
|
||||
{v.valid_to ? fmtDate(v.valid_to) : 'present'}
|
||||
</Typography>
|
||||
{v.llm_reasoning && (
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
{v.llm_reasoning}
|
||||
</Typography>
|
||||
)}
|
||||
{v.notes && (
|
||||
<Alert severity="info" sx={{ mt: 0.5 }}>
|
||||
{v.notes}
|
||||
</Alert>
|
||||
)}
|
||||
{v.evidence_urls.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{v.evidence_urls.map((u) => (
|
||||
<Typography
|
||||
key={u}
|
||||
variant="caption"
|
||||
component="a"
|
||||
href={u}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ display: 'block', wordBreak: 'break-all' }}
|
||||
>
|
||||
{u}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
|
||||
{/* Moderator override form */}
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Moderator override
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Your moderator id (keycloak)"
|
||||
size="small"
|
||||
value={moderatorId}
|
||||
onChange={(e) => setModeratorId(e.target.value)}
|
||||
required
|
||||
helperText="Used in audit log + version provenance"
|
||||
/>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>Set truth</InputLabel>
|
||||
<Select
|
||||
label="Set truth"
|
||||
value={setTruth}
|
||||
onChange={(e) =>
|
||||
setSetTruth(e.target.value as 'unchanged' | 'true' | 'false')
|
||||
}
|
||||
>
|
||||
<MenuItem value="unchanged">Don't change</MenuItem>
|
||||
<MenuItem value="true">TRUE</MenuItem>
|
||||
<MenuItem value="false">FALSE</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{setTruth !== 'unchanged' && (
|
||||
<>
|
||||
<TextField
|
||||
label="Confidence"
|
||||
type="number"
|
||||
size="small"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
value={confidence}
|
||||
onChange={(e) => setConfidence(Number(e.target.value))}
|
||||
/>
|
||||
<TextField
|
||||
label="Evidence URLs (one per line)"
|
||||
multiline
|
||||
minRows={2}
|
||||
size="small"
|
||||
value={evidenceUrls}
|
||||
onChange={(e) => setEvidenceUrls(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>Lock state</InputLabel>
|
||||
<Select
|
||||
label="Lock state"
|
||||
value={lockChange}
|
||||
onChange={(e) =>
|
||||
setLockChange(
|
||||
e.target.value as 'unchanged' | 'lock' | 'unlock',
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="unchanged">Don't change</MenuItem>
|
||||
<MenuItem value="lock">Lock (auditor will skip)</MenuItem>
|
||||
<MenuItem value="unlock">Unlock (auditor can update)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Notes (audit trail)"
|
||||
multiline
|
||||
minRows={2}
|
||||
size="small"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
||||
{submitErr && <Alert severity="error">{submitErr}</Alert>}
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={mutation.isPending}
|
||||
startIcon={
|
||||
mutation.isPending ? <CircularProgress size={16} /> : undefined
|
||||
}
|
||||
>
|
||||
Apply override
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// List page
|
||||
// ============================================================================
|
||||
|
||||
interface DueForRecheckResponse {
|
||||
items: FactStatusItem[];
|
||||
count: number;
|
||||
limit: number;
|
||||
volatility_filter: string | null;
|
||||
}
|
||||
|
||||
export default function BrainFacts() {
|
||||
const [tab, setTab] = useState<'browse' | 'due'>('browse');
|
||||
const [dueVolatility, setDueVolatility] = useState<'' | 'volatile' | 'evolving' | 'stable'>('');
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [entity, setEntity] = useState('');
|
||||
const [predicate, setPredicate] = useState('');
|
||||
const [truth, setTruth] = useState<'all' | 'true' | 'false' | 'unknown'>(
|
||||
'all',
|
||||
);
|
||||
const [lockedOnly, setLockedOnly] = useState(false);
|
||||
const [topic, setTopic] = useState('');
|
||||
const [selectedFact, setSelectedFact] = useState<number | null>(null);
|
||||
|
||||
// Due for recheck — total count badge (always-on, refresh 60s)
|
||||
// NOTE: must use apiGet (not raw fetch) so the SPA BASE_URL prefix
|
||||
// (/admin-ai when mounted under reverse proxy) is applied.
|
||||
const dueBadge = useQuery({
|
||||
queryKey: ['fact_due_count'],
|
||||
queryFn: () =>
|
||||
apiGet<DueForRecheckResponse>(
|
||||
'/api/brain/v1/fact_status/due_for_recheck?limit=1',
|
||||
),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// Due for recheck — full list (only fetched when on "due" tab)
|
||||
const dueList = useQuery({
|
||||
queryKey: ['fact_due_list', dueVolatility],
|
||||
enabled: tab === 'due',
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (dueVolatility) params.set('volatility', dueVolatility);
|
||||
return apiGet<DueForRecheckResponse>(
|
||||
`/api/brain/v1/fact_status/due_for_recheck?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('page', String(pagination.page + 1));
|
||||
p.set('page_size', String(pagination.pageSize));
|
||||
if (entity) p.set('entity', entity);
|
||||
if (predicate) p.set('predicate', predicate);
|
||||
if (truth === 'true') p.set('current_truth', 'true');
|
||||
if (truth === 'false') p.set('current_truth', 'false');
|
||||
if (lockedOnly) p.set('locked_only', 'true');
|
||||
if (topic) p.set('topic', topic);
|
||||
return p.toString();
|
||||
}, [pagination, entity, predicate, truth, lockedOnly, topic]);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['brain', 'facts', params],
|
||||
queryFn: () => apiGet<FactStatusListResponse>(`/api/brain/v1/fact_status/list?${params}`),
|
||||
});
|
||||
|
||||
const columns: GridColDef<FactStatusItem>[] = [
|
||||
{
|
||||
field: 'current_truth',
|
||||
headerName: 'Truth',
|
||||
width: 70,
|
||||
sortable: false,
|
||||
renderCell: (p) => <TruthIcon truth={p.row.current_truth} />,
|
||||
},
|
||||
{
|
||||
field: 'subject',
|
||||
headerName: 'Subject',
|
||||
flex: 1.2,
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'predicate',
|
||||
headerName: 'Predicate',
|
||||
width: 180,
|
||||
renderCell: (p) => (
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{p.row.predicate}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'object',
|
||||
headerName: 'Object',
|
||||
flex: 1.2,
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'volatility',
|
||||
headerName: 'Volatility',
|
||||
width: 110,
|
||||
renderCell: (p) =>
|
||||
p.row.volatility ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.row.volatility}
|
||||
color={VOLATILITY_COLOR[p.row.volatility]}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'moderator_locked',
|
||||
headerName: 'Lock',
|
||||
width: 70,
|
||||
sortable: false,
|
||||
renderCell: (p) =>
|
||||
p.row.moderator_locked ? (
|
||||
<LockIcon fontSize="small" color="info" />
|
||||
) : (
|
||||
<LockOpenIcon fontSize="small" color="disabled" />
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'last_verified_at',
|
||||
headerName: 'Verified',
|
||||
width: 170,
|
||||
renderCell: (p) => (
|
||||
<Typography variant="caption">
|
||||
{fmtDate(p.row.last_verified_at)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Brain — fact status</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<IconButton onClick={() => tab === 'browse' ? list.refetch() : dueList.refetch()} aria-label="refresh">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v as 'browse' | 'due')} sx={{ mb: 2 }}>
|
||||
<Tab value="browse" label="Browse" />
|
||||
<Tab
|
||||
value="due"
|
||||
label={
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<span>Due for Recheck</span>
|
||||
{dueBadge.data && dueBadge.data.count > 0 && (
|
||||
<Chip label={dueBadge.data.count > 99 ? '99+' : dueBadge.data.count} size="small" color="warning" sx={{ height: 18, fontSize: 10 }} />
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{tab === 'due' && (
|
||||
<>
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">
|
||||
Facts whose <code>next_check_at</code> has passed and need re-verification. Excludes moderator-locked facts.
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Volatility</InputLabel>
|
||||
<Select label="Volatility" value={dueVolatility} onChange={(e) => setDueVolatility(e.target.value as 'volatile' | 'evolving' | 'stable' | '')}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="volatile">Volatile</MenuItem>
|
||||
<MenuItem value="evolving">Evolving</MenuItem>
|
||||
<MenuItem value="stable">Stable</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{dueList.isError && <Alert severity="error" sx={{ mb: 2 }}>{(dueList.error as Error).message}</Alert>}
|
||||
|
||||
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={(dueList.data?.items ?? []) as unknown as FactStatusItem[]}
|
||||
getRowId={(r) => r.fact_id}
|
||||
columns={columns}
|
||||
loading={dueList.isLoading}
|
||||
hideFooterPagination
|
||||
onRowClick={(p) => setSelectedFact(p.row.fact_id)}
|
||||
disableRowSelectionOnClick
|
||||
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<FactDetailDrawer
|
||||
factId={selectedFact}
|
||||
onClose={() => setSelectedFact(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'browse' && <>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} flexWrap="wrap">
|
||||
<TextField
|
||||
label="Entity (subject or object)"
|
||||
size="small"
|
||||
value={entity}
|
||||
onChange={(e) => setEntity(e.target.value)}
|
||||
sx={{ minWidth: 240 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Predicate"
|
||||
size="small"
|
||||
value={predicate}
|
||||
onChange={(e) => setPredicate(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Truth</InputLabel>
|
||||
<Select
|
||||
label="Truth"
|
||||
value={truth}
|
||||
onChange={(e) =>
|
||||
setTruth(e.target.value as 'all' | 'true' | 'false' | 'unknown')
|
||||
}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">TRUE only</MenuItem>
|
||||
<MenuItem value="false">FALSE only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Topic code"
|
||||
size="small"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
sx={{ minWidth: 160 }}
|
||||
helperText="e.g., war, climate"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={lockedOnly}
|
||||
onChange={(e) => setLockedOnly(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Locked only"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{list.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(list.error as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={list.data?.items ?? []}
|
||||
getRowId={(r) => r.fact_id}
|
||||
columns={columns}
|
||||
rowCount={list.data?.total ?? 0}
|
||||
loading={list.isLoading}
|
||||
paginationMode="server"
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
onRowClick={(p) => setSelectedFact(p.row.fact_id)}
|
||||
disableRowSelectionOnClick
|
||||
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<FactDetailDrawer
|
||||
factId={selectedFact}
|
||||
onClose={() => setSelectedFact(null)}
|
||||
/>
|
||||
</>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
354
ai_platform/modules/dashboard/web/src/pages/brain/Invalidate.tsx
Normal file
354
ai_platform/modules/dashboard/web/src/pages/brain/Invalidate.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
/**
|
||||
* Brain Cache Invalidate — Pilon 8 admin UI.
|
||||
*
|
||||
* Form-driven mass invalidation with a 2-step UX:
|
||||
* 1. Operator picks filters (topic_codes, entity_canonicals, claim_pattern,
|
||||
* since) → click "Preview" → backend runs dry_run=true → shows counts.
|
||||
* 2. If counts look right, "Confirm invalidate" runs the same filter
|
||||
* with dry_run=false. Audit log entry written automatically.
|
||||
*
|
||||
* Safety:
|
||||
* - No "Confirm" button visible until a Preview has been run.
|
||||
* - Filters are cleared after Confirm to prevent accidental re-execution.
|
||||
* - "invalidate_gold" requires explicit checkbox (gold = moderator-validated,
|
||||
* should not be flushed casually).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet, apiPost } from '@/api/client';
|
||||
import type {
|
||||
AuditLogResponse,
|
||||
CacheInvalidateRequest,
|
||||
CacheInvalidateResponse,
|
||||
} from '@/types/brain';
|
||||
|
||||
function parseList(s: string): string[] {
|
||||
return s
|
||||
.split(/[\n,]/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function BrainInvalidate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Filter inputs
|
||||
const [topicCodes, setTopicCodes] = useState('');
|
||||
const [entityCanonicals, setEntityCanonicals] = useState('');
|
||||
const [claimPattern, setClaimPattern] = useState('');
|
||||
const [since, setSince] = useState('');
|
||||
const [invalidateGold, setInvalidateGold] = useState(false);
|
||||
const [actor, setActor] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
// Last preview result — only when this is non-null can we Confirm.
|
||||
const [preview, setPreview] = useState<CacheInvalidateResponse | null>(null);
|
||||
const [lastResult, setLastResult] = useState<CacheInvalidateResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
||||
|
||||
// Recent invalidations panel
|
||||
const recent = useQuery({
|
||||
queryKey: ['brain', 'audit_log', 'invalidate'],
|
||||
queryFn: () =>
|
||||
apiGet<AuditLogResponse>(
|
||||
'/api/brain/v1/cache/audit_log?action=invalidate&page_size=10',
|
||||
),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const buildBody = (dry: boolean): CacheInvalidateRequest => {
|
||||
const body: CacheInvalidateRequest = {
|
||||
dry_run: dry,
|
||||
invalidate_gold: invalidateGold,
|
||||
};
|
||||
const tc = parseList(topicCodes);
|
||||
const ec = parseList(entityCanonicals);
|
||||
if (tc.length) body.topic_codes = tc;
|
||||
if (ec.length) body.entity_canonicals = ec;
|
||||
if (claimPattern.trim()) body.claim_pattern = claimPattern.trim();
|
||||
if (since.trim()) body.since = new Date(since).toISOString();
|
||||
if (actor.trim()) body.actor = actor.trim();
|
||||
if (reason.trim()) body.reason = reason.trim();
|
||||
return body;
|
||||
};
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<CacheInvalidateResponse>(
|
||||
'/api/brain/v1/cache/invalidate',
|
||||
buildBody(true),
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setPreview(data);
|
||||
setSubmitErr(null);
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
setPreview(null);
|
||||
setSubmitErr(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<CacheInvalidateResponse>(
|
||||
'/api/brain/v1/cache/invalidate',
|
||||
buildBody(false),
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setLastResult(data);
|
||||
setPreview(null);
|
||||
// Reset filters so user can't accidentally re-run.
|
||||
setTopicCodes('');
|
||||
setEntityCanonicals('');
|
||||
setClaimPattern('');
|
||||
setSince('');
|
||||
setReason('');
|
||||
// Refresh recent invalidations.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['brain', 'audit_log', 'invalidate'],
|
||||
});
|
||||
},
|
||||
onError: (e: Error) => setSubmitErr(e.message),
|
||||
});
|
||||
|
||||
const hasFilter =
|
||||
topicCodes.trim() ||
|
||||
entityCanonicals.trim() ||
|
||||
claimPattern.trim() ||
|
||||
since.trim();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Brain — cache invalidate</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', lg: 'row' }} spacing={3}>
|
||||
<Box sx={{ flex: 2 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
1. Build filter
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Provide at least one of: topic codes, entity canonicals, claim
|
||||
pattern, or "since" timestamp. Empty filters are rejected.
|
||||
</Alert>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Topic codes"
|
||||
multiline
|
||||
minRows={2}
|
||||
size="small"
|
||||
value={topicCodes}
|
||||
onChange={(e) => setTopicCodes(e.target.value)}
|
||||
helperText="One per line or comma-separated. Examples: war, elections, climate"
|
||||
/>
|
||||
<TextField
|
||||
label="Entity canonicals"
|
||||
multiline
|
||||
minRows={2}
|
||||
size="small"
|
||||
value={entityCanonicals}
|
||||
onChange={(e) => setEntityCanonicals(e.target.value)}
|
||||
helperText='Lowercased "subject predicate object". Example: vladimir putin is_president_of russia'
|
||||
/>
|
||||
<TextField
|
||||
label="Claim pattern (ILIKE)"
|
||||
size="small"
|
||||
value={claimPattern}
|
||||
onChange={(e) => setClaimPattern(e.target.value)}
|
||||
helperText="ILIKE pattern matched against content_preview / verification_processed"
|
||||
/>
|
||||
<TextField
|
||||
label="Since"
|
||||
type="datetime-local"
|
||||
size="small"
|
||||
value={since}
|
||||
onChange={(e) => setSince(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText="Match rows updated_at >= this timestamp"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={invalidateGold}
|
||||
onChange={(e) => setInvalidateGold(e.target.checked)}
|
||||
color="warning"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<WarningAmberIcon fontSize="small" color="warning" />
|
||||
<Typography variant="body2">
|
||||
Also invalidate <strong>gold</strong> atoms (moderator-validated)
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<TextField
|
||||
label="Actor (audit log)"
|
||||
size="small"
|
||||
value={actor}
|
||||
onChange={(e) => setActor(e.target.value)}
|
||||
helperText="Your keycloak id — defaults to 'api'"
|
||||
/>
|
||||
<TextField
|
||||
label="Reason"
|
||||
size="small"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
helperText="Why are you doing this? Stored in audit log"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
2. Preview & confirm
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setLastResult(null);
|
||||
previewMutation.mutate();
|
||||
}}
|
||||
disabled={!hasFilter || previewMutation.isPending}
|
||||
startIcon={
|
||||
previewMutation.isPending ? (
|
||||
<CircularProgress size={16} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Preview (dry run)
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
disabled={!preview || confirmMutation.isPending}
|
||||
startIcon={
|
||||
confirmMutation.isPending ? (
|
||||
<CircularProgress size={16} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Confirm invalidate
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{submitErr && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{submitErr}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<Alert severity="warning" icon={<WarningAmberIcon />} sx={{ mt: 2 }}>
|
||||
Will invalidate <strong>{preview.invalidated_atoms}</strong> atoms
|
||||
+ <strong>{preview.invalidated_vcache}</strong> verification rows.
|
||||
Click "Confirm invalidate" to apply.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{lastResult && (
|
||||
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mt: 2 }}>
|
||||
Done — invalidated <strong>{lastResult.invalidated_atoms}</strong>{' '}
|
||||
atoms + <strong>{lastResult.invalidated_vcache}</strong>{' '}
|
||||
verification rows at{' '}
|
||||
{new Date(lastResult.executed_at).toLocaleString()}.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 320 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="h6">Recent invalidations</Typography>
|
||||
<IconButton size="small" onClick={() => recent.refetch()}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
{recent.isLoading && <CircularProgress size={20} />}
|
||||
{recent.data?.items.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No invalidations yet.
|
||||
</Typography>
|
||||
)}
|
||||
<Stack spacing={1.5}>
|
||||
{(recent.data?.items ?? []).map((entry) => {
|
||||
const counts = entry.payload?.counts as
|
||||
| { atoms?: number; vcache?: number }
|
||||
| undefined;
|
||||
const reasonText = entry.payload?.reason as string | undefined;
|
||||
return (
|
||||
<Box key={entry.log_id}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(entry.created_at).toLocaleString()} •{' '}
|
||||
{entry.actor}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${counts?.atoms ?? 0} atoms`}
|
||||
color="default"
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${counts?.vcache ?? 0} vcache`}
|
||||
color="default"
|
||||
/>
|
||||
</Stack>
|
||||
{reasonText && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 0.5, fontStyle: 'italic' }}
|
||||
>
|
||||
{reasonText}
|
||||
</Typography>
|
||||
)}
|
||||
<Divider sx={{ mt: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
209
ai_platform/modules/dashboard/web/src/pages/brain/Stats.tsx
Normal file
209
ai_platform/modules/dashboard/web/src/pages/brain/Stats.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
import type { AtomStatsExtended } from '@/types/brain';
|
||||
|
||||
const TIER_COLORS = {
|
||||
gold: '#FFC107',
|
||||
silver: '#9E9E9E',
|
||||
bronze: '#CD7F32',
|
||||
} as const;
|
||||
|
||||
export default function BrainStats() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['brain', 'stats'],
|
||||
queryFn: () =>
|
||||
apiGet<AtomStatsExtended>('/api/brain/v1/analysis_atom/stats/extended'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <Alert severity="error">Failed to load stats: {String(error)}</Alert>;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const tierData = [
|
||||
{ name: 'gold', value: data.by_tier.gold, fill: TIER_COLORS.gold },
|
||||
{ name: 'silver', value: data.by_tier.silver, fill: TIER_COLORS.silver },
|
||||
{ name: 'bronze', value: data.by_tier.bronze, fill: TIER_COLORS.bronze },
|
||||
].filter((t) => t.value > 0);
|
||||
|
||||
const componentData = [
|
||||
{ name: 'techniques', value: data.by_component.techniques },
|
||||
{ name: 'ai_tampered', value: data.by_component.ai_tampered },
|
||||
{ name: 'claims', value: data.by_component.claims },
|
||||
];
|
||||
|
||||
const hitsData = [
|
||||
{ name: 'gold (24h)', value: data.hits_24h_gold, fill: TIER_COLORS.gold },
|
||||
{ name: 'silver (24h)', value: data.hits_24h_silver, fill: TIER_COLORS.silver },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 3 }}>
|
||||
<Typography variant="h1">Brain Stats</Typography>
|
||||
<Chip
|
||||
label={isFetching ? 'refreshing…' : 'auto-refresh 30s'}
|
||||
size="small"
|
||||
color={isFetching ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total atoms
|
||||
</Typography>
|
||||
<Typography variant="h2">{data.total_atoms.toLocaleString()}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Hit rate (24h)
|
||||
</Typography>
|
||||
<Typography variant="h2" color="success.main">
|
||||
{data.hit_rate_24h !== null
|
||||
? `${(data.hit_rate_24h * 100).toFixed(1)}%`
|
||||
: '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{data.hits_24h_gold + data.hits_24h_silver} hits / {data.writes_24h} writes
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Writes (24h)
|
||||
</Typography>
|
||||
<Typography variant="h2">{data.writes_24h}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Gold promotions (24h)
|
||||
</Typography>
|
||||
<Typography variant="h2" sx={{ color: TIER_COLORS.gold }}>
|
||||
{data.gold_promotions_24h}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Atoms by tier
|
||||
</Typography>
|
||||
{tierData.length === 0 ? (
|
||||
<Alert severity="info">No atoms yet</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie data={tierData} dataKey="value" nameKey="name" outerRadius={90} label>
|
||||
{tierData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Atoms by component
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={componentData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#0052CC" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Hits by tier (24h)
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={hitsData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value">
|
||||
{hitsData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Refreshed live every 30s. Click anywhere on the page to force a re-fetch via{' '}
|
||||
<code onClick={() => refetch()} style={{ cursor: 'pointer' }}>
|
||||
refetch()
|
||||
</code>
|
||||
.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
129
ai_platform/modules/dashboard/web/src/pages/brain/Taxonomy.tsx
Normal file
129
ai_platform/modules/dashboard/web/src/pages/brain/Taxonomy.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet, apiPost } from '@/api/client';
|
||||
import type { TaxonomyInfo } from '@/types/brain';
|
||||
|
||||
interface ReloadResponse {
|
||||
ok: boolean;
|
||||
before: number | null;
|
||||
after: number | null;
|
||||
fetched: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function BrainTaxonomy() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['brain', 'taxonomy'],
|
||||
queryFn: () => apiGet<TaxonomyInfo>('/api/brain/v1/taxonomy'),
|
||||
});
|
||||
|
||||
const reloadMut = useMutation({
|
||||
mutationFn: () => apiPost<ReloadResponse>('/api/brain/v1/taxonomy/reload'),
|
||||
onSuccess: (data) => {
|
||||
if (data.ok) {
|
||||
setFeedback({
|
||||
kind: 'ok',
|
||||
msg: `Reloaded — ${data.fetched} tags from atomic. Resolver was ${data.before} → now ${data.after}.`,
|
||||
});
|
||||
} else {
|
||||
setFeedback({ kind: 'err', msg: data.error ?? 'Reload failed' });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['brain', 'taxonomy'] });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) return <Alert severity="error">{String(error)}</Alert>;
|
||||
if (!data) return null;
|
||||
|
||||
const sortedNamespaces = [...data.namespaces].sort();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 3 }}>
|
||||
<Typography variant="h1">Taxonomy</Typography>
|
||||
<Chip label={`${data.total_tags} tags`} size="small" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => reloadMut.mutate()}
|
||||
disabled={reloadMut.isPending}
|
||||
>
|
||||
{reloadMut.isPending ? 'Reloading…' : 'Reload from atomic'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{feedback && (
|
||||
<Alert
|
||||
severity={feedback.kind === 'ok' ? 'success' : 'error'}
|
||||
onClose={() => setFeedback(null)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{feedback.msg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Namespaces
|
||||
</Typography>
|
||||
{sortedNamespaces.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No taxonomy loaded. Brain may have failed to fetch from atomic at startup.
|
||||
</Alert>
|
||||
) : (
|
||||
<List dense>
|
||||
{sortedNamespaces.map((ns) => (
|
||||
<ListItem
|
||||
key={ns}
|
||||
secondaryAction={
|
||||
<Chip
|
||||
label={`${data.by_namespace[ns] ?? 0} tags`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={ns}
|
||||
primaryTypographyProps={{ sx: { fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
116
ai_platform/modules/dashboard/web/src/pages/insights/Archive.tsx
Normal file
116
ai_platform/modules/dashboard/web/src/pages/insights/Archive.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
|
||||
interface ArchiveResponse {
|
||||
claims: Array<{
|
||||
id?: number;
|
||||
claim_id?: string | number;
|
||||
claim?: string;
|
||||
summary?: string | null;
|
||||
verdict?: string | null;
|
||||
confidence?: number | null;
|
||||
created_at?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
function fmt(s: string | undefined | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
export default function Archive() {
|
||||
const [q, setQ] = useState('');
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['archive', q],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '50');
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
return apiGet<ArchiveResponse>(`/api/archive/claims?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="h1">Claims Archive</Typography>
|
||||
<Chip label={`${data?.total ?? data?.claims?.length ?? '…'} claims`} size="small" />
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label="Search claim text (ILIKE)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<Stack alignItems="center" sx={{ py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
)}
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
{data?.claims && data.claims.length === 0 && (
|
||||
<Alert severity="info">No claims yet — promote a request from /history first.</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{(data?.claims ?? []).map((c, i) => (
|
||||
<Grid size={{ xs: 12, md: 6 }} key={c.id ?? c.claim_id ?? i}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} alignItems="center">
|
||||
{c.verdict && (
|
||||
<Chip
|
||||
label={c.verdict}
|
||||
size="small"
|
||||
color={c.verdict === 'true' ? 'success' : c.verdict === 'false' ? 'error' : 'default'}
|
||||
/>
|
||||
)}
|
||||
{c.confidence !== null && c.confidence !== undefined && (
|
||||
<Chip
|
||||
label={`${(Number(c.confidence) * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{fmt(c.created_at as string | undefined)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mb: 1 }}>
|
||||
{c.claim ?? '(no claim text)'}
|
||||
</Typography>
|
||||
{c.summary && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{c.summary}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
296
ai_platform/modules/dashboard/web/src/pages/insights/Cost.tsx
Normal file
296
ai_platform/modules/dashboard/web/src/pages/insights/Cost.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
import type { CostStats } from '@/types/insights';
|
||||
|
||||
const TIER_COLORS = { free: '#0052CC', premium: '#FF8C42', null: '#A0A0B0' } as const;
|
||||
|
||||
function fmt(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
function tierColor(t: string | null | undefined): string {
|
||||
if (!t) return TIER_COLORS.null;
|
||||
return (TIER_COLORS as Record<string, string>)[t] ?? '#A0A0B0';
|
||||
}
|
||||
|
||||
export default function Cost() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['cost'],
|
||||
queryFn: () => apiGet<CostStats>('/api/stats/cost'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) return <Alert severity="error">{String(error)}</Alert>;
|
||||
if (!data) return null;
|
||||
|
||||
const tierPie = data.by_tier.map((r) => ({
|
||||
name: r.tier ?? 'unknown',
|
||||
value: r.cost,
|
||||
fill: tierColor(r.tier),
|
||||
}));
|
||||
const providerBars = data.by_provider.map((r) => ({
|
||||
name: r.provider ?? 'unknown',
|
||||
cost: r.cost,
|
||||
count: r.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h1" sx={{ mb: 1 }}>
|
||||
Cost
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Spend rollups (24h / 7d / 30d) + provider/tier breakdown. Refreshes every 60s.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 24h
|
||||
</Typography>
|
||||
<Typography variant="h2">${data.cost_24h.toFixed(4)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 7d
|
||||
</Typography>
|
||||
<Typography variant="h2">${data.cost_7d.toFixed(4)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 30d
|
||||
</Typography>
|
||||
<Typography variant="h2">${data.cost_30d.toFixed(4)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Projected monthly
|
||||
</Typography>
|
||||
{(data as { projection_confidence?: string }).projection_confidence && (
|
||||
<Chip
|
||||
label={(data as { projection_confidence?: string }).projection_confidence}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={
|
||||
(data as { projection_confidence?: string }).projection_confidence === 'stable' ? 'success' :
|
||||
(data as { projection_confidence?: string }).projection_confidence === 'moderate' ? 'default' : 'warning'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="h2" color="warning.main">
|
||||
${data.projected_monthly.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{(data as { projection_basis?: string }).projection_basis ?? 'estimate'}
|
||||
</Typography>
|
||||
{(data as { trend?: string }).trend && (data as { trend?: string }).trend !== 'unknown' && (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{
|
||||
color: (data as { trend?: string }).trend === 'increasing' ? 'error.main' :
|
||||
(data as { trend?: string }).trend === 'decreasing' ? 'success.main' : 'text.secondary',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{(data as { trend?: string }).trend === 'increasing' ? '↗' : (data as { trend?: string }).trend === 'decreasing' ? '↘' : '→'} 7d trend: {(data as { trend?: string }).trend}
|
||||
</Typography>
|
||||
{(data as { trend_pct?: number | null }).trend_pct !== undefined && (data as { trend_pct?: number | null }).trend_pct !== null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({(data as { trend_pct?: number }).trend_pct! >= 0 ? '+' : ''}{(data as { trend_pct?: number }).trend_pct}%)
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Cost by tier (30d)
|
||||
</Typography>
|
||||
{tierPie.length === 0 ? (
|
||||
<Alert severity="info">No spend recorded</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie data={tierPie} dataKey="value" nameKey="name" outerRadius={90} label>
|
||||
{tierPie.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Cost by provider (30d)
|
||||
</Typography>
|
||||
{providerBars.length === 0 ? (
|
||||
<Alert severity="info">No provider cost recorded</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={providerBars}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="cost" fill="#0052CC" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Provider quota usage
|
||||
</Typography>
|
||||
{data.budgets.length === 0 ? (
|
||||
<Alert severity="info">No provider quotas configured</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{data.budgets.map((b) => {
|
||||
// USD quotas should be formatted as $X.XX (Pay-as-you-go billing
|
||||
// shows fractional dollars; toLocaleString() with no options
|
||||
// strips decimals AND inserts thousand separators, so 43.11
|
||||
// ends up rendered as "43,112" on locales that group thousands).
|
||||
const isUsd = (b.unit ?? '').toLowerCase() === 'usd';
|
||||
const fmt = (n: number) =>
|
||||
isUsd
|
||||
? `$${n.toFixed(2)}`
|
||||
: n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return (
|
||||
<Box key={b.name}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{b.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{fmt(b.used)} / {fmt(b.limit)}
|
||||
{!isUsd && b.unit ? ` ${b.unit}` : ''} (
|
||||
{b.percent.toFixed(1)}%)
|
||||
{b.plan_price && ` — $${b.plan_price}/mo`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, b.percent)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor:
|
||||
b.percent > 90 ? 'error.main' : b.percent > 75 ? 'warning.main' : 'success.main',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||
Top 10 most expensive (30d)
|
||||
</Typography>
|
||||
{data.top.length === 0 ? (
|
||||
<Alert severity="info">No expensive requests</Alert>
|
||||
) : (
|
||||
<Paper variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Tier</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Endpoint</TableCell>
|
||||
<TableCell align="right">Duration</TableCell>
|
||||
<TableCell align="right">Cost</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.top.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{fmt(r.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{r.tier && <Chip label={r.tier} size="small" />}
|
||||
</TableCell>
|
||||
<TableCell>{r.provider ?? '—'}</TableCell>
|
||||
<TableCell>{r.endpoint ?? '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{r.duration_ms !== null ? `${r.duration_ms} ms` : '—'}
|
||||
</TableCell>
|
||||
<TableCell align="right">${r.cost.toFixed(4)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
415
ai_platform/modules/dashboard/web/src/pages/insights/History.tsx
Normal file
415
ai_platform/modules/dashboard/web/src/pages/insights/History.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Drawer,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import { apiGet, apiPost } from '@/api/client';
|
||||
import type { HistoryDetail, HistoryItem, HistoryListResponse } from '@/types/insights';
|
||||
|
||||
function fmt(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
function statusColor(code: number | null): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (code === null) return 'default';
|
||||
if (code < 300) return 'success';
|
||||
if (code < 500) return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function HistoryDetailDrawer({ id, onClose }: { id: number | null; onClose: () => void }) {
|
||||
const open = id !== null;
|
||||
const queryClient = useQueryClient();
|
||||
const detail = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: () => apiGet<HistoryDetail>(`/api/history/${id}`),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Promote-to-archive mutation. Backend rejects everything except /v1/gather
|
||||
// rows that have a stored raw_response, so we hide the button otherwise.
|
||||
const promoteMut = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!detail.data) throw new Error('No row loaded');
|
||||
const reqId = detail.data.request_id;
|
||||
return apiPost<{ ok: boolean; archived_id?: number; created?: boolean }>(
|
||||
`/api/archive/promote/${encodeURIComponent(reqId)}`,
|
||||
{},
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archive', 'claims'] });
|
||||
},
|
||||
});
|
||||
|
||||
const canPromote = Boolean(
|
||||
detail.data?.endpoint === '/v1/gather' && detail.data?.raw_response,
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
|
||||
>
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h2">Request #{id}</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{detail.isLoading && <CircularProgress size={24} />}
|
||||
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
|
||||
|
||||
{detail.data && (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{detail.data.module && (
|
||||
<Chip
|
||||
label={`module: ${detail.data.module}`}
|
||||
size="small"
|
||||
color={
|
||||
detail.data.module === 'brain'
|
||||
? 'secondary'
|
||||
: detail.data.module === 'agent_v3'
|
||||
? 'success'
|
||||
: 'primary'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{detail.data.tier && detail.data.tier !== 'n/a' && <Chip label={`tier: ${detail.data.tier}`} size="small" />}
|
||||
{detail.data.endpoint && (
|
||||
<Chip label={detail.data.endpoint} size="small" variant="outlined" />
|
||||
)}
|
||||
{detail.data.provider && (
|
||||
<Chip label={`provider: ${detail.data.provider}`} size="small" variant="outlined" />
|
||||
)}
|
||||
<Chip
|
||||
label={`HTTP ${detail.data.status_code ?? '?'}` as string}
|
||||
size="small"
|
||||
color={statusColor(detail.data.status_code)}
|
||||
/>
|
||||
{detail.data.duration_ms !== null && (
|
||||
<Chip
|
||||
label={`${detail.data.duration_ms} ms`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{detail.data.cost_usd !== null && detail.data.cost_usd !== undefined && (
|
||||
<Chip
|
||||
label={`$${Number(detail.data.cost_usd).toFixed(4)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Request ID
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{detail.data.request_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{detail.data.error && (
|
||||
<Alert severity="error">
|
||||
<Typography variant="body2">{detail.data.error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Promote-to-archive: only meaningful for /v1/gather rows that
|
||||
actually have a raw_response payload to extract. Backend
|
||||
rejects everything else with 400/404. */}
|
||||
{canPromote && (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="small"
|
||||
startIcon={<ArchiveIcon />}
|
||||
onClick={() => promoteMut.mutate()}
|
||||
disabled={promoteMut.isPending || promoteMut.isSuccess}
|
||||
>
|
||||
{promoteMut.isPending
|
||||
? 'Promoting…'
|
||||
: promoteMut.isSuccess
|
||||
? 'Promoted'
|
||||
: 'Promote to Archive'}
|
||||
</Button>
|
||||
{promoteMut.isSuccess && (
|
||||
<Button size="small" component={RouterLink} to="/insights/archive">
|
||||
View in Archive
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{promoteMut.error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{(promoteMut.error as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
{promoteMut.isSuccess && promoteMut.data && (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
Archived as claim #{promoteMut.data.archived_id}
|
||||
{promoteMut.data.created === false ? ' (already existed, refreshed)' : ''}.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Boolean(detail.data.stages) && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Stages
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(detail.data.stages, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Boolean(detail.data.raw_request) && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Raw request
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(detail.data.raw_request, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Boolean(detail.data.raw_response) && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Raw response
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(detail.data.raw_response, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const MODULE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'default'> = {
|
||||
web: 'primary',
|
||||
brain: 'secondary',
|
||||
agent_v3: 'success',
|
||||
};
|
||||
|
||||
export default function History() {
|
||||
const queryClient = useQueryClient();
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({ page: 0, pageSize: 25 });
|
||||
const [module_, setModule] = useState('all');
|
||||
const [tier, setTier] = useState('all');
|
||||
const [provider, setProvider] = useState('');
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [hours, setHours] = useState(24);
|
||||
const [drawerId, setDrawerId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useQuery({
|
||||
queryKey: ['history', module_, tier, provider, endpoint, hours, pagination.page, pagination.pageSize],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(pagination.pageSize));
|
||||
params.set('offset', String(pagination.page * pagination.pageSize));
|
||||
params.set('hours', String(hours));
|
||||
if (module_ !== 'all') params.set('module', module_);
|
||||
if (tier !== 'all') params.set('tier', tier);
|
||||
if (provider.trim()) params.set('provider', provider.trim());
|
||||
if (endpoint.trim()) params.set('endpoint', endpoint.trim());
|
||||
return apiGet<HistoryListResponse>(`/api/history?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: GridColDef<HistoryItem>[] = useMemo(
|
||||
() => [
|
||||
{ field: 'id', headerName: 'ID', width: 80 },
|
||||
{
|
||||
field: 'created_at',
|
||||
headerName: 'When',
|
||||
width: 170,
|
||||
valueFormatter: (v: string) => fmt(v),
|
||||
},
|
||||
{
|
||||
field: 'module',
|
||||
headerName: 'Module',
|
||||
width: 100,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
label={p.value ?? 'web'}
|
||||
size="small"
|
||||
color={MODULE_COLOR[(p.value as string) ?? 'web'] ?? 'default'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'tier',
|
||||
headerName: 'Tier',
|
||||
width: 90,
|
||||
renderCell: (p) =>
|
||||
p.value && p.value !== 'n/a' ? (
|
||||
<Chip label={p.value} size="small" color={p.value === 'premium' ? 'primary' : 'default'} />
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{ field: 'endpoint', headerName: 'Endpoint', width: 140 },
|
||||
{ field: 'provider', headerName: 'Provider', width: 130 },
|
||||
{
|
||||
field: 'status_code',
|
||||
headerName: 'Status',
|
||||
width: 90,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
label={p.value ?? '?'}
|
||||
size="small"
|
||||
color={statusColor(p.value as number | null)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'duration_ms',
|
||||
headerName: 'Duration',
|
||||
width: 110,
|
||||
valueFormatter: (v: number | null) => (v !== null ? `${v} ms` : '—'),
|
||||
},
|
||||
{
|
||||
field: 'cost_usd',
|
||||
headerName: 'Cost',
|
||||
width: 100,
|
||||
valueFormatter: (v: number | null) =>
|
||||
v !== null && v !== undefined ? `$${Number(v).toFixed(4)}` : '—',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="h1">Request History</Typography>
|
||||
<Chip label={`${data?.items.length ?? '…'} loaded`} size="small" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['history'] })}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
select
|
||||
label="Module"
|
||||
value={module_}
|
||||
onChange={(e) => setModule(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="web">web</MenuItem>
|
||||
<MenuItem value="brain">brain</MenuItem>
|
||||
<MenuItem value="agent_v3">agent_v3</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Tier"
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="free">free</MenuItem>
|
||||
<MenuItem value="premium">premium</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Window"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
<MenuItem value={1}>Last hour</MenuItem>
|
||||
<MenuItem value={24}>Last 24h</MenuItem>
|
||||
<MenuItem value={168}>Last 7d</MenuItem>
|
||||
<MenuItem value={720}>Last 30d</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Provider"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Endpoint"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
<Paper sx={{ height: 640 }}>
|
||||
<DataGrid
|
||||
rows={data?.items ?? []}
|
||||
getRowId={(row) => row.id}
|
||||
columns={columns}
|
||||
loading={isLoading || isFetching}
|
||||
paginationMode="server"
|
||||
rowCount={data?.total ?? data?.items?.length ?? 0}
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
onRowClick={(p) => setDrawerId(p.row.id as number)}
|
||||
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<HistoryDetailDrawer id={drawerId} onClose={() => setDrawerId(null)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
import type { ProvidersStatsResponse } from '@/types/insights';
|
||||
|
||||
export default function Providers() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['providers'],
|
||||
queryFn: () => apiGet<ProvidersStatsResponse>('/api/stats/providers'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) return <Alert severity="error">{String(error)}</Alert>;
|
||||
if (!data) return null;
|
||||
|
||||
const ageSec = (data as unknown as { age_seconds?: number | null })?.age_seconds ?? null;
|
||||
const ageLabel = ageSec !== null ? `cache ${Math.round(ageSec)}s old` : null;
|
||||
const ageColor: 'default' | 'warning' | 'error' = ageSec === null ? 'default' : ageSec > 120 ? 'warning' : 'default';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="h1">Providers</Typography>
|
||||
{ageLabel && <Chip label={ageLabel} size="small" variant="outlined" color={ageColor} />}
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Live quota + health for all configured upstream providers. Auto-refreshes every 30s.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{data.providers.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.name}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
{p.healthy === true ? (
|
||||
<HealthAndSafetyIcon fontSize="small" color="success" />
|
||||
) : p.healthy === false ? (
|
||||
<ErrorOutlineIcon fontSize="small" color="error" />
|
||||
) : null}
|
||||
<Typography variant="h3">{p.display_name}</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Chip label={p.kind} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{p.message && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={p.healthy === false ? 'error.main' : 'text.secondary'}
|
||||
sx={{ display: 'block', mb: 1 }}
|
||||
>
|
||||
{p.message}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{p.quota_limit ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption">
|
||||
{(() => {
|
||||
const isUsd = (p.quota_unit ?? '').toLowerCase() === 'usd';
|
||||
const fmt = (n: number) =>
|
||||
isUsd
|
||||
? `$${n.toFixed(2)}`
|
||||
: n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return (
|
||||
<>
|
||||
{fmt(p.quota_used ?? 0)} / {fmt(p.quota_limit)}
|
||||
{!isUsd && p.quota_unit ? ` ${p.quota_unit}` : ''}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(p.quota_percent_used ?? 0).toFixed(1)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, p.quota_percent_used ?? 0)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor:
|
||||
(p.quota_percent_used ?? 0) > 90
|
||||
? 'error.main'
|
||||
: (p.quota_percent_used ?? 0) > 75
|
||||
? 'warning.main'
|
||||
: 'success.main',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No quota tracking
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{p.plan_price_monthly !== null && p.plan_price_monthly !== undefined && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Plan: ${p.plan_price_monthly}/month
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
import { useState } from 'react';
|
||||
import { Alert, Box, Button, Card, CardContent, Chip, CircularProgress, IconButton, Stack, Tab, Table, TableBody, TableCell, TableHead, TableRow, Tabs, Tooltip, Typography } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ModuleConfigForm from '@/components/ModuleConfigForm';
|
||||
import { apiGet } from '@/api/client';
|
||||
|
||||
type Tier = 'live' | 'restart' | 'mixed';
|
||||
|
||||
interface ModuleMeta {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/**
|
||||
* Whether overrides land instantly (live) or require restart, or mixed.
|
||||
* Influences the banner shown at the top of the Config tab.
|
||||
*/
|
||||
tier: Tier;
|
||||
categoryOrder?: string[];
|
||||
}
|
||||
|
||||
const MODULES: Record<string, ModuleMeta> = {
|
||||
web: {
|
||||
title: 'Web Search',
|
||||
subtitle:
|
||||
'Free (SearXNG) + premium (Brave/Tavily/SerpAPI/LinkUp/Exa) routing, evidence packing, brain integration.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: [
|
||||
'providers',
|
||||
'routing',
|
||||
'tiers',
|
||||
'llm',
|
||||
'search',
|
||||
'fetch',
|
||||
'browse',
|
||||
'vision',
|
||||
'evidence',
|
||||
'gather',
|
||||
'context',
|
||||
'brain',
|
||||
'rate_limit',
|
||||
'concurrency',
|
||||
'log',
|
||||
],
|
||||
},
|
||||
llm: {
|
||||
title: 'LLM Inference',
|
||||
subtitle: 'Router for LiteLLM / vLLM / llama.cpp. Backend selection + retry config.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['backend', 'timeouts', 'retries', 'rate_limit', 'concurrency', 'llamacpp', 'log'],
|
||||
},
|
||||
embeddings: {
|
||||
title: 'Embeddings',
|
||||
subtitle: 'BGE-M3 1024-dim via vLLM/llama.cpp. Used by didi-brain.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['backend', 'timeouts', 'rate_limit', 'concurrency', 'log'],
|
||||
},
|
||||
rerank: {
|
||||
title: 'Rerank',
|
||||
subtitle: 'BGE-reranker-v2-m3 cross-encoder for two-stage retrieval.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['backend', 'timeouts', 'rate_limit', 'concurrency', 'log'],
|
||||
},
|
||||
audio: {
|
||||
title: 'Audio (Whisper)',
|
||||
subtitle: 'faster-whisper transcription. Model load is restart-only.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['whisper', 'upload', 'log'],
|
||||
},
|
||||
video: {
|
||||
title: 'Video Analysis',
|
||||
subtitle: 'BusterX deepfake + Qwen3-VL semantic. Prompts editable live.',
|
||||
tier: 'live',
|
||||
categoryOrder: ['frames', 'inference', 'semantic', 'prompts', 'log'],
|
||||
},
|
||||
catalog: {
|
||||
title: 'Catalog API',
|
||||
subtitle: 'Service registry: aggregates /v1/info + OpenAPI from all modules.',
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['discovery', 'log'],
|
||||
},
|
||||
brain: {
|
||||
title: 'Brain (Cache)',
|
||||
subtitle: 'Verification cache TTL + atom tier policy.',
|
||||
// Mixed: atom.* and log.level apply live (RuntimeConfigClient polls every 30s),
|
||||
// verification_cache.* are env-only (restart required to pick up the change).
|
||||
tier: 'mixed',
|
||||
categoryOrder: ['verification_cache', 'atom', 'log'],
|
||||
},
|
||||
gateway: {
|
||||
title: 'Gateway',
|
||||
subtitle: 'Read-only — nginx token + route table. Static config; rotate via deploy.',
|
||||
tier: 'restart',
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
moduleId: string;
|
||||
}
|
||||
|
||||
function tierBanner(tier: Tier): { kind: 'success' | 'info' | 'warning'; text: string } {
|
||||
if (tier === 'live')
|
||||
return {
|
||||
kind: 'success',
|
||||
text: 'All keys here apply live within 30s of save (no restart).',
|
||||
};
|
||||
if (tier === 'restart')
|
||||
return {
|
||||
kind: 'warning',
|
||||
text: 'Most keys here require a service restart to take effect — flagged inline.',
|
||||
};
|
||||
return {
|
||||
kind: 'info',
|
||||
text: 'Mix of live + restart-required keys. Restart-required ones are flagged inline.',
|
||||
};
|
||||
}
|
||||
|
||||
export default function ModulePage({ moduleId }: Props) {
|
||||
const meta = MODULES[moduleId];
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
if (!meta) {
|
||||
return <Alert severity="error">Unknown module: {moduleId}</Alert>;
|
||||
}
|
||||
|
||||
const banner = tierBanner(meta.tier);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
|
||||
<Typography variant="h1">{meta.title}</Typography>
|
||||
<Chip label={moduleId} size="small" sx={{ fontFamily: 'monospace' }} />
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{meta.subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Config" />
|
||||
<Tab label="Live State" />
|
||||
<Tab label="Actions" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Box>
|
||||
<Alert severity={banner.kind} sx={{ mb: 2 }}>
|
||||
{banner.text}
|
||||
</Alert>
|
||||
{moduleId === 'gateway' ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 1 }}>
|
||||
Gateway is static config
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
The nginx config + bearer token live in deploy/.env and are baked at container
|
||||
start. No live-editable knobs are exposed in the dashboard schema. Token rotation
|
||||
+ route changes require a deploy.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ModuleConfigForm moduleId={moduleId} categoryOrder={meta.categoryOrder} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && <LiveStateTab moduleId={moduleId} />}
|
||||
|
||||
{tab === 2 && <ActionsTab moduleId={moduleId} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModuleHealth {
|
||||
module: string;
|
||||
url: string;
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
http_status?: number;
|
||||
latency_ms: number;
|
||||
error?: string | null;
|
||||
info?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function statusColor(s: string): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (s === 'healthy') return 'success';
|
||||
if (s === 'degraded') return 'warning';
|
||||
if (s === 'down') return 'error';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === 'healthy') return <CheckCircleIcon color="success" />;
|
||||
if (status === 'degraded') return <WarningIcon color="warning" />;
|
||||
return <ErrorIcon color="error" />;
|
||||
}
|
||||
|
||||
function LiveStateTab({ moduleId }: { moduleId: string }) {
|
||||
const health = useQuery({
|
||||
queryKey: ['module_health', moduleId],
|
||||
queryFn: () => apiGet<ModuleHealth>(`/api/proxy/${moduleId}/health`),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h3">Live State</Typography>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={() => health.refetch()} disabled={health.isFetching}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{health.isLoading && <CircularProgress size={20} />}
|
||||
{health.isError && <Alert severity="error">{String(health.error)}</Alert>}
|
||||
{health.data && (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<StatusIcon status={health.data.status} />
|
||||
<Box>
|
||||
<Typography variant="h2" sx={{ textTransform: 'capitalize' }}>{health.data.status}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{health.data.latency_ms}ms
|
||||
{health.data.http_status ? ` · HTTP ${health.data.http_status}` : ''}
|
||||
{' · auto-refresh 10s'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Endpoint: <code>{health.data.url}</code>
|
||||
</Typography>
|
||||
|
||||
{health.data.error && (
|
||||
<Alert severity={statusColor(health.data.status) === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">{health.data.error}</Typography>
|
||||
{(health.data.error.includes('name resolution') || health.data.error.includes('Connection refused') || health.data.error.includes('timeout')) && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Service not reachable. To override the default URL, set environment variable
|
||||
<code style={{ marginLeft: 4 }}>DASHBOARD_{moduleId.toUpperCase()}_HEALTH_URL=http://<host>:<port>/v1/info</code>
|
||||
in dashboard's deploy/.env and redeploy.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{health.data.info && Object.keys(health.data.info).length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Module info from <code>/v1/info</code>
|
||||
</Typography>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ '&:last-child': { pb: 2 } }}>
|
||||
<Box component="pre" sx={{ fontSize: 11, fontFamily: 'monospace', whiteSpace: 'pre-wrap', m: 0, maxHeight: 300, overflow: 'auto' }}>
|
||||
{JSON.stringify(health.data.info, null, 2)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
value: unknown;
|
||||
default: unknown;
|
||||
is_override: boolean;
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
}
|
||||
interface ConfigResponse {
|
||||
items: Record<string, ConfigItem>;
|
||||
schema: Record<string, { module: string; restart_required?: boolean; label?: string }>;
|
||||
}
|
||||
|
||||
function ActionsTab({ moduleId }: { moduleId: string }) {
|
||||
const config = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiGet<ConfigResponse>('/api/config'),
|
||||
});
|
||||
|
||||
// Find keys for THIS module that are overridden AND require restart
|
||||
const pendingRestarts = config.data
|
||||
? Object.entries(config.data.items)
|
||||
.filter(([k, v]) => {
|
||||
const sch = config.data!.schema[k];
|
||||
return sch && sch.module === moduleId && v.is_override && sch.restart_required === true;
|
||||
})
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: config.data!.schema[k].label ?? k,
|
||||
value: v.value,
|
||||
default: v.default,
|
||||
updated_at: v.updated_at,
|
||||
updated_by: v.updated_by,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const swarmCmd = `sudo docker service update --force <stack>_${moduleId}`;
|
||||
|
||||
const copyCmd = () => {
|
||||
navigator.clipboard.writeText(swarmCmd).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ mb: 1 }}>Pending Restarts & Actions</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Keys flagged <code>restart_required=true</code> need a container restart for the change to take effect. Live keys take effect immediately — nothing to do here for those.
|
||||
</Typography>
|
||||
|
||||
{config.isLoading && <CircularProgress size={20} />}
|
||||
{config.data && pendingRestarts.length === 0 && (
|
||||
<Alert severity="success" icon={<CheckCircleIcon />}>
|
||||
No pending restart-required overrides for this module.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pendingRestarts.length > 0 && (
|
||||
<>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{pendingRestarts.length} override{pendingRestarts.length > 1 ? 's' : ''} waiting for container restart.
|
||||
</Alert>
|
||||
<Table size="small" sx={{ mb: 2 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key</TableCell>
|
||||
<TableCell>New value</TableCell>
|
||||
<TableCell>Default</TableCell>
|
||||
<TableCell>Set by</TableCell>
|
||||
<TableCell>Set at</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{pendingRestarts.map((p) => (
|
||||
<TableRow key={p.key}>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{p.key}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{String(p.value).slice(0, 50)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{String(p.default).slice(0, 50)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="caption">{p.updated_by ?? '—'}</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption">{p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}</Typography></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 3, p: 2, bgcolor: 'background.default', borderRadius: 1, border: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Manual restart command (run on Swarm manager — replace <code><stack></code> with your Docker stack name)
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box component="code" sx={{ fontFamily: 'monospace', fontSize: 13, flex: 1, p: 1, bgcolor: 'background.paper', borderRadius: 0.5 }}>
|
||||
{swarmCmd}
|
||||
</Box>
|
||||
<Tooltip title="Copy">
|
||||
<IconButton size="small" onClick={copyCmd}><ContentCopyIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Restart triggers a rolling update — typically <30s downtime per replica. After completion, run a config check by visiting the Live State tab.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{config.data && (
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={() => config.refetch()}>
|
||||
Refresh pending list
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
322
ai_platform/modules/dashboard/web/src/pages/operations/Live.tsx
Normal file
322
ai_platform/modules/dashboard/web/src/pages/operations/Live.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { Box, Card, CardContent, Chip, CircularProgress, Grid, LinearProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from '@/api/client';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
|
||||
const REFRESH_MS = 5_000;
|
||||
|
||||
interface SummaryResponse {
|
||||
window_hours: number;
|
||||
total_requests: number;
|
||||
total_errors: number;
|
||||
error_rate: number;
|
||||
avg_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
created_at: string;
|
||||
endpoint: string;
|
||||
provider: string;
|
||||
tier: string;
|
||||
status_code: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number | null;
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
items: HistoryItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProviderStatItem {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
kind?: string;
|
||||
healthy: boolean | null;
|
||||
quota_percent_used?: number | null;
|
||||
quota_used?: number | null;
|
||||
quota_limit?: number | null;
|
||||
quota_unit?: string | null;
|
||||
plan_name?: string | null;
|
||||
plan_price_monthly?: number | null;
|
||||
message?: string | null;
|
||||
last_error?: string | null;
|
||||
}
|
||||
|
||||
interface ProvidersResponse {
|
||||
providers: ProviderStatItem[];
|
||||
last_refresh?: string | null;
|
||||
age_seconds?: number | null;
|
||||
cache_ttl_seconds?: number;
|
||||
}
|
||||
|
||||
interface TimelinePoint { hour: string; tier: string; count: number; avg_ms: number; }
|
||||
interface TimelineResponse { timeline: TimelinePoint[]; window_hours: number; }
|
||||
|
||||
function statusColor(code: number): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (!code) return 'default';
|
||||
if (code >= 500) return 'error';
|
||||
if (code >= 400) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function ageSec(iso: string): string {
|
||||
const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
return `${Math.floor(s / 3600)}h ago`;
|
||||
}
|
||||
|
||||
function KpiTile({ label, value, color, hint }: { label: string; value: React.ReactNode; color?: string; hint?: string }) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="h2" sx={{ mt: 0.5, color: color ?? 'primary.main' }}>{value}</Typography>
|
||||
{hint && <Typography variant="caption" color="text.secondary">{hint}</Typography>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveStatus() {
|
||||
const summary = useQuery({
|
||||
queryKey: ['live', 'summary', 1],
|
||||
queryFn: () => apiGet<SummaryResponse>('/api/stats/summary?hours=1'),
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
|
||||
const recent = useQuery({
|
||||
queryKey: ['live', 'history', 20],
|
||||
queryFn: () => apiGet<HistoryResponse>('/api/history?limit=20&offset=0'),
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
|
||||
const providers = useQuery({
|
||||
queryKey: ['live', 'providers'],
|
||||
queryFn: () => apiGet<ProvidersResponse>('/api/stats/providers'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const timeline = useQuery({
|
||||
queryKey: ['live', 'timeline', 6],
|
||||
queryFn: () => apiGet<TimelineResponse>('/api/stats/timeline?hours=6'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const errorRatePct = summary.data?.error_rate !== undefined ? (summary.data.error_rate * 100).toFixed(1) : '—';
|
||||
const errorColor = (summary.data?.error_rate ?? 0) > 0.05 ? 'error.main' : 'success.main';
|
||||
|
||||
// Aggregate timeline (sum per hour across tiers)
|
||||
const timelineByHour: Record<string, number> = {};
|
||||
timeline.data?.timeline.forEach((p) => {
|
||||
const h = p.hour ?? 'unknown';
|
||||
timelineByHour[h] = (timelineByHour[h] ?? 0) + p.count;
|
||||
});
|
||||
const timelineEntries = Object.entries(timelineByHour).sort(([a], [b]) => a.localeCompare(b)).slice(-12);
|
||||
const maxCount = Math.max(1, ...timelineEntries.map(([, c]) => c));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<MonitorHeartIcon color="primary" />
|
||||
<Typography variant="h1">Live Status</Typography>
|
||||
<Chip label={`refresh ${REFRESH_MS / 1000}s`} size="small" variant="outlined" sx={{ ml: 1 }} />
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Real-time view of platform activity (last 1 hour). Refreshes every {REFRESH_MS / 1000}s.
|
||||
</Typography>
|
||||
|
||||
{/* KPI tiles — last 1h */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<KpiTile
|
||||
label="Requests (1h)"
|
||||
value={summary.isLoading ? <CircularProgress size={20} /> : (summary.data?.total_requests ?? 0).toLocaleString()}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<KpiTile
|
||||
label="Error rate (1h)"
|
||||
value={summary.isLoading ? <CircularProgress size={20} /> : `${errorRatePct}%`}
|
||||
color={errorColor}
|
||||
hint={`${summary.data?.total_errors ?? 0} errors`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<KpiTile
|
||||
label="Avg duration (1h)"
|
||||
value={summary.isLoading ? <CircularProgress size={20} /> : `${Math.round(summary.data?.avg_duration_ms ?? 0)} ms`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<KpiTile
|
||||
label="Cost (1h)"
|
||||
value={summary.isLoading ? <CircularProgress size={20} /> : `$${(summary.data?.total_cost_usd ?? 0).toFixed(4)}`}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Provider health column */}
|
||||
<Grid size={{ xs: 12, md: 5 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 2 }}>
|
||||
<Typography variant="h2">Provider Health</Typography>
|
||||
{providers.data?.age_seconds !== undefined && providers.data?.age_seconds !== null && (
|
||||
<Chip
|
||||
label={`refreshed ${Math.round(providers.data.age_seconds)}s ago`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={providers.data.age_seconds > 120 ? 'warning' : 'default'}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{providers.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
{(providers.data?.providers ?? []).map((p) => {
|
||||
const statusLabel = p.healthy === true ? 'healthy' : p.healthy === false ? 'down' : 'unknown';
|
||||
const statusColor: 'success' | 'error' | 'default' = p.healthy === true ? 'success' : p.healthy === false ? 'error' : 'default';
|
||||
const quotaPct = p.quota_percent_used;
|
||||
const quotaColor: 'error' | 'warning' | 'primary' = (quotaPct ?? 0) >= 90 ? 'error' : (quotaPct ?? 0) >= 75 ? 'warning' : 'primary';
|
||||
return (
|
||||
<Box key={p.name} sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{p.display_name ?? p.name}</Typography>
|
||||
<Chip label={statusLabel} color={statusColor} size="small" />
|
||||
{p.plan_name && <Typography variant="caption" color="text.secondary">{p.plan_name}</Typography>}
|
||||
</Stack>
|
||||
{quotaPct !== null && quotaPct !== undefined && (
|
||||
<Typography variant="caption" color="text.secondary">{quotaPct.toFixed(0)}%</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{quotaPct !== null && quotaPct !== undefined && (
|
||||
<LinearProgress variant="determinate" value={Math.min(100, quotaPct)} color={quotaColor} sx={{ height: 6, borderRadius: 3 }} />
|
||||
)}
|
||||
{(p.last_error || p.message) && (
|
||||
<Typography variant="caption" color={p.healthy === false ? 'error.main' : 'text.secondary'}>
|
||||
{p.last_error ?? p.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{(providers.data?.providers ?? []).length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">No provider data.</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Throughput sparkline (last 6h) */}
|
||||
<Card sx={{ mt: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Throughput (last 6h)</Typography>
|
||||
{timeline.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : timelineEntries.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No traffic.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 0.5, height: 80 }}>
|
||||
{timelineEntries.map(([hour, count]) => {
|
||||
const heightPct = (count / maxCount) * 100;
|
||||
const label = new Date(hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return (
|
||||
<Box key={hour} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Box
|
||||
title={`${label}: ${count} req`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${Math.max(2, heightPct)}%`,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '2px 2px 0 0',
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, mt: 0.5, color: 'text.secondary' }}>
|
||||
{label.slice(0, 2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Recent activity feed */}
|
||||
<Grid size={{ xs: 12, md: 7 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const items = recent.data?.items ?? [];
|
||||
const newestAge = items.length > 0 ? (Date.now() - new Date(items[0].created_at).getTime()) / 1000 : null;
|
||||
const allStale = items.length > 0 && newestAge !== null && newestAge > 3600;
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h2">Latest 20 requests</Typography>
|
||||
{allStale && (
|
||||
<Chip label="all >1h old — no recent traffic" size="small" color="warning" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{recent.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (recent.data?.items ?? []).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No requests logged yet — modules may not be ingesting events to /api/ingest/event.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Endpoint</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Tier</TableCell>
|
||||
<TableCell align="right">Status</TableCell>
|
||||
<TableCell align="right">Duration</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(recent.data?.items ?? []).map((it) => (
|
||||
<TableRow key={it.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{ageSec(it.created_at)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{it.endpoint}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{it.provider || '—'}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={it.tier} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip label={it.status_code || '—'} size="small" color={statusColor(it.status_code)} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="caption">{it.duration_ms}ms</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import { apiGet } from '@/api/client';
|
||||
import KpiTile from '@/components/KpiTile';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
|
||||
const REFRESH_MS = 5_000;
|
||||
const LATENCY_REFRESH_MS = 15_000;
|
||||
|
||||
interface ServiceHealth {
|
||||
module: string;
|
||||
url: string;
|
||||
status: 'healthy' | 'degraded' | 'down' | string;
|
||||
http_status?: number;
|
||||
latency_ms?: number;
|
||||
info?: { name?: string; models?: unknown; status?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
interface ServicesResponse {
|
||||
services: ServiceHealth[];
|
||||
summary: { healthy: number; degraded: number; down: number; total: number };
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
name: string;
|
||||
vhost: string;
|
||||
messages: number;
|
||||
ready: number;
|
||||
unacked: number;
|
||||
consumers: number;
|
||||
state?: string;
|
||||
}
|
||||
interface QueuesResponse {
|
||||
enabled: boolean;
|
||||
queues: QueueItem[];
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface LatencyItem {
|
||||
job: string;
|
||||
p50_ms: number | null;
|
||||
p90_ms: number | null;
|
||||
p99_ms: number | null;
|
||||
}
|
||||
interface LatencyResponse {
|
||||
enabled: boolean;
|
||||
services: LatencyItem[];
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function modelHint(info: ServiceHealth['info']): string | undefined {
|
||||
if (!info) return undefined;
|
||||
if (typeof info.name === 'string') return info.name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fmtMs(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `${Math.round(v)} ms`;
|
||||
}
|
||||
|
||||
export default function Monitoring() {
|
||||
const services = useQuery({
|
||||
queryKey: ['monitoring', 'services'],
|
||||
queryFn: () => apiGet<ServicesResponse>('/api/monitoring/services'),
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
const queues = useQuery({
|
||||
queryKey: ['monitoring', 'queues'],
|
||||
queryFn: () => apiGet<QueuesResponse>('/api/monitoring/queues'),
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
const latency = useQuery({
|
||||
queryKey: ['monitoring', 'latency'],
|
||||
queryFn: () => apiGet<LatencyResponse>('/api/monitoring/latency'),
|
||||
refetchInterval: LATENCY_REFRESH_MS,
|
||||
});
|
||||
|
||||
const sum = services.data?.summary;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<MonitorHeartIcon color="primary" />
|
||||
<Typography variant="h1">AI Monitoring</Typography>
|
||||
<Chip label={`refresh ${REFRESH_MS / 1000}s`} size="small" variant="outlined" sx={{ ml: 1 }} />
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Central health of all AI services, message-queue depth and request latency percentiles.
|
||||
</Typography>
|
||||
|
||||
{/* Roll-up KPI tiles */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<KpiTile label="Services healthy" color="success.main"
|
||||
value={services.isLoading ? <CircularProgress size={20} /> : `${sum?.healthy ?? 0}/${sum?.total ?? 0}`} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<KpiTile label="Degraded" color={(sum?.degraded ?? 0) > 0 ? 'warning.main' : undefined}
|
||||
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.degraded ?? 0)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<KpiTile label="Down" color={(sum?.down ?? 0) > 0 ? 'error.main' : undefined}
|
||||
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.down ?? 0)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<KpiTile label="Total services"
|
||||
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.total ?? 0)} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Service health grid */}
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Services</Typography>
|
||||
{services.isError && <Alert severity="error" sx={{ mb: 2 }}>Failed to load service health.</Alert>}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
{(services.data?.services ?? []).map((s) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={s.module}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>{s.module}</Typography>
|
||||
<StatusChip status={s.status} />
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{fmtMs(s.latency_ms)}{s.http_status ? ` · HTTP ${s.http_status}` : ''}
|
||||
</Typography>
|
||||
{modelHint(s.info) && (
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontFamily: 'monospace' }}>
|
||||
{modelHint(s.info)}
|
||||
</Typography>
|
||||
)}
|
||||
{s.error && (
|
||||
<Typography variant="caption" color="error.main" display="block">{s.error}</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
{!services.isLoading && (services.data?.services ?? []).length === 0 && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="body2" color="text.secondary">No services configured.</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Message queues */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Message Queues (RabbitMQ)</Typography>
|
||||
{queues.data && !queues.data.enabled ? (
|
||||
<Alert severity="info">Queue panel disabled — set <code>rabbitmq_mgmt_url</code>.</Alert>
|
||||
) : queues.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : queues.data?.error ? (
|
||||
<Alert severity="warning">RabbitMQ unreachable: {queues.data.error}</Alert>
|
||||
) : (queues.data?.queues ?? []).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No queues.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Queue</TableCell>
|
||||
<TableCell align="right">Ready</TableCell>
|
||||
<TableCell align="right">Unacked</TableCell>
|
||||
<TableCell align="right">Consumers</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(queues.data?.queues ?? []).map((q) => {
|
||||
const backlog = q.ready > 0 && q.consumers === 0;
|
||||
return (
|
||||
<TableRow key={`${q.vhost}/${q.name}`} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{q.name}</Typography>
|
||||
{backlog && <Chip label="no consumers" size="small" color="warning" sx={{ ml: 1 }} />}
|
||||
</TableCell>
|
||||
<TableCell align="right">{q.ready}</TableCell>
|
||||
<TableCell align="right">{q.unacked}</TableCell>
|
||||
<TableCell align="right">{q.consumers}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Latency percentiles */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Request Latency (p50/p90/p99)</Typography>
|
||||
{latency.data && !latency.data.enabled ? (
|
||||
<Alert severity="info">Latency panel disabled — set <code>prometheus_url</code>.</Alert>
|
||||
) : latency.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : latency.data?.error ? (
|
||||
<Alert severity="warning">Prometheus unreachable: {latency.data.error}</Alert>
|
||||
) : (latency.data?.services ?? []).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No latency data.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Service</TableCell>
|
||||
<TableCell align="right">p50</TableCell>
|
||||
<TableCell align="right">p90</TableCell>
|
||||
<TableCell align="right">p99</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(latency.data?.services ?? []).map((l) => (
|
||||
<TableRow key={l.job} hover>
|
||||
<TableCell><Typography variant="caption">{l.job}</Typography></TableCell>
|
||||
<TableCell align="right">{fmtMs(l.p50_ms)}</TableCell>
|
||||
<TableCell align="right">{fmtMs(l.p90_ms)}</TableCell>
|
||||
<TableCell align="right">{fmtMs(l.p99_ms)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Audit log — tabs wrapper.
|
||||
*
|
||||
* - Dashboard tab: dashboard's own audit_log (config changes, archive
|
||||
* promotions, user CRUD). Sourced from /api/audit.
|
||||
* - Brain tab: brain_audit_log (cache mutations, fact_status changes,
|
||||
* judge decisions). Sourced from /api/brain/v1/cache/audit_log (Phase D2).
|
||||
*
|
||||
* Two distinct sources kept side-by-side so an admin can see the full
|
||||
* picture without context-switching pages.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
|
||||
import AuditLogDashboard from './AuditLogDashboard';
|
||||
import AuditLogBrain from './AuditLogBrain';
|
||||
|
||||
export default function AuditLog() {
|
||||
const [tab, setTab] = useState<'dashboard' | 'brain'>('dashboard');
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Audit log</Typography>
|
||||
</Stack>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v as 'dashboard' | 'brain')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab label="Dashboard" value="dashboard" />
|
||||
<Tab label="Brain" value="brain" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 'dashboard' && <AuditLogDashboard />}
|
||||
{tab === 'brain' && <AuditLogBrain />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
/**
|
||||
* Brain audit log viewer — Phase D2.
|
||||
*
|
||||
* Reads brain_audit_log via /v1/cache/audit_log. Shows every cache mutation:
|
||||
* judge decisions (KEEP/INVALIDATE/RECHECK), fact_status changes, mass
|
||||
* invalidations, gold promotions. Filters: action prefix, target table,
|
||||
* actor, since.
|
||||
*
|
||||
* Sibling of AuditLogDashboard.tsx (which reads dashboard's own audit
|
||||
* table) — both rendered via the AuditLog tabs wrapper.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
import type { AuditLogItem, AuditLogResponse } from '@/types/brain';
|
||||
|
||||
function fmt(s: string) {
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
// Color hint by action prefix — keeps the grid scannable.
|
||||
type ChipColor = 'primary' | 'warning' | 'success' | 'error' | 'default';
|
||||
|
||||
const ACTION_COLORS: Record<string, ChipColor> = {
|
||||
invalidate: 'warning',
|
||||
judge_invalidate: 'error',
|
||||
judge_keep_cache: 'success',
|
||||
judge_needs_full_recheck: 'primary',
|
||||
fact_truth_changed: 'warning',
|
||||
fact_truth_set: 'success',
|
||||
promote_gold: 'success',
|
||||
audit_demote: 'warning',
|
||||
};
|
||||
|
||||
function actionColor(action: string): ChipColor {
|
||||
if (action in ACTION_COLORS) return ACTION_COLORS[action];
|
||||
for (const [k, v] of Object.entries(ACTION_COLORS)) {
|
||||
if (action.startsWith(k)) return v;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function jsonPreview(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'string') return v.length > 80 ? v.slice(0, 77) + '…' : v;
|
||||
const s = JSON.stringify(v);
|
||||
return s.length > 80 ? s.slice(0, 77) + '…' : s;
|
||||
}
|
||||
|
||||
const ACTION_PRESETS = [
|
||||
{ value: 'all', label: 'All actions' },
|
||||
{ value: 'invalidate', label: 'Mass invalidations' },
|
||||
{ value: 'judge_', label: 'Judge decisions (any)' },
|
||||
{ value: 'judge_invalidate', label: 'Judge → INVALIDATE' },
|
||||
{ value: 'judge_keep_cache', label: 'Judge → KEEP' },
|
||||
{ value: 'fact_truth_', label: 'Fact truth changes' },
|
||||
{ value: 'promote_gold', label: 'Gold promotions' },
|
||||
];
|
||||
|
||||
const TARGET_PRESETS = [
|
||||
{ value: 'all', label: 'All targets' },
|
||||
{ value: 'brain_analysis_atom', label: 'Atoms' },
|
||||
{ value: 'brain_verification_cache', label: 'Verification cache' },
|
||||
{ value: 'brain_fact_status', label: 'Fact status' },
|
||||
{ value: 'multi', label: 'Multi (mass invalidate)' },
|
||||
];
|
||||
|
||||
export default function AuditLogBrain() {
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const [actionFilter, setActionFilter] = useState('all');
|
||||
const [targetFilter, setTargetFilter] = useState('all');
|
||||
const [actorFilter, setActorFilter] = useState('');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('page', String(pagination.page + 1));
|
||||
p.set('page_size', String(pagination.pageSize));
|
||||
if (actionFilter !== 'all') p.set('action', actionFilter);
|
||||
if (targetFilter !== 'all') p.set('target_table', targetFilter);
|
||||
if (actorFilter.trim()) p.set('actor', actorFilter.trim());
|
||||
return p.toString();
|
||||
}, [pagination, actionFilter, targetFilter, actorFilter]);
|
||||
|
||||
const { data, isLoading, error, isFetching, refetch } = useQuery({
|
||||
queryKey: ['brain', 'audit_log', params],
|
||||
queryFn: () => apiGet<AuditLogResponse>(`/api/brain/v1/cache/audit_log?${params}`),
|
||||
});
|
||||
|
||||
const columns: GridColDef<AuditLogItem>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'created_at',
|
||||
headerName: 'When',
|
||||
width: 170,
|
||||
renderCell: (p) => (
|
||||
<Typography variant="caption">{fmt(p.row.created_at)}</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
headerName: 'Action',
|
||||
width: 200,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.row.action}
|
||||
color={actionColor(p.row.action)}
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'target_table',
|
||||
headerName: 'Target',
|
||||
width: 200,
|
||||
renderCell: (p) => (
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{p.row.target_table}
|
||||
{p.row.target_id !== 'mass' && (
|
||||
<span style={{ opacity: 0.6 }}>:{p.row.target_id}</span>
|
||||
)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'actor',
|
||||
headerName: 'Actor',
|
||||
width: 160,
|
||||
renderCell: (p) => p.row.actor ?? '—',
|
||||
},
|
||||
{
|
||||
field: 'payload',
|
||||
headerName: 'Payload',
|
||||
flex: 1,
|
||||
minWidth: 280,
|
||||
sortable: false,
|
||||
renderCell: (p) => (
|
||||
<Tooltip title={<pre>{JSON.stringify(p.row.payload, null, 2)}</pre>}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', cursor: 'help' }}
|
||||
>
|
||||
{jsonPreview(p.row.payload)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">Brain cache + fact_status mutations</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
label="Action"
|
||||
size="small"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
{ACTION_PRESETS.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Target table"
|
||||
size="small"
|
||||
value={targetFilter}
|
||||
onChange={(e) => setTargetFilter(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{TARGET_PRESETS.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Actor (ILIKE)"
|
||||
size="small"
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(error as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={data?.items ?? []}
|
||||
getRowId={(r) => r.log_id}
|
||||
columns={columns}
|
||||
rowCount={data?.total ?? 0}
|
||||
loading={isLoading}
|
||||
paginationMode="server"
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet } from '@/api/client';
|
||||
import type { AuditEntry, AuditListResponse } from '@/types/insights';
|
||||
|
||||
function fmt(s: string) {
|
||||
return new Date(s).toLocaleString();
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, 'primary' | 'warning' | 'success' | 'error'> = {
|
||||
'config.set': 'primary',
|
||||
'config.delete': 'warning',
|
||||
'archive.promote': 'success',
|
||||
};
|
||||
|
||||
function jsonPreview(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'string') return v.length > 60 ? v.slice(0, 57) + '…' : v;
|
||||
const s = JSON.stringify(v);
|
||||
return s.length > 60 ? s.slice(0, 57) + '…' : s;
|
||||
}
|
||||
|
||||
export default function AuditLogDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [pagination, setPagination] = useState<GridPaginationModel>({ page: 0, pageSize: 25 });
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('all');
|
||||
|
||||
const { data, isLoading, error, isFetching } = useQuery({
|
||||
queryKey: ['audit', usernameFilter, actionFilter, pagination.page, pagination.pageSize],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(pagination.pageSize));
|
||||
params.set('offset', String(pagination.page * pagination.pageSize));
|
||||
if (usernameFilter.trim()) params.set('username', usernameFilter.trim());
|
||||
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||||
return apiGet<AuditListResponse>(`/api/audit?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: GridColDef<AuditEntry>[] = useMemo(
|
||||
() => [
|
||||
{ field: 'id', headerName: 'ID', width: 70 },
|
||||
{
|
||||
field: 'timestamp',
|
||||
headerName: 'When',
|
||||
width: 170,
|
||||
valueFormatter: (v: string) => fmt(v),
|
||||
},
|
||||
{ field: 'username', headerName: 'User', width: 130 },
|
||||
{
|
||||
field: 'action',
|
||||
headerName: 'Action',
|
||||
width: 160,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
label={p.value}
|
||||
size="small"
|
||||
color={ACTION_COLORS[p.value as string] ?? 'default'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'target',
|
||||
headerName: 'Target',
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
renderCell: (p) => (
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{p.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'old_value',
|
||||
headerName: 'Old',
|
||||
width: 200,
|
||||
renderCell: (p) => (
|
||||
<Tooltip title={JSON.stringify(p.value, null, 2)}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{jsonPreview(p.value)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'new_value',
|
||||
headerName: 'New',
|
||||
width: 200,
|
||||
renderCell: (p) => (
|
||||
<Tooltip title={JSON.stringify(p.value, null, 2)}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{jsonPreview(p.value)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="h1">Audit Log</Typography>
|
||||
<Chip label={`${data?.total ?? '…'} entries`} size="small" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['audit'] })}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
select
|
||||
label="Action"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="config.set">config.set</MenuItem>
|
||||
<MenuItem value="config.delete">config.delete</MenuItem>
|
||||
<MenuItem value="config.reset">config.reset</MenuItem>
|
||||
<MenuItem value="archive.promote">archive.promote</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Username (exact)"
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
<Paper sx={{ height: 640 }}>
|
||||
<DataGrid
|
||||
rows={data?.items ?? []}
|
||||
getRowId={(row) => row.id}
|
||||
columns={columns}
|
||||
loading={isLoading || isFetching}
|
||||
paginationMode="server"
|
||||
rowCount={data?.total ?? 0}
|
||||
paginationModel={pagination}
|
||||
onPaginationModelChange={setPagination}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
200
ai_platform/modules/dashboard/web/src/pages/system/Catalog.tsx
Normal file
200
ai_platform/modules/dashboard/web/src/pages/system/Catalog.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import HubIcon from '@mui/icons-material/Hub';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiDelete, apiGet, apiPost } from '@/api/client';
|
||||
|
||||
interface CatalogEntry {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
service: string;
|
||||
context_length: number | null;
|
||||
supports_cpu: boolean;
|
||||
supports_gpu: boolean;
|
||||
quantization: string | null;
|
||||
endpoint: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
interface CatalogResponse { items: CatalogEntry[]; total: number; }
|
||||
|
||||
const SERVICES = ['llm', 'embeddings', 'rerank', 'audio', 'video', 'extractors', 'web'];
|
||||
const KINDS = ['model', 'extractor'];
|
||||
|
||||
const EMPTY = {
|
||||
kind: 'model', name: '', display_name: '', service: 'llm',
|
||||
context_length: '', quantization: '', endpoint: '',
|
||||
supports_cpu: false, supports_gpu: true,
|
||||
};
|
||||
|
||||
export default function Catalog() {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['catalog'],
|
||||
queryFn: () => apiGet<CatalogResponse>('/api/catalog'),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: unknown) => apiPost('/api/catalog', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['catalog'] }); setOpen(false); setForm({ ...EMPTY }); setError(null); },
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => apiDelete(`/api/catalog/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }),
|
||||
});
|
||||
|
||||
function submit() {
|
||||
setError(null);
|
||||
create.mutate({
|
||||
kind: form.kind,
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim() || null,
|
||||
service: form.service,
|
||||
context_length: form.context_length ? Number(form.context_length) : null,
|
||||
quantization: form.quantization.trim() || null,
|
||||
endpoint: form.endpoint.trim() || null,
|
||||
supports_cpu: form.supports_cpu,
|
||||
supports_gpu: form.supports_gpu,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<HubIcon color="primary" />
|
||||
<Typography variant="h1">Model & Extractor Catalog</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
DB-backed catalog of AI models and extractors. Create, edit and disable entries.
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ ...EMPTY }); setError(null); setOpen(true); }}>
|
||||
Add entry
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{list.isError && <Alert severity="error">Failed to load catalog.</Alert>}
|
||||
{list.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (list.data?.items ?? []).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No entries yet.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Kind</TableCell>
|
||||
<TableCell>Service</TableCell>
|
||||
<TableCell align="right">Context</TableCell>
|
||||
<TableCell>Device</TableCell>
|
||||
<TableCell>Quant</TableCell>
|
||||
<TableCell>Enabled</TableCell>
|
||||
<TableCell align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(list.data?.items ?? []).map((e) => (
|
||||
<TableRow key={e.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{e.display_name || e.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{e.name}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={e.kind} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{e.service}</TableCell>
|
||||
<TableCell align="right">{e.context_length ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
{e.supports_gpu && <Chip label="GPU" size="small" sx={{ mr: 0.5 }} />}
|
||||
{e.supports_cpu && <Chip label="CPU" size="small" variant="outlined" />}
|
||||
</TableCell>
|
||||
<TableCell>{e.quantization ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={e.enabled ? 'yes' : 'no'} size="small" color={e.enabled ? 'success' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => remove.mutate(e.id)} disabled={remove.isPending}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Add catalog entry</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField select label="Kind" value={form.kind} onChange={(ev) => setForm({ ...form, kind: ev.target.value })} fullWidth>
|
||||
{KINDS.map((k) => <MenuItem key={k} value={k}>{k}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select label="Service" value={form.service} onChange={(ev) => setForm({ ...form, service: ev.target.value })} fullWidth>
|
||||
{SERVICES.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
<TextField label="Name (id)" value={form.name} onChange={(ev) => setForm({ ...form, name: ev.target.value })} required fullWidth />
|
||||
<TextField label="Display name" value={form.display_name} onChange={(ev) => setForm({ ...form, display_name: ev.target.value })} fullWidth />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField label="Context length" type="number" value={form.context_length} onChange={(ev) => setForm({ ...form, context_length: ev.target.value })} fullWidth />
|
||||
<TextField label="Quantization" value={form.quantization} onChange={(ev) => setForm({ ...form, quantization: ev.target.value })} fullWidth />
|
||||
</Stack>
|
||||
<TextField label="Endpoint" value={form.endpoint} onChange={(ev) => setForm({ ...form, endpoint: ev.target.value })} fullWidth />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControlLabel control={<Checkbox checked={form.supports_gpu} onChange={(ev) => setForm({ ...form, supports_gpu: ev.target.checked })} />} label="GPU" />
|
||||
<FormControlLabel control={<Checkbox checked={form.supports_cpu} onChange={(ev) => setForm({ ...form, supports_cpu: ev.target.checked })} />} label="CPU" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={submit} disabled={!form.name.trim() || create.isPending}>
|
||||
{create.isPending ? 'Saving…' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SchemaIcon from '@mui/icons-material/Schema';
|
||||
|
||||
import { apiGet, apiPut, apiDelete } from '@/api/client';
|
||||
|
||||
interface SchemaMetadata {
|
||||
type: 'bool' | 'int' | 'float' | 'string' | 'enum' | 'csv' | 'text';
|
||||
default: unknown;
|
||||
module: string;
|
||||
category?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
restart_required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface SchemaOverrideRow {
|
||||
key: string;
|
||||
metadata: SchemaMetadata;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse { overrides: SchemaOverrideRow[]; }
|
||||
|
||||
const TYPES: SchemaMetadata['type'][] = ['bool', 'int', 'float', 'string', 'enum', 'csv', 'text'];
|
||||
const MODULES = ['web', 'llm', 'embeddings', 'rerank', 'audio', 'video', 'catalog', 'brain', 'system'];
|
||||
|
||||
interface FormState {
|
||||
key: string;
|
||||
type: SchemaMetadata['type'];
|
||||
defaultStr: string;
|
||||
module: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
restart_required: boolean;
|
||||
min: string;
|
||||
max: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
key: '',
|
||||
type: 'string',
|
||||
defaultStr: '',
|
||||
module: 'web',
|
||||
category: '',
|
||||
label: '',
|
||||
description: '',
|
||||
restart_required: false,
|
||||
min: '',
|
||||
max: '',
|
||||
options: '',
|
||||
};
|
||||
|
||||
function formToMetadata(f: FormState): { metadata: SchemaMetadata; error?: string } {
|
||||
if (!f.key.trim()) return { metadata: {} as SchemaMetadata, error: 'Key is required' };
|
||||
if (!/^[a-z][a-z0-9._-]*$/.test(f.key)) return { metadata: {} as SchemaMetadata, error: 'Key must be lowercase, dot/underscore/hyphen separated' };
|
||||
|
||||
let parsedDefault: unknown = f.defaultStr;
|
||||
if (f.type === 'bool') parsedDefault = f.defaultStr.toLowerCase() === 'true';
|
||||
else if (f.type === 'int') {
|
||||
const n = parseInt(f.defaultStr, 10);
|
||||
if (isNaN(n)) return { metadata: {} as SchemaMetadata, error: 'Default must be an integer' };
|
||||
parsedDefault = n;
|
||||
} else if (f.type === 'float') {
|
||||
const n = parseFloat(f.defaultStr);
|
||||
if (isNaN(n)) return { metadata: {} as SchemaMetadata, error: 'Default must be a number' };
|
||||
parsedDefault = n;
|
||||
} else if (f.type === 'csv') {
|
||||
parsedDefault = f.defaultStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const meta: SchemaMetadata = {
|
||||
type: f.type,
|
||||
default: parsedDefault,
|
||||
module: f.module,
|
||||
restart_required: f.restart_required,
|
||||
};
|
||||
if (f.category) meta.category = f.category;
|
||||
if (f.label) meta.label = f.label;
|
||||
if (f.description) meta.description = f.description;
|
||||
if ((f.type === 'int' || f.type === 'float') && f.min) meta.min = parseFloat(f.min);
|
||||
if ((f.type === 'int' || f.type === 'float') && f.max) meta.max = parseFloat(f.max);
|
||||
if (f.type === 'enum') {
|
||||
const opts = f.options.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (opts.length === 0) return { metadata: meta, error: 'Enum type requires at least one option' };
|
||||
meta.options = opts;
|
||||
}
|
||||
return { metadata: meta };
|
||||
}
|
||||
|
||||
function metadataToForm(key: string, m: SchemaMetadata): FormState {
|
||||
let defaultStr = '';
|
||||
if (m.default !== undefined && m.default !== null) {
|
||||
if (Array.isArray(m.default)) defaultStr = (m.default as unknown[]).join(', ');
|
||||
else defaultStr = String(m.default);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
type: m.type,
|
||||
defaultStr,
|
||||
module: m.module,
|
||||
category: m.category ?? '',
|
||||
label: m.label ?? '',
|
||||
description: m.description ?? '',
|
||||
restart_required: m.restart_required ?? false,
|
||||
min: m.min !== undefined ? String(m.min) : '',
|
||||
max: m.max !== undefined ? String(m.max) : '',
|
||||
options: (m.options ?? []).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export default function SchemaOverrides() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({
|
||||
queryKey: ['schema_overrides'],
|
||||
queryFn: () => apiGet<ListResponse>('/api/config/schema/_overrides'),
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: async ({ key, metadata }: { key: string; metadata: SchemaMetadata }) => {
|
||||
return apiPut(`/api/config/schema/${encodeURIComponent(key)}`, metadata);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['schema_overrides'] });
|
||||
qc.invalidateQueries({ queryKey: ['config'] });
|
||||
qc.invalidateQueries({ queryKey: ['settings', 'config'] });
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (key: string) => apiDelete(`/api/config/schema/${encodeURIComponent(key)}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['schema_overrides'] });
|
||||
qc.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingKey(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: SchemaOverrideRow) => {
|
||||
setEditingKey(row.key);
|
||||
setForm(metadataToForm(row.key, row.metadata));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const { metadata, error } = formToMetadata(form);
|
||||
if (error) {
|
||||
setFormError(error);
|
||||
return;
|
||||
}
|
||||
upsert.mutate({ key: form.key, metadata });
|
||||
};
|
||||
|
||||
const handleDelete = (key: string) => {
|
||||
if (window.confirm(`Delete runtime schema for "${key}"? Hardcoded schema (if any) will reappear.`)) {
|
||||
remove.mutate(key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<SchemaIcon color="primary" />
|
||||
<Typography variant="h1">Schema Overrides</Typography>
|
||||
</Stack>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate}>
|
||||
Register key
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Register or override config schema entries at runtime. Hardcoded keys still work — entries here augment or override them without redeploy.
|
||||
</Typography>
|
||||
|
||||
{list.isLoading && <CircularProgress />}
|
||||
{list.isError && <Alert severity="error">{String(list.error)}</Alert>}
|
||||
{list.data && (
|
||||
<Paper>
|
||||
{list.data.overrides.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">No runtime schema overrides yet. Click "Register key" to add one.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key</TableCell>
|
||||
<TableCell>Module</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Default</TableCell>
|
||||
<TableCell>Restart</TableCell>
|
||||
<TableCell>Updated</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list.data.overrides.map((row) => (
|
||||
<TableRow key={row.key} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{row.key}</Typography>
|
||||
{row.metadata.label && <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{row.metadata.label}</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={row.metadata.module} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={row.metadata.type} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{row.metadata.default === null ? 'null' : Array.isArray(row.metadata.default) ? `[${(row.metadata.default as unknown[]).length}]` : String(row.metadata.default).slice(0, 40)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.metadata.restart_required ? <Chip label="yes" size="small" color="warning" /> : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(row)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => handleDelete(row.key)} color="error"><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editingKey ? `Edit schema: ${editingKey}` : 'Register new schema key'}</DialogTitle>
|
||||
<DialogContent>
|
||||
{formError && <Alert severity="error" sx={{ mb: 2 }}>{formError}</Alert>}
|
||||
{upsert.isError && <Alert severity="error" sx={{ mb: 2 }}>{String(upsert.error)}</Alert>}
|
||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Key"
|
||||
value={form.key}
|
||||
disabled={editingKey !== null}
|
||||
onChange={(e) => setForm({ ...form, key: e.target.value })}
|
||||
helperText="lowercase, dot/underscore separated (e.g. web.providers.brave.enabled)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select label="Type" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as SchemaMetadata['type'] })}>
|
||||
{TYPES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Module</InputLabel>
|
||||
<Select label="Module" value={form.module} onChange={(e) => setForm({ ...form, module: e.target.value })}>
|
||||
{MODULES.map((m) => <MenuItem key={m} value={m}>{m}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Default value"
|
||||
value={form.defaultStr}
|
||||
onChange={(e) => setForm({ ...form, defaultStr: e.target.value })}
|
||||
helperText={
|
||||
form.type === 'bool' ? 'true or false' :
|
||||
form.type === 'csv' ? 'comma-separated list' :
|
||||
form.type === 'enum' ? 'must match one of the options below' :
|
||||
'value used when no override is set'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
{form.type === 'enum' && (
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Options (comma-separated)"
|
||||
value={form.options}
|
||||
onChange={(e) => setForm({ ...form, options: e.target.value })}
|
||||
helperText="e.g. round-robin, parallel, priority"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{(form.type === 'int' || form.type === 'float') && (
|
||||
<>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth size="small" label="Min" value={form.min} onChange={(e) => setForm({ ...form, min: e.target.value })} />
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth size="small" label="Max" value={form.max} onChange={(e) => setForm({ ...form, max: e.target.value })} />
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth size="small" label="Category" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} helperText="logical group inside module" />
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth size="small" label="Label" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} helperText="short human-friendly title" />
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<TextField fullWidth size="small" label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} helperText="one-line help text shown under the field" />
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.restart_required} onChange={(e) => setForm({ ...form, restart_required: e.target.checked })} />}
|
||||
label="Restart required"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={upsert.isPending}>
|
||||
{upsert.isPending ? <CircularProgress size={16} /> : editingKey ? 'Update' : 'Register'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
297
ai_platform/modules/dashboard/web/src/pages/system/Settings.tsx
Normal file
297
ai_platform/modules/dashboard/web/src/pages/system/Settings.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { Box, Card, CardContent, Chip, CircularProgress, Grid, Link, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { apiGet } from '@/api/client';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
|
||||
interface ConfigResponse {
|
||||
items: Record<string, { value: unknown; default: unknown; is_override: boolean }>;
|
||||
schema: Record<string, { module: string; restart_required: boolean; description?: string; tier?: string }>;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
// Dashboard /health returns `db: boolean` (true = PG reachable). Older
|
||||
// sketches expected `database: string`; we accept both for forward-compat.
|
||||
db?: boolean;
|
||||
database?: string;
|
||||
version?: string;
|
||||
uptime_seconds?: number;
|
||||
build_time?: string;
|
||||
}
|
||||
|
||||
function formatUptime(sec?: number): string {
|
||||
if (!sec) return '—';
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// Module → route mapping. Routes are stable; modules detected dynamically from /api/config schema.
|
||||
const MODULE_ROUTES: Record<string, string> = {
|
||||
web: '/modules/web',
|
||||
llm: '/modules/llm',
|
||||
embeddings: '/modules/embeddings',
|
||||
rerank: '/modules/rerank',
|
||||
audio: '/modules/audio',
|
||||
video: '/modules/video',
|
||||
catalog: '/modules/catalog',
|
||||
gateway: '/modules/gateway',
|
||||
brain: '/brain/settings',
|
||||
};
|
||||
|
||||
// Static system links (always shown)
|
||||
const SYSTEM_LINKS = [
|
||||
{ label: 'Audit Log', to: '/system/audit' },
|
||||
{ label: 'Live Status', to: '/operations/live' },
|
||||
];
|
||||
|
||||
function moduleLabel(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
web: 'Web Search',
|
||||
llm: 'LLM Inference',
|
||||
embeddings: 'Embeddings',
|
||||
rerank: 'Rerank',
|
||||
audio: 'Audio',
|
||||
video: 'Video Analysis',
|
||||
catalog: 'Catalog',
|
||||
gateway: 'Gateway',
|
||||
brain: 'Brain',
|
||||
};
|
||||
return (map[id] ?? id.charAt(0).toUpperCase() + id.slice(1)) + ' config';
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const config = useQuery({
|
||||
queryKey: ['settings', 'config'],
|
||||
queryFn: () => apiGet<ConfigResponse>('/api/config'),
|
||||
});
|
||||
const health = useQuery({
|
||||
queryKey: ['settings', 'health'],
|
||||
// Dashboard liveness probe lives at root /health (next to /ready), not
|
||||
// /api/health — the latter returns 404 and shows "unknown" in the UI.
|
||||
queryFn: () => apiGet<HealthResponse>('/health'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const items = config.data?.items ?? {};
|
||||
const schema = config.data?.schema ?? {};
|
||||
const totalKeys = Object.keys(items).length;
|
||||
const overrides = Object.entries(items).filter(([, v]) => v.is_override);
|
||||
const restartRequired = overrides.filter(([k]) => schema[k]?.restart_required);
|
||||
const liveOverrides = overrides.length - restartRequired.length;
|
||||
|
||||
// Group by module
|
||||
const moduleStats = new Map<string, { total: number; overrides: number }>();
|
||||
Object.entries(schema).forEach(([key, s]) => {
|
||||
const m = s.module || 'other';
|
||||
const cur = moduleStats.get(m) ?? { total: 0, overrides: 0 };
|
||||
cur.total += 1;
|
||||
if (items[key]?.is_override) cur.overrides += 1;
|
||||
moduleStats.set(m, cur);
|
||||
});
|
||||
const sortedModules = Array.from(moduleStats.entries()).sort((a, b) => b[1].total - a[1].total);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<SettingsIcon color="primary" />
|
||||
<Typography variant="h1">System Settings</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Platform-wide overview, system health, and quick access to module configuration.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* System health card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>System Status</Typography>
|
||||
{health.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2">API Status</Typography>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{health.data?.status === 'ok' || health.data?.status === 'healthy' ? (
|
||||
<CheckCircleIcon fontSize="small" color="success" />
|
||||
) : (
|
||||
<CancelIcon fontSize="small" color="error" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{health.data?.status ?? 'unknown'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{health.data?.version && (
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2">Version</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{health.data.version}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{health.data?.uptime_seconds !== undefined && (
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2">Uptime</Typography>
|
||||
<Typography variant="body2">{formatUptime(health.data.uptime_seconds)}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{(health.data?.db !== undefined || health.data?.database) && (
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2">Database</Typography>
|
||||
<Chip
|
||||
label={
|
||||
typeof health.data?.db === 'boolean'
|
||||
? health.data.db
|
||||
? 'connected'
|
||||
: 'down'
|
||||
: (health.data?.database ?? 'unknown')
|
||||
}
|
||||
size="small"
|
||||
color={
|
||||
health.data?.db === false ? 'error' : 'success'
|
||||
}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{health.data?.build_time && (
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2">Build time</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{health.data.build_time}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Identity card */}
|
||||
<Card sx={{ mt: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Your Session</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2">User</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{user?.email ?? user?.preferred_username ?? '—'}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2">Roles</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{(((user?.realm_access as { roles?: string[] } | undefined)?.roles ?? [])
|
||||
.filter((r) => !['default-roles-didi-admins', 'offline_access', 'uma_authorization'].includes(r))
|
||||
.map((r) => <Chip key={r} label={r} size="small" />))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Configuration overview */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Configuration Surface</Typography>
|
||||
{config.isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={4}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Total keys</Typography>
|
||||
<Typography variant="h2">{totalKeys}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Live overrides</Typography>
|
||||
<Typography variant="h2" sx={{ color: liveOverrides ? 'warning.main' : 'text.primary' }}>{liveOverrides}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Need restart</Typography>
|
||||
<Typography variant="h2" sx={{ color: restartRequired.length ? 'error.main' : 'text.primary' }}>{restartRequired.length}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Per module
|
||||
</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Module</TableCell>
|
||||
<TableCell align="right">Keys</TableCell>
|
||||
<TableCell align="right">Overrides</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedModules.map(([m, s]) => (
|
||||
<TableRow key={m}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, textTransform: 'capitalize' }}>{m}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">{s.total}</TableCell>
|
||||
<TableCell align="right">
|
||||
{s.overrides > 0 ? (
|
||||
<Chip label={s.overrides} size="small" color="warning" />
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Quick links — modules generated dynamically from config schema */}
|
||||
<Grid size={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>Quick Access</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{sortedModules
|
||||
.filter(([m]) => MODULE_ROUTES[m])
|
||||
.map(([m, s]) => (
|
||||
<Grid key={m} size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Link component={RouterLink} to={MODULE_ROUTES[m]!} underline="hover" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.5 }}>
|
||||
<LaunchIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="body2">{moduleLabel(m)}</Typography>
|
||||
{s.overrides > 0 && <Chip label={s.overrides} size="small" color="warning" sx={{ height: 16, fontSize: 10 }} />}
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
{SYSTEM_LINKS.map((q) => (
|
||||
<Grid key={q.to} size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Link component={RouterLink} to={q.to} underline="hover" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.5 }}>
|
||||
<LaunchIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="body2">{q.label}</Typography>
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
ai_platform/modules/dashboard/web/src/theme.ts
Normal file
66
ai_platform/modules/dashboard/web/src/theme.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// Matches DIDI admin-dashboard palette (deep blue + honest teal accent)
|
||||
// Dark by default — AI platform admins live in this UI all day.
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#0052CC' },
|
||||
secondary: { main: '#00BFA6' },
|
||||
success: { main: '#28A745' },
|
||||
error: { main: '#E63946' },
|
||||
warning: { main: '#FF8C42' },
|
||||
background: {
|
||||
default: '#050510',
|
||||
paper: '#0E0E1A',
|
||||
},
|
||||
text: {
|
||||
primary: '#E8E8E8',
|
||||
secondary: '#A0A0B0',
|
||||
},
|
||||
divider: '#1f1f2f',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, Roboto, Helvetica, Arial, sans-serif',
|
||||
h1: { fontSize: 32, fontWeight: 700 },
|
||||
h2: { fontSize: 24, fontWeight: 600 },
|
||||
h3: { fontSize: 20, fontWeight: 500 },
|
||||
h4: { fontSize: 16, fontWeight: 500 },
|
||||
body1: { fontSize: 14 },
|
||||
button: { textTransform: 'none', fontWeight: 500 },
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8 },
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#0E0E1A',
|
||||
border: '1px solid #1f1f2f',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: '#0A0A14',
|
||||
backgroundImage: 'none',
|
||||
borderBottom: '1px solid #1f1f2f',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: '#0A0A14',
|
||||
borderRight: '1px solid #1f1f2f',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
184
ai_platform/modules/dashboard/web/src/types/brain.ts
Normal file
184
ai_platform/modules/dashboard/web/src/types/brain.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Mirror of brain admin endpoints from didi_brain/brain_api/schemas.py
|
||||
|
||||
export interface AtomListItem {
|
||||
atom_id: number;
|
||||
content_hash: string;
|
||||
content_preview: string | null;
|
||||
component: 'techniques' | 'ai_tampered' | 'claims';
|
||||
tier: 'free' | 'premium';
|
||||
cache_tier: 'gold' | 'silver' | 'bronze';
|
||||
prompt_hash: string;
|
||||
framework_version: string | null;
|
||||
model_used: string | null;
|
||||
llm_confidence: number | null;
|
||||
human_validated: boolean;
|
||||
hit_count: number;
|
||||
last_hit_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface AtomListResponse {
|
||||
items: AtomListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface AtomDetail extends AtomListItem {
|
||||
result_processed: Record<string, unknown>;
|
||||
result_raw: Record<string, unknown> | null;
|
||||
human_corrections: Record<string, unknown> | null;
|
||||
validator_user_id: string | null;
|
||||
validated_at: string | null;
|
||||
}
|
||||
|
||||
export interface VerificationListItem {
|
||||
claim_hash: string;
|
||||
tier: 'free' | 'premium';
|
||||
model: string | null;
|
||||
prompt_hash: string;
|
||||
framework_version: string | null;
|
||||
schema_name: string;
|
||||
evidence_url_count: number;
|
||||
status: string | null;
|
||||
volatility: 'volatile' | 'evolving' | 'stable' | null;
|
||||
topic_codes: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface VerificationListResponse {
|
||||
items: VerificationListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface VerificationDetail extends VerificationListItem {
|
||||
evidence_urls: string[];
|
||||
verification_processed: Record<string, unknown>;
|
||||
verification_raw: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface TaxonomyInfo {
|
||||
total_tags: number;
|
||||
namespaces: string[];
|
||||
by_namespace: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AtomStatsExtended {
|
||||
total_atoms: number;
|
||||
by_tier: { gold: number; silver: number; bronze: number };
|
||||
by_component: { techniques: number; ai_tampered: number; claims: number };
|
||||
hit_rate_24h: number | null;
|
||||
hits_24h_gold: number;
|
||||
hits_24h_silver: number;
|
||||
writes_24h: number;
|
||||
gold_promotions_24h: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase D2 — fact_status, audit log, cache invalidate
|
||||
// ============================================================================
|
||||
|
||||
export type Volatility = 'volatile' | 'evolving' | 'stable';
|
||||
|
||||
export interface FactStatusItem {
|
||||
fact_id: number;
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
canonical_form: string;
|
||||
canonical_form_hash: string;
|
||||
current_truth: boolean | null;
|
||||
current_version_id: number | null;
|
||||
current_confidence: number | null;
|
||||
last_verified_at: string | null;
|
||||
last_evidence_urls: string[];
|
||||
volatility: Volatility | null;
|
||||
topic_codes: string[];
|
||||
next_check_at: string;
|
||||
check_interval_hours: number;
|
||||
moderator_locked: boolean;
|
||||
moderator_user_id: string | null;
|
||||
moderator_notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FactStatusListResponse {
|
||||
items: FactStatusItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface FactStatusVersionItem {
|
||||
version_id: number;
|
||||
fact_id: number;
|
||||
truth_value: boolean;
|
||||
confidence: number | null;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
source_atom_ids: string[];
|
||||
evidence_urls: string[];
|
||||
llm_reasoning: string | null;
|
||||
created_by: string;
|
||||
moderator_user_id: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FactStatusVersionsResponse {
|
||||
versions: FactStatusVersionItem[];
|
||||
fact_id: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FactStatusPatchRequest {
|
||||
set_truth?: boolean | null;
|
||||
confidence?: number;
|
||||
evidence_urls?: string[];
|
||||
notes?: string | null;
|
||||
lock?: boolean | null;
|
||||
moderator_user_id: string;
|
||||
}
|
||||
|
||||
export interface AuditLogItem {
|
||||
log_id: number;
|
||||
action: string;
|
||||
target_table: string;
|
||||
target_id: string;
|
||||
actor: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
items: AuditLogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface CacheInvalidateRequest {
|
||||
topic_codes?: string[];
|
||||
entity_canonicals?: string[];
|
||||
claim_pattern?: string | null;
|
||||
since?: string | null;
|
||||
invalidate_gold?: boolean;
|
||||
dry_run?: boolean;
|
||||
actor?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CacheInvalidateResponse {
|
||||
invalidated_atoms: number;
|
||||
invalidated_vcache: number;
|
||||
dry_run: boolean;
|
||||
filters_applied: Record<string, unknown>;
|
||||
executed_at: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue