Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View 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

View 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

View 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).

View 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

View 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"]

View 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

View 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

View 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

View 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"]

View file

@ -0,0 +1,3 @@
"""Dashboard module — admin UI for web search providers and costs."""
__version__ = "0.1.0"

View file

@ -0,0 +1 @@
"""FastAPI application."""

View 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()

View 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"

View 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 [])

View file

@ -0,0 +1 @@
"""API routes."""

View file

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

View file

@ -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,
}

View file

@ -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"),
)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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")
}

View file

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

View file

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

View file

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

View 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,
}

View 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],
}

View 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}

View 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()

View 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()

View 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()

View file

@ -0,0 +1 @@
"""Database layer."""

View 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")

View 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

View 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}")

View 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

View file

@ -0,0 +1 @@
"""Live provider stats fetchers."""

View file

@ -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(),
}

View file

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

View file

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

View file

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

View file

@ -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 []

View file

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

View file

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

View file

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

View 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)

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

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

View file

@ -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 %}

View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

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

View file

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

View file

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

View file

@ -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 %}

View 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

View 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

View 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

View 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>

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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>
);
}

View 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'
);
})();

View 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 };

View file

@ -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}</>;
}

View 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 });

View 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>
);
}

View 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>
);
}

View file

@ -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 &quot;{moduleId}&quot;. (Either the module has no
editable knobs, or schema isn&apos;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>
);
}

View file

@ -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} />;
}

View 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>
);
}

View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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://&lt;host&gt;:&lt;port&gt;/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>&lt;stack&gt;</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 &lt;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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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',
},
},
},
},
});

View 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