livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
60
backend/.gitignore
vendored
Normal file
60
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# ==============================================================================
|
||||
# Backend .gitignore — DIDI Platform
|
||||
# ==============================================================================
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment & secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.staging
|
||||
.env.production
|
||||
*.env # orice fisier *.env (ex. .cluster-credentials.env) — secrete
|
||||
!*.env.example # dar pastreaza template-urile
|
||||
.cluster-credentials.env
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
.htpasswd
|
||||
pgpass
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.code-workspace
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Docker data volumes
|
||||
data/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.bak-*
|
||||
*.orig
|
||||
*.rej
|
||||
*.backup
|
||||
|
||||
# DB seed / dumps (date + PII — nu se versioneaza; preconditie de deployment)
|
||||
*.sql.dump
|
||||
services/data-layer/didiDatabase/DIDI_full_export_*.sql
|
||||
|
||||
# Output teste de integrare (generat)
|
||||
scripts/integration/results/
|
||||
|
||||
# Handoff-uri interne de sesiune (note interne, nu livrabile)
|
||||
HANDOFF_*.md
|
||||
124
backend/BUILD_AND_SCRIPTS.md
Normal file
124
backend/BUILD_AND_SCRIPTS.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# DIDI Backend — Build, Scripturi, Env & Seed (index operațional)
|
||||
|
||||
> Răspunde la: *cum ridic platforma pe o mașină nouă, ce script rulez, ce env pun, de unde iau seed-ul.*
|
||||
> Topologia curentă = **totul local pe mașina de deployment** (fără cluster extern). Vezi `services/*/INDEX.md` pentru detalii per serviciu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build curat pe o mașină nouă (calea recomandată)
|
||||
|
||||
```bash
|
||||
cd backend/production
|
||||
./build-local.sh [hostname] # implicit: hostname-ul mașinii
|
||||
```
|
||||
|
||||
`build-local.sh` ridică TOATĂ stiva local, în ordinea corectă: network → data-layer (PG+seed, Redis,
|
||||
RabbitMQ, MinIO, Keycloak) → Kong → framework + sync-redis → agent-v3 + workeri → dashboard.
|
||||
**Seed-ul se importă automat** la primul boot al `didi-postgres` (montat ca init script).
|
||||
|
||||
**Precondiții** (nu-s în git — se aduc manual pe mașina nouă):
|
||||
1. `.env` completate pentru fiecare serviciu (copiază din `.env.example`, pune secretele — vezi §4).
|
||||
2. Seed-ul `services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql` prezent (vezi §5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Ce docker-compose rulează per serviciu (evită confuzia `.local.yml`)
|
||||
|
||||
| Serviciu | Compose CANONIC (rulează) | Notă |
|
||||
|---|---|---|
|
||||
| **data-layer** (PG, Redis, RabbitMQ, MinIO, Keycloak) | `services/data-layer/docker-compose.local.yml` | AICI `.local.yml` = cel activ (PG+Keycloak local) |
|
||||
| **didiFramework** | `services/orchestration-layer/didiFramework/docker-compose.yml` | `.yml` are `${DB_HOST:-didi-postgres}` — local by default, overridable |
|
||||
| **agent-v3 + workeri** | `services/orchestration-layer/agent-v3/docker-compose.yml` | idem |
|
||||
| **Kong** | `docker run` din `build-local.sh` (DBless) | config `didiKong/declarative/kong.yml.didi11-local` |
|
||||
| **admin dashboard** | imagine `didi-admin:latest` (`docker run`) | vezi build-local.sh Faza 7 |
|
||||
| observabilitate (opțional) | `observability/docker-compose.yml` | Prometheus/Grafana/Loki/OTel |
|
||||
|
||||
> `data-layer/docker-compose.yml` (fără `.local`) = stiva veche de staging (`staging-dataLayer-*`) — nefolosită pentru DB principal în modul local.
|
||||
> Cele 2 `.local.yml` redundante ale framework/agent au fost arhivate (erau acoperite integral de `.yml`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventar scripturi
|
||||
|
||||
| Script | Ce face | Stare |
|
||||
|---|---|---|
|
||||
| `production/build-local.sh` | **Build complet local** (recomandat) | ✅ curent |
|
||||
| `production/full-build.sh` | Build pe topologie cluster (PG/Kong externe) | legacy — doar dacă revii la cluster |
|
||||
| `production/migrate-to-cluster.sh` | Mută DB-urile Kong/Keycloak pe cluster | legacy |
|
||||
| `services/data-layer/didiQueue/init-queues.sh` | Creează exchange + cozi + DLQ în RabbitMQ | util la nevoie (topologia se face și dinamic de workeri) |
|
||||
| `services/data-layer/didiStorage/init-buckets.sh` | Creează bucket-urile MinIO | rulat de build-local.sh |
|
||||
| `services/gateway-auth-layer/didiKong/entrypoint.sh` | Entrypoint container Kong | intern |
|
||||
| `services/orchestration-layer/agent-v3/scale-workers.sh` | Scalare workeri (status/set/auto pe metrici) | operațional |
|
||||
| `services/orchestration-layer/didiFramework/scripts/bulk_insert_*.sh` | Insert bulk indicatori/reguli tehnici | one-off (datele sunt deja în seed) |
|
||||
| `services/orchestration-layer/scripts/redis-switch.sh` | Comută Redis/RabbitMQ local ↔ cluster | operațional |
|
||||
| `services/orchestration-layer/scripts/minio-switch.sh` | Comută MinIO local ↔ cluster | operațional |
|
||||
| `scripts/api/api_probe.py` (repo root) | Inventar + probă live a tuturor endpoint-urilor | testare/recepție |
|
||||
| `scripts/api/generate_openapi.py` (repo root) | Generează `openapi.yaml` din inventar + probe | testare/recepție |
|
||||
| `scripts/ci/health-check.sh` (repo root) | Health-gate post-deploy | CI/CD |
|
||||
|
||||
---
|
||||
|
||||
## 4. Fișiere .env (unde stau, ce pui)
|
||||
|
||||
Fiecare serviciu are `.env` (gitignored, cu secrete) + `.env.example` (în git, șablon fără secrete).
|
||||
Pe mașină nouă: `cp .env.example .env` și completează valorile `CHANGE_ME`.
|
||||
|
||||
| Serviciu | `.env` | Secrete de completat |
|
||||
|---|---|---|
|
||||
| `services/orchestration-layer/agent-v3/` | infra + LLM + **URL-uri Lot 1** | OPENROUTER/OPENAI/GROQ/ANTHROPIC/GOOGLE keys, M17 token, MinIO keys |
|
||||
|
||||
**Lot 1 (platforma AI) — configurabil 100% din env.** agent-v3 cheamă serviciile AI prin URL-uri
|
||||
din `.env` (nu hardcodate). Pe o mașină nouă cu Lot 1 propriu, setezi doar host-urile în
|
||||
`agent-v3/.env` (bloc marcat „Lot 1 — Platforma AI"):
|
||||
|
||||
| Env var | Serviciu Lot 1 |
|
||||
|---|---|
|
||||
| `LLM_ROUTER_URL` | llm-inference (Qwen text/OCR) |
|
||||
| `VISION_LLM_URL` | Qwen Vision (fallback: `LLM_ROUTER_URL`) |
|
||||
| `DIDI_BRAIN_URL` | brain (verification cache + RAG) |
|
||||
| `VIDEO_ANALYSIS_URL` | video/BusterX deepfake |
|
||||
| `EXTRACTORS_URL` | extractors (EXIF/ELA/NER/YOLO/OCR) |
|
||||
| `FORENSIC_API_URL` | forensic m25-m29 |
|
||||
| `M17_WHISPER_URL` | transcriere audio |
|
||||
| `M17_WEB_API_URL` | web search (claims/source) |
|
||||
| `DOMAIN_CHECK_API_URL` | domain check |
|
||||
|
||||
Fiecare are și fallback în `docker-compose.yml` (`${VAR:-default}`): dacă Lot 1 rulează pe aceeași
|
||||
rețea Docker, merge cu numele de container (ex. `didiAI-extractors`); dacă e pe alt host, pui IP-ul în `.env`.
|
||||
| `admin-dashboard/` | Keycloak + URL-uri | de regulă doar hostname-uri |
|
||||
| `production/` | Redis/Keycloak/Kong | REDIS/KEYCLOAK passwords, Kong secrets |
|
||||
| `services/data-layer/`, `didiCache/`, `didiQueue/`, `didiStorage/` | infra | credențiale local (redis123/rabbitmq123/minio123 în staging) |
|
||||
| `services/orchestration-layer/didiFramework/` | DB + Keycloak + MinIO | DB_PASSWORD, KEYCLOAK_ADMIN_PASSWORD |
|
||||
| `observability/` | Grafana | GRAFANA admin |
|
||||
|
||||
> `services/orchestration-layer/scripts/.cluster-credentials.env` (gitignored) = credențialele cluster,
|
||||
> folosit doar de `redis-switch.sh --cluster` / `minio-switch.sh cluster`. Irelevant pentru build local.
|
||||
|
||||
---
|
||||
|
||||
## 5. Seed-ul bazei de date
|
||||
|
||||
- **Fișier canonic:** `services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql` (23 MB, pg_dump
|
||||
complet: schema + date + toate migrațiile 001–017). Restore curat → 4 scheme `bos_*`, 97 tabele.
|
||||
- **NU e în git** (untracked, prea mare). Pe o mașină nouă trebuie adus manual (copiere / stick / scp)
|
||||
în directorul de mai sus ÎNAINTE de `build-local.sh`.
|
||||
- **Redis NU se seed-uiește** — e cache derivat din PostgreSQL. După restore: `POST /api/sync-redis`
|
||||
(build-local.sh o face automat). Rețetă completă: `didiDatabase/REBUILD.md`.
|
||||
- Arhivat în afara repo-ului (`/home/admin365/didi_seed_archive_2026-07-08/`): seed-ul vechi
|
||||
`2026-03-22` (fără migrații) + pachetul demo (abandonat — livrarea folosește doar full seed).
|
||||
|
||||
---
|
||||
|
||||
## 6. Verificare rapidă după build
|
||||
|
||||
```bash
|
||||
# health toate serviciile
|
||||
docker exec didi-postgres pg_isready -U bos_interface
|
||||
docker exec didi-cache redis-cli -a redis123 --no-auth-warning ping
|
||||
docker exec didi-framework wget -qO- http://127.0.0.1:3005/health
|
||||
docker exec didi-agent-v3 wget -qO- http://localhost:24803/api/v3/health
|
||||
# chei framework în Redis (după sync)
|
||||
docker exec didi-cache redis-cli -a redis123 --no-auth-warning keys 'didi:framework:*' | wc -l # aștept 8
|
||||
# API docs (dacă rulează containerul didi-api-docs)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8089/
|
||||
```
|
||||
135
backend/CLAUDE.md
Normal file
135
backend/CLAUDE.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# DIDI Backend Platform
|
||||
|
||||
Misinformation detection platform — 3-layer microservices architecture (TypeScript/Node.js).
|
||||
|
||||
> **⚠️ READ FIRST**: [`REFACTOR_CONTEXT.md`](./REFACTOR_CONTEXT.md) — convenții, helpers,
|
||||
> structură nouă post-refactor (sesiune 2026-05-07). Conține pattern-urile
|
||||
> obligatorii (`log`, `requireEnv`, `internalError`, `lazyRedis`, `acquireLock`,
|
||||
> `LogicalInputError`, etc) + anti-pattern-uri de evitat + fișierele mari rămase.
|
||||
|
||||
## Active Services
|
||||
|
||||
| Service | Port | Container | Compose file |
|
||||
|---------|------|-----------|--------------|
|
||||
| Agent V3 | 24803 | didi-agent-v3 | `services/orchestration-layer/agent-v3/docker-compose.yml` |
|
||||
| didiFramework | 3005 | didi-framework | `services/orchestration-layer/didiFramework/docker-compose.yml` |
|
||||
| Admin Dashboard | 3000 (HTTPS) | didi-admin | `services/data-layer/docker-compose.yml` |
|
||||
|
||||
Workers (in agent-v3 compose): worker-techniques ×2, worker-ai-tampered ×2, worker-claims ×3, worker-domain ×2, worker-media-preprocess ×2, verdict-aggregator ×2.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Rebuild agent-v3 (+ all workers)
|
||||
cd services/orchestration-layer/agent-v3 && docker compose up -d --build agent-v3
|
||||
|
||||
# Rebuild framework
|
||||
cd services/orchestration-layer/didiFramework && docker compose up -d --build didi-framework
|
||||
|
||||
# Rebuild admin dashboard
|
||||
cd admin-dashboard && docker build -t didi-admin:latest .
|
||||
cd ../services/data-layer && docker compose up -d --force-recreate didi-admin
|
||||
|
||||
# Sync parameters to Redis
|
||||
docker exec didi-framework sh -c 'wget -qO- --post-data="" http://127.0.0.1:3005/api/sync-redis'
|
||||
|
||||
# View logs
|
||||
docker logs -f didi-agent-v3
|
||||
docker logs -f didi-framework
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── admin-dashboard/ # React 19, MUI 7, TypeScript
|
||||
│ ├── src/components/ # ServiceCard, AnalysisHistory, UserManagement, etc.
|
||||
│ ├── src/services/api.ts # API client (health checks via Docker container state)
|
||||
│ ├── nginx-ssl.conf # Nginx proxy (/framework/ → :3005, /agent-v3/ → :24803)
|
||||
│ └── Dockerfile # Multi-stage: node:20 build → nginx:alpine serve
|
||||
├── production/ # Production docker-compose + .env
|
||||
└── services/
|
||||
├── data-layer/
|
||||
│ ├── didiCache/ # Redis 7 config
|
||||
│ ├── didiDatabase/ # Local PG (waitlist only, main DB is external cluster)
|
||||
│ ├── didiQueue/ # RabbitMQ init scripts
|
||||
│ ├── didiStorage/ # MinIO init + lifecycle
|
||||
│ ├── pgadmin/
|
||||
│ └── docker-compose.yml # All data layer + admin dashboard containers
|
||||
├── gateway-auth-layer/
|
||||
│ ├── didiKeycloak/ # Realm import + 3 custom themes
|
||||
│ └── didiKong/ # Declarative config + SSL certs
|
||||
└── orchestration-layer/
|
||||
├── agent-v3/ # Analysis engine (Express 5, TypeScript)
|
||||
│ ├── src/api/ # routes.ts, ai-tampered-routes.ts, claims-routes.ts, pipeline-routes.ts
|
||||
│ ├── src/components/ # techniques/, ai-tampered/, claims/, pipeline/ executors
|
||||
│ ├── src/shared/ # media/, persistence/, redis/, types/
|
||||
│ ├── src/queue/ # dispatcher.ts, connection.ts, aggregator.ts, workers/
|
||||
│ └── docker-compose.yml # agent-v3 + 13 worker containers
|
||||
└── didiFramework/ # Parameters CRUD API (Express 4, TypeScript)
|
||||
├── src/routes/ # 20+ route files (CRUD for all parameters)
|
||||
├── sql/migrations/ # PG migrations
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Databases
|
||||
|
||||
| Database | Host | Schema | Used by |
|
||||
|----------|------|--------|---------|
|
||||
| DIDI (PG cluster) | 10.11.50.167:5000 | bos_parammgmt | didiFramework (parameters CRUD) |
|
||||
| | | bos_analysis | agent-v3 (analysis results) |
|
||||
| | | bos_sysadmin | didiFramework (users, subscriptions) |
|
||||
| | | bos_subscriber | didiFramework (personal data) |
|
||||
| Redis | didi-cache:6379 | — | Both (framework config cache, session state) |
|
||||
| RabbitMQ | rabbitmq:5672 | — | agent-v3 (async analysis queues) |
|
||||
| MinIO | minio:9000 | — | Both (media file storage) |
|
||||
|
||||
## API Routes (Agent V3, all async)
|
||||
|
||||
All `/analyze` and `/analyze-media` endpoints dispatch to RabbitMQ workers and return 202.
|
||||
|
||||
| Prefix | File | What |
|
||||
|--------|------|------|
|
||||
| `/api/v3/techniques/*` | routes.ts | Manipulation technique detection |
|
||||
| `/api/v3/ai-tampered/*` | ai-tampered-routes.ts | AI content detection |
|
||||
| `/api/v3/claims/*` | claims-routes.ts | Claim extraction + verification |
|
||||
| `/api/v3/pipeline/*` | pipeline-routes.ts | Full pipeline, history, extension |
|
||||
| `/api/v3/source-assessment/*` | source-assessment-routes.ts | Source credibility |
|
||||
| `/api/v3/domain/*` | routes.ts | Domain analysis |
|
||||
| `/api/v3/media/*` | routes.ts | File upload/download (MinIO) |
|
||||
|
||||
## LLM Models
|
||||
|
||||
| Role | Model | Location |
|
||||
|------|-------|----------|
|
||||
| Primary (all) | Qwen 3.5 397B | Local GPU 10.11.10.17:14011 |
|
||||
| Fallback 1 | Claude Sonnet 4.6 (Opus 4.6 for ai-tampered) | OpenRouter |
|
||||
| Fallback 2 | GPT-4o | OpenRouter |
|
||||
| Vision | Qwen Vision → Gemini Flash → GPT-4o | 10.11.10.42:14001 + OpenRouter |
|
||||
| Transcription | M17-Whisper → Groq → OpenAI | 10.11.10.17:54300 + Cloud |
|
||||
|
||||
Managed via `component_stage_assignment` PG table → sync to Redis.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Agent V3 | Node.js, TypeScript, Express 5 |
|
||||
| didiFramework | Node.js, TypeScript, Express 4 |
|
||||
| Admin Dashboard | React 19, MUI 7, TypeScript |
|
||||
| Database | PostgreSQL 14 (Patroni/HAProxy) |
|
||||
| Cache | Redis 7 |
|
||||
| Queue | RabbitMQ 3.12 |
|
||||
| Storage | MinIO |
|
||||
| Gateway | Kong 3.4 (SSL :443) |
|
||||
| Auth | Keycloak 22 |
|
||||
|
||||
## Credentials (Staging)
|
||||
|
||||
| Service | User | Password |
|
||||
|---------|------|----------|
|
||||
| PostgreSQL (cluster) | bos_interface | interface |
|
||||
| Redis | — | redis123 |
|
||||
| RabbitMQ | admin | rabbitmq123 |
|
||||
| MinIO | minioadmin | minio123 |
|
||||
| Keycloak | admin@didi.local | admin123 |
|
||||
1649
backend/DEPLOY_FROM_SCRATCH.md
Normal file
1649
backend/DEPLOY_FROM_SCRATCH.md
Normal file
File diff suppressed because it is too large
Load diff
96
backend/README.md
Normal file
96
backend/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# DIDI Backend
|
||||
|
||||
Misinformation detection backend — TypeScript/Node.js microservices.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Description | Port | Container |
|
||||
|---------|-------------|------|-----------|
|
||||
| **Agent V3** | Analysis engine — 4 detection components + verdict | 24803 | didi-agent-v3 |
|
||||
| **didiFramework** | Parameters CRUD API, user management, Redis sync | 3005 | didi-framework |
|
||||
| **Admin Dashboard** | React admin UI (services, config, history, users) | 3000 | didi-admin |
|
||||
| **Kong** | API Gateway (SSL, routing, rate limiting) | 443 | kong |
|
||||
| **Keycloak** | Authentication (OAuth2/OIDC, JWT) | 28000 | keycloak |
|
||||
| **Redis** | Cache (framework config, session state) | 6379 | didi-cache |
|
||||
| **RabbitMQ** | Queue (async analysis, 24 component queues) | 5672 | rabbitmq |
|
||||
| **MinIO** | Object storage (media files, per-user buckets) | 9000 | minio |
|
||||
| **PostgreSQL** | External cluster (Patroni/HAProxy) | 5000 | — |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Start data layer
|
||||
cd services/data-layer && docker compose up -d
|
||||
|
||||
# 2. Start agent-v3 + workers
|
||||
cd services/orchestration-layer/agent-v3 && docker compose up -d --build
|
||||
|
||||
# 3. Start framework
|
||||
cd services/orchestration-layer/didiFramework && docker compose up -d --build
|
||||
|
||||
# 4. Sync parameters
|
||||
docker exec didi-framework sh -c 'wget -qO- --post-data="" http://127.0.0.1:3005/api/sync-redis'
|
||||
|
||||
# 5. Test
|
||||
curl http://localhost:24803/api/v3/health
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── admin-dashboard/ # React 19, MUI 7 — admin UI
|
||||
├── production/ # Production docker-compose + .env
|
||||
└── services/
|
||||
├── data-layer/ # Redis, RabbitMQ, MinIO, local PG
|
||||
├── gateway-auth-layer/ # Kong + Keycloak
|
||||
└── orchestration-layer/
|
||||
├── agent-v3/ # Analysis engine (TypeScript, Express 5)
|
||||
└── didiFramework/ # Parameters API (TypeScript, Express 4)
|
||||
```
|
||||
|
||||
Each service has its own `INDEX.md` with detailed documentation.
|
||||
|
||||
## Analysis Pipeline
|
||||
|
||||
```
|
||||
Input (text/image/audio/video/URL)
|
||||
│
|
||||
├─ Worker: media-preprocess (extract text from media)
|
||||
│
|
||||
├─ Worker: techniques → manipulation_score (0-100)
|
||||
├─ Worker: ai-tampered → ai_probability (0-100)
|
||||
├─ Worker: claims → credibility_score (0-100)
|
||||
├─ Worker: domain → trust_score (0-100)
|
||||
│
|
||||
└─ Aggregator: verdict → risk_score (0-100) + explanation (RO+EN)
|
||||
```
|
||||
|
||||
All analysis endpoints are **async**: dispatch to RabbitMQ → return 202 → poll for results.
|
||||
|
||||
## Rebuild Commands
|
||||
|
||||
```bash
|
||||
# Agent V3
|
||||
cd services/orchestration-layer/agent-v3
|
||||
docker compose up -d --build agent-v3
|
||||
|
||||
# Framework
|
||||
cd services/orchestration-layer/didiFramework
|
||||
docker compose up -d --build didi-framework
|
||||
|
||||
# Admin Dashboard
|
||||
cd admin-dashboard
|
||||
docker build -t didi-admin:latest .
|
||||
cd ../services/data-layer && docker compose up -d --force-recreate didi-admin
|
||||
```
|
||||
|
||||
## Credentials (Staging)
|
||||
|
||||
| Service | User | Password |
|
||||
|---------|------|----------|
|
||||
| PostgreSQL (cluster) | bos_interface | interface |
|
||||
| Redis | — | redis123 |
|
||||
| RabbitMQ | admin | rabbitmq123 |
|
||||
| MinIO | minioadmin | minio123 |
|
||||
| Keycloak | admin@didi.local | admin123 |
|
||||
625
backend/REFACTOR_CONTEXT.md
Normal file
625
backend/REFACTOR_CONTEXT.md
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
# DIDI Backend — Refactor Context (sesiune 2026-05-07)
|
||||
|
||||
**Citire obligatorie pentru orice sesiune ulterioară.** Acest document conține
|
||||
convențiile, helper-ele și starea după 54 task-uri de refactor + 13 deploy-uri.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — ce s-a întâmplat
|
||||
|
||||
Începută cu un audit ostil al `agent-v3` + `didiFramework`. Am descoperit + rezolvat:
|
||||
- **8 bug-uri critice** (auth bypass admin, parole hardcoded, race conditions,
|
||||
silent failures, fragile error matching)
|
||||
- **Migrare integrală la pino structured logging** (651 console.* → 0)
|
||||
- **Reducere `as any` 92%** (159 → 13)
|
||||
- **Curățarea error.message leaks** (126 → 0)
|
||||
- **Centralizare Redis pools, Keycloak token, PG pools, Express helpers**
|
||||
- **Split la 11 fișiere monolitice** (13049 LOC → 75 fișiere modulare):
|
||||
`routes.ts`, `admin.ts`, `verdict-calculator.ts`, `auth.ts`, `pipeline-routes.ts`,
|
||||
`claims/executor.ts`, `ai-tampered/executor.ts`, `techniques/executor.ts`,
|
||||
`source-assessment/executor.ts`, `providers.ts` (didiFramework),
|
||||
`sync-redis.ts` (didiFramework)
|
||||
|
||||
Sistemul live a rulat fără downtime detectabil pentru utilizatori.
|
||||
|
||||
---
|
||||
|
||||
## 1. Convenții obligatorii (RESPECTĂ-LE)
|
||||
|
||||
### 1.1 Logging — folosește `log`, NU `console`
|
||||
|
||||
```ts
|
||||
// agent-v3
|
||||
import { log } from '../shared/logger';
|
||||
|
||||
// didiFramework
|
||||
import { log } from '../config/logger';
|
||||
```
|
||||
|
||||
Pino este wrappuit cu un adapter variadic — `log.info('msg', x, y)` și
|
||||
`log.info({ session_id }, 'msg')` ambele merg.
|
||||
|
||||
În route handlers, folosește `req.log` care are deja `request_id` atașat:
|
||||
```ts
|
||||
req.log.info({ user_id }, 'analyze starting');
|
||||
```
|
||||
|
||||
**NU readuce console.log/error/warn.** Comentariul cu "TODO" e mai bun decât un console.
|
||||
|
||||
### 1.2 Env vars — `requireEnv()` pentru tot ce e secret/required
|
||||
|
||||
```ts
|
||||
// agent-v3
|
||||
import { requireEnv, optionalEnv } from '../shared/helpers/env';
|
||||
|
||||
// didiFramework
|
||||
import { requireEnv, optionalEnv } from '../config/env';
|
||||
|
||||
const PG_PASSWORD = requireEnv('PG_PASSWORD'); // throws on missing
|
||||
const PORT = optionalEnv('PORT', '3005'); // safe default
|
||||
```
|
||||
|
||||
**INTERZIS** `process.env.X || 'fallback'` pentru:
|
||||
- Parole, tokens, API keys
|
||||
- Hostname-uri interne (10.11.x.y)
|
||||
- DB names, users
|
||||
|
||||
OK pentru: log levels, ports, mobile schemes, public URLs, schema names.
|
||||
|
||||
### 1.3 Error responses — `internalError()`, NU `error.message`
|
||||
|
||||
```ts
|
||||
// agent-v3
|
||||
import { internalError } from '../shared/helpers/error-response';
|
||||
|
||||
// didiFramework
|
||||
import { internalError } from '../config/error-response';
|
||||
|
||||
try {
|
||||
// ...
|
||||
} catch (error) {
|
||||
internalError(res, error, 'optional_context_tag');
|
||||
}
|
||||
```
|
||||
|
||||
Asta:
|
||||
- Loghează errorul + stack intern cu `correlation_id` (UUID)
|
||||
- Returnează către client `{success: false, error: 'Internal server error', correlation_id}`
|
||||
- NU leakuiește detalii interne
|
||||
|
||||
**INTERZIS** `res.status(500).json({error: error.message})` sau orice variantă
|
||||
care expune mesajul brut.
|
||||
|
||||
### 1.4 Redis — `lazyRedis()` per modul + `scanKeys()`, NU `KEYS`
|
||||
|
||||
```ts
|
||||
import { lazyRedis } from '../shared/redis/connection';
|
||||
import { scanKeys } from '../shared/redis/scan';
|
||||
|
||||
const getRedis = lazyRedis('module-name'); // label vizibil în connection metadata
|
||||
|
||||
// SCAN, nu KEYS — KEYS blochează server-ul O(N)
|
||||
const keys = await scanKeys(getRedis(), 'pattern:*');
|
||||
```
|
||||
|
||||
### 1.5 Locks distribuite — `acquireLock`/`releaseLock` (fenced)
|
||||
|
||||
```ts
|
||||
import { acquireLock, releaseLock } from '../shared/redis/lock';
|
||||
|
||||
const lock = await acquireLock(redis, lockKey, 30); // 30s TTL
|
||||
if (!lock) {
|
||||
// alt proces are lock-ul
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// critical section
|
||||
} finally {
|
||||
await releaseLock(redis, lock); // compare-and-delete via Lua
|
||||
}
|
||||
```
|
||||
|
||||
**INTERZIS** `redis.del(lockKey)` direct — poate șterge lock-ul altcuiva dacă TTL
|
||||
a expirat.
|
||||
|
||||
### 1.6 Errori logice — `LogicalInputError`, NU string-matching
|
||||
|
||||
```ts
|
||||
import { LogicalInputError, isLogicalInputError } from '../shared/helpers/errors';
|
||||
|
||||
if (!text) throw new LogicalInputError('Component requires text input');
|
||||
|
||||
// În worker:
|
||||
if (isLogicalInputError(err)) { /* nu retry */ }
|
||||
```
|
||||
|
||||
**INTERZIS** `err.message.includes('requires')` sau alte verificări fragile.
|
||||
|
||||
### 1.7 Validare config Redis — zod, nu JSON.parse direct
|
||||
|
||||
Pentru endpoint-uri PUT care scriu în Redis:
|
||||
```ts
|
||||
import { TierStageAssignmentsSchema } from '../shared/helpers/config-schemas';
|
||||
|
||||
const parsed = TierStageAssignmentsSchema.safeParse(req.body?.stage_assignments);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({success: false, error: 'Invalid payload', details: parsed.error.issues});
|
||||
}
|
||||
await r.set(key, JSON.stringify(parsed.data));
|
||||
```
|
||||
|
||||
### 1.8 PG pools — folosește shared, NU `new Pool()` în route file
|
||||
|
||||
```ts
|
||||
// didiFramework
|
||||
import pool from '../config/database';
|
||||
// SAU pentru query helpers:
|
||||
import { query, queryOne, transaction } from '../config/database';
|
||||
|
||||
// agent-v3
|
||||
import { getPgPool } from '../shared/persistence/pg-pool';
|
||||
```
|
||||
|
||||
**INTERZIS** `new Pool({...})` în orice fișier de rută. Excepție: `waitlist.ts`
|
||||
folosește un DB diferit (staging), păstrat lazy-init.
|
||||
|
||||
### 1.9 Keycloak admin — folosește centralizat
|
||||
|
||||
```ts
|
||||
// didiFramework
|
||||
import { getKeycloakAdminToken } from '../config/keycloak-admin';
|
||||
const token = await getKeycloakAdminToken(); // throws on failure
|
||||
```
|
||||
|
||||
**INTERZIS** rescrierea logicii de fetch /token din nou.
|
||||
|
||||
### 1.10 Video weighting — folosește helper
|
||||
|
||||
```ts
|
||||
import { combineVideoProbability, DEFAULT_VIDEO_TRACK_WEIGHTS } from '../shared/media/video-weighting';
|
||||
|
||||
const final = combineVideoProbability(textProb, visualProb); // 0.4/0.6 default
|
||||
```
|
||||
|
||||
**INTERZIS** `textProb * 0.4 + visualProb * 0.6` inline (a fost deja inversat
|
||||
într-un loc înainte de fix).
|
||||
|
||||
### 1.11 Type safety — `as any` necesită justificare
|
||||
|
||||
Reduceri reușite: 159 → 13. Cele 13 rămase sunt în:
|
||||
- `routes.ts` (4) — în techniques.ts după split
|
||||
- `pipeline-routes.ts` (4) — încă nedefript
|
||||
- `admin.ts` (3) — distribute în users.ts după split
|
||||
- `verdict-calculator.ts` (2)
|
||||
|
||||
Când adaugi cod nou:
|
||||
- Pentru fetch responses externe → declară `interface Response { ... }` și cast
|
||||
- Pentru data din Redis → zod schema sau tip bine definit
|
||||
- Pentru Express req extension → declară în `declare global { namespace Express { interface Request { ... } } }` (NU cast `as any`)
|
||||
|
||||
### 1.12 `.dockerignore` — OBLIGATORIU pentru ambele services
|
||||
|
||||
```
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env.local
|
||||
```
|
||||
|
||||
**Bug istoric**: stale `dist/admin.js` din host se copia în container și
|
||||
suprascria buildul nou. Dacă `dist/` nu e ignorat → build inconsistent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structură nouă post-split
|
||||
|
||||
### agent-v3
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── routes.ts # 37 LOC barrel (techniques + media + domain + /health)
|
||||
├── techniques.ts # 781 LOC, 11 endpoints
|
||||
├── media.ts # 171 LOC, 4 endpoints
|
||||
├── domain.ts # 333 LOC, 2 endpoints
|
||||
├── pipeline-routes.ts # 6 LOC re-export → ./pipeline
|
||||
├── pipeline/ # split 5 → folder (was 1611 LOC)
|
||||
│ ├── index.ts # 36 LOC barrel
|
||||
│ ├── _init.ts # 36 LOC (lazy redis/persist/media + FRAMEWORK_API_URL)
|
||||
│ ├── _helpers/
|
||||
│ │ ├── vision.ts # 54 LOC (extractImageVision)
|
||||
│ │ └── url-helpers.ts # 151 LOC (detectUrlType + fetchArticleText + isBoilerplate + buildPlatformInfoField)
|
||||
│ ├── analyze.ts # 535 LOC (POST /analyze + /analyze-url + /analyze-media)
|
||||
│ ├── status.ts # 157 LOC (GET /verdict-config + /:sessionId/{status,component/:name,result})
|
||||
│ ├── history.ts # 181 LOC (6 history endpoints)
|
||||
│ ├── extension.ts # 234 LOC (validateApiKey + 4 extension endpoints)
|
||||
│ └── async.ts # 296 LOC (3 async/queue endpoints)
|
||||
├── ai-tampered-routes.ts # 764 LOC
|
||||
├── claims-routes.ts # 671 LOC
|
||||
├── source-assessment-routes.ts # 470 LOC
|
||||
├── moderation-routes.ts # 420 LOC
|
||||
├── _init.ts # 36 LOC (shared lazy: Redis, MediaService, multer, PersistService)
|
||||
└── _helpers/
|
||||
└── standalone-session.ts # 86 LOC (buildStandaloneSession + persistStandaloneResult)
|
||||
|
||||
src/components/pipeline/verdict-calculator/ # split 4 → folder
|
||||
├── index.ts # 864 LOC (VerdictCalculator class)
|
||||
├── types.ts # 161 LOC (interfaces)
|
||||
├── defaults.ts # 72 LOC (DEFAULT_*)
|
||||
└── mappers.ts # 32 LOC (pure functions)
|
||||
|
||||
src/components/claims/ # split 6 → folder (was 1228 LOC)
|
||||
├── executor.ts # 319 LOC (ClaimsExecutor class shell + execute orchestrator + loadConfigs)
|
||||
├── types.ts # 159 LOC (interfaces, zod schemas, ClaimsCtx)
|
||||
├── web-search.ts # 76 LOC (searchWebM17 + M17* types)
|
||||
├── llm-utils.ts # 117 LOC (callWithFallbacks, parseJsonResponse, validate*)
|
||||
└── stages/
|
||||
├── extraction.ts # 82 LOC (Stage 1: extractClaims + loadConfig helper)
|
||||
├── verification.ts # 443 LOC (Stage 2: verifyClaims + 5 helpers + calculateStatusFromSources)
|
||||
└── scoring.ts # 179 LOC (Stage 3: buildFinalResult + buildEmpty + buildSkipped)
|
||||
|
||||
src/components/ai-tampered/ # split 7 → folder (was 1043 LOC)
|
||||
├── executor.ts # 339 LOC (AITamperedExecutor class + execute() + quickAnalyze() + loadFromRedis)
|
||||
├── types.ts # 201 LOC (interfaces, 3 zod schemas, AiTamperedCtx)
|
||||
├── patterns.ts # 57 LOC (AI_TOOL_PATTERNS + DISCLOSURE_INDICATORS + PARTIAL_DISCLOSURE_PATTERNS)
|
||||
├── disclosure.ts # 145 LOC (checkDisclosure + findToolMention + getDisclosureProminence + calculateDisclosureRating)
|
||||
├── llm-utils.ts # 122 LOC (callWithFallbacks, parseJsonResponse, validate*)
|
||||
└── stages/
|
||||
├── _helpers.ts # 17 LOC (loadFromRedis pentru stages)
|
||||
├── screening.ts # 53 LOC (Stage 1: executeScreening)
|
||||
├── deep-analysis.ts # 65 LOC (Stage 2: executeDeepAnalysis)
|
||||
└── scoring.ts # 191 LOC (Stage 3: buildFinalResult + buildEmptyResult + determineVerdict + determineConfidenceLevel)
|
||||
|
||||
src/components/techniques/ # split 8 → folder (was 962 LOC)
|
||||
├── executor.ts # 237 LOC (TechniquesV3Executor class + execute() + loadFromRedis)
|
||||
├── types.ts # 243 LOC (interfaces, 3 zod schemas, TechniquesCtx, resolveStageAssignment)
|
||||
├── llm-utils.ts # 115 LOC (callWithFallbacks, parseJsonResponse, validate*)
|
||||
└── stages/
|
||||
├── _helpers.ts # 15 LOC (loadFromRedis pentru stages)
|
||||
├── screening.ts # 50 LOC (Stage 1: executeScreening)
|
||||
├── deep-analysis.ts # 93 LOC (Stage 2: executeDeepAnalysis + buildTechniquesList)
|
||||
└── scoring.ts # 317 LOC (Stage 3: buildFinalResult + buildEmptyResult + calculateManipulationScore + buildCouplingContext + findTechniqueById)
|
||||
|
||||
src/components/source-assessment/ # split 9 → folder (was 932 LOC)
|
||||
├── executor.ts # 137 LOC (SourceAssessmentExecutor class + execute() orchestrator 4-step)
|
||||
├── types.ts # 95 LOC (interfaces, ScoringConfig, SourceAssessmentCtx)
|
||||
├── defaults.ts # 191 LOC (DEFAULT_*_PROMPT, DEFAULT_MODELS, DEFAULT_AXIS_WEIGHTS, defaultFramework)
|
||||
├── external.ts # 167 LOC (searchM17 + checkDomain + extractRedFlags + extractDomainFromUrl + DOMAIN_CHECK_URL via optionalEnv)
|
||||
├── config-loader.ts # 143 LOC (loadFramework + loadModels + loadScoringConfig + loadPrompt — toate cu fallback default)
|
||||
└── stages/
|
||||
├── extraction.ts # 61 LOC (Step 1: extractSourceMetadata)
|
||||
├── mapping.ts # 127 LOC (Step 3: mapToFramework — LLM clasifică evidence)
|
||||
└── scoring.ts # 180 LOC (Step 4: buildResult + calculateAuthorScore + neutralFallback)
|
||||
|
||||
src/shared/
|
||||
├── logger.ts # pino + variadic adapter
|
||||
├── request-logger.ts # Express middleware (request_id propagation)
|
||||
├── helpers/
|
||||
│ ├── env.ts # requireEnv, optionalEnv
|
||||
│ ├── errors.ts # LogicalInputError class
|
||||
│ ├── error-response.ts # internalError(res, err, ctx?)
|
||||
│ └── config-schemas.ts # TierStageAssignmentsSchema (zod)
|
||||
├── redis/
|
||||
│ ├── connection.ts # lazyRedis(label) + createRedisConnection
|
||||
│ ├── scan.ts # scanKeys cursor-based
|
||||
│ └── lock.ts # acquireLock/releaseLock (fenced)
|
||||
└── media/
|
||||
└── video-weighting.ts # combineVideoProbability + DEFAULT_VIDEO_TRACK_WEIGHTS
|
||||
```
|
||||
|
||||
### didiFramework
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── auth/ # split 5 → folder
|
||||
│ ├── index.ts # 23 LOC barrel
|
||||
│ ├── _helpers.ts # 154 LOC (JWT, Keycloak, getCreditCost, types)
|
||||
│ ├── me-profile.ts # 321 LOC (GET /me + PUT /profile)
|
||||
│ ├── registration.ts # 158 LOC (POST /register)
|
||||
│ ├── credits.ts # 335 LOC (5 endpoints)
|
||||
│ └── email-verify.ts # 415 LOC (GET + POST /verify-email)
|
||||
├── admin/ # split 2 → folder
|
||||
│ ├── index.ts # 32 LOC barrel + auth middleware mount
|
||||
│ ├── _middleware.ts # 63 LOC (requireAdmin)
|
||||
│ ├── _keycloak-helpers.ts # 397 LOC (caches + roles/groups + audit + types)
|
||||
│ ├── users.ts # 709 LOC (7 endpoints)
|
||||
│ ├── plans.ts # 170 LOC (3 endpoints)
|
||||
│ ├── docker.ts # 117 LOC (2 endpoints)
|
||||
│ └── roles-groups.ts # 393 LOC (9 endpoints)
|
||||
└── (alte route files, neatinse)
|
||||
|
||||
src/config/
|
||||
├── env.ts # requireEnv, optionalEnv
|
||||
├── error-response.ts # internalError
|
||||
├── keycloak-admin.ts # getKeycloakAdminToken (canonical)
|
||||
├── logger.ts # pino + variadic adapter
|
||||
├── request-logger.ts # Express middleware
|
||||
└── (database.ts, minio.ts, redis.ts — pre-existing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Fișiere mari rămase (candidați viitor split)
|
||||
|
||||
| Fișier | LOC | Tip | Plan recomandat |
|
||||
|---|---|---|---|
|
||||
| _(none — toate fișierele >900 LOC au fost spart)_ | | | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Bug-uri rezolvate (NU le reintroduce)
|
||||
|
||||
### 4.1 Auth bypass admin (CRITIC, 100 zile expus)
|
||||
|
||||
`requireAdmin = (req,res,next) => next()` cu comentariu "STAGING bypass remove
|
||||
in production" — toate 21 admin endpoints erau publice.
|
||||
|
||||
**Fix**: `_middleware.ts` în admin folder verifică:
|
||||
- JWT decoded (defense-in-depth — Kong validează signature upstream)
|
||||
- Issuer include `/realms/${ADMIN_REALM}` (default `didi-admins`)
|
||||
- `realm_access.roles` include unul din `ADMIN_ROLES` (default `admin,super-admin`)
|
||||
|
||||
Override pentru dev: `ADMIN_AUTH_BYPASS=true` (logează warn pe FIECARE request).
|
||||
|
||||
### 4.2 Aggregator stolen lock
|
||||
|
||||
TTL 30s pentru lock; calcul-verdict + LLM explanation putea dura mai mult; alt
|
||||
worker prelua lock-ul; primul worker făcea `del()` pe lock-ul greșit.
|
||||
|
||||
**Fix**: `acquireLock` returnează token UUID, `releaseLock` verifică prin Lua
|
||||
script înainte de `del`. Loghează warn dacă TTL a expirat.
|
||||
|
||||
### 4.3 Component-worker retry fără backoff → cascade failure
|
||||
|
||||
LLM rate-limit → retry imediat → cascadă.
|
||||
|
||||
**Fix**: linear backoff 1-5s între retry-uri în `component-worker.ts`.
|
||||
|
||||
### 4.4 Empty API key fallback
|
||||
|
||||
`return process.env.OPENROUTER_API_KEY || ''` → request cu `Bearer ` (empty)
|
||||
→ silent 401 LLM.
|
||||
|
||||
**Fix**: throw clar mentționând env vars verificate.
|
||||
|
||||
### 4.5 Video weighting inversat
|
||||
|
||||
`pipeline-routes` folosea 0.6 text + 0.4 visual; `aggregator` folosea 0.4 + 0.6.
|
||||
Același video, scoruri diferite.
|
||||
|
||||
**Fix**: helper `combineVideoProbability()` cu canonical 0.4/0.6 (visual mai
|
||||
greu — frames sunt evidence directă).
|
||||
|
||||
### 4.6 KEYCLOAK_ADMIN_USER vs KEYCLOAK_ADMIN
|
||||
|
||||
`auth.ts` cerea `KEYCLOAK_ADMIN_USER` (env var inexistentă în prod) pe când
|
||||
`admin.ts` și docker-compose foloseau `KEYCLOAK_ADMIN`. Ar fi crăpat la primul
|
||||
restart cu `requireEnv`.
|
||||
|
||||
**Fix**: centralizat în `keycloak-admin.ts` cu `KEYCLOAK_ADMIN`.
|
||||
|
||||
### 4.7 M17_WEB_API_URL throw at module-load
|
||||
|
||||
`source-assessment/executor.ts` arunca `Error('M17_WEB_API_URL ...')` la
|
||||
**module-load time** (nu la runtime). Tests + service refuzau să se încarce.
|
||||
|
||||
**Fix**: `getM17SearchUrl()` lazy.
|
||||
|
||||
### 4.8 Stale `dist/` în Docker context
|
||||
|
||||
Local `dist/` se copia în container și suprascria build-ul fresh.
|
||||
|
||||
**Fix**: `.dockerignore` în ambele services.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test status
|
||||
|
||||
```
|
||||
agent-v3 vitest:
|
||||
- 421 / 466 trec
|
||||
- 45 fail = pre-existente (au devenit vizibile abia după fix-ul M17 lazy)
|
||||
- Toate 20 fail-uri pe care le-am cauzat eu inițial = ACUM REPARATE
|
||||
- Suite-le mari care nu se încărcau în baseline (component-runner, integration,
|
||||
component-worker) acum rulează
|
||||
```
|
||||
|
||||
**Cele 45 fail-uri rămase sunt în**:
|
||||
- `video-processor.test.ts` (13)
|
||||
- `verdict-explanation.test.ts` (11)
|
||||
- `executor.test.ts` (4)
|
||||
- `verdict-calculator.test.ts` (4)
|
||||
- `analysis-session.test.ts` (4)
|
||||
- alte (9 distribuite)
|
||||
|
||||
Sunt issues de **mock data** stale și **algorithm shifts** — nu blocau prod.
|
||||
NU sunt regresii cauzate de mine.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build + deploy
|
||||
|
||||
### Local TS check (rapid, fără Docker)
|
||||
|
||||
```bash
|
||||
cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3
|
||||
npx tsc --noEmit # trebuie să fie 0 erori
|
||||
|
||||
cd ../didiFramework
|
||||
npx tsc --noEmit # trebuie să fie 0 erori
|
||||
```
|
||||
|
||||
### Docker rebuild + deploy
|
||||
|
||||
```bash
|
||||
# agent-v3 (rebuilds toate workers)
|
||||
cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3
|
||||
docker compose up -d --build
|
||||
|
||||
# didiFramework
|
||||
cd /home/admin365/didi_mono/backend/services/orchestration-layer/didiFramework
|
||||
docker compose up -d --build didi-framework
|
||||
```
|
||||
|
||||
### Smoke test post-deploy
|
||||
|
||||
```bash
|
||||
# Health
|
||||
curl http://10.11.10.12:24803/api/v3/health
|
||||
curl http://10.11.10.12:3005/health
|
||||
|
||||
# Auth admin (trebuie să fie 401 fără JWT)
|
||||
curl -o /dev/null -w "HTTP %{http_code}\n" http://10.11.10.12:3005/api/admin/users
|
||||
|
||||
# Pipeline async
|
||||
curl -X POST http://10.11.10.12:24803/api/v3/techniques/analyze \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"Test analizei.","plan_type":1}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Backups
|
||||
|
||||
Locație: `/home/admin365/didi_mono/backups/`
|
||||
|
||||
Snapshots numerotate cu timestamp `YYYYMMDD_HHMMSS` la momente cheie:
|
||||
- `agent-v3_20260507_122836.tar.gz` (start sesiune)
|
||||
- `agent-v3_pre-pino_*.tar.gz`
|
||||
- `agent-v3_pre-split-routes_*.tar.gz`
|
||||
- `agent-v3_pre-verdict-split_*.tar.gz`
|
||||
- `didiFramework_*.tar.gz` (similar)
|
||||
|
||||
Pentru rollback la orice etapă:
|
||||
```bash
|
||||
tar -xzf /home/admin365/didi_mono/backups/<snapshot>.tar.gz \
|
||||
-C /home/admin365/didi_mono/backend/services/orchestration-layer/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Statistici cumulate sesiune
|
||||
|
||||
| Metric | Start | După |
|
||||
|---|---|---|
|
||||
| `console.*` în prod | 651 | **0** |
|
||||
| `error.message` leaks | 126 | **0** |
|
||||
| `as any` real în prod | 159 | **13** |
|
||||
| Hardcoded passwords în source | 13 | **0** |
|
||||
| Auth bypass active | DA | nu |
|
||||
| Race conditions cunoscute | 3 | 0 |
|
||||
| Files >1000 LOC | 7 | **1** (am spart 9: routes, admin, verdict-calc, auth, pipeline-routes, + 4 executors — singurul rămas e verdict-calc/index.ts cu 864 LOC core class, dar acela e DEJA splitat în folder) |
|
||||
| TypeScript errors | 4 baseline | **0** ambele services |
|
||||
| `process.env.*` direct reads | 137 | 73 |
|
||||
|
||||
**54 task-uri completate, 13 deploy-uri reușite, 0 downtime detectabil.**
|
||||
|
||||
### Update 2026-05-07 (sesiune ulterioară)
|
||||
|
||||
Pipeline-routes split done (1611 → 8 files). Anti-patterns curățate în drum:
|
||||
- 2 `error.message` leaks (lines 943, 1213) → `internalError(res, err, ctx)`
|
||||
- 2 `process.env.X || 'fallback'` (FRAMEWORK_API_URL, M17_WEB_API_URL) → `optionalEnv` / `requireEnv`
|
||||
- Imports mid-file (line 1329-1338) → grupate sus în fiecare sub-file
|
||||
|
||||
Claims executor split done (1228 → 7 files). Strategy: păstrează class API
|
||||
identic, stage methods devin standalone functions ce primesc `ClaimsCtx`
|
||||
(snapshot din state-ul clasei). Class shell rămâne `executor.ts` (319 LOC) cu:
|
||||
constructor, state, `loadConfigs`, `saveResult`, `buildCtx`, `execute()` orchestrator.
|
||||
|
||||
- `searchWebM17` mutat în `web-search.ts` (M17_WEB_API_URL → `requireEnv`)
|
||||
- All 36 unit tests trec după split (executor-output.test.ts)
|
||||
- Public API neatins: `new ClaimsExecutor(redis, llm).execute(text, sessionId, tier)`
|
||||
- Re-export types din executor.ts pentru consumers existenți (component-runner, claims-routes)
|
||||
- Pattern propus pentru ai-tampered/techniques/source-assessment executors:
|
||||
same approach (Ctx + standalone stage functions + thin class shell)
|
||||
|
||||
Ai-tampered executor split done (1043 → 9 files). Same pattern as claims:
|
||||
- 3 stages (screening, deep-analysis, scoring) + helpers (disclosure, patterns, llm-utils)
|
||||
- Class shell în executor.ts (339 LOC) cu execute() + quickAnalyze() (no-LLM rapid path)
|
||||
- AiTamperedCtx snapshot trimis la stage functions
|
||||
- Smoke test live pe `/api/v3/ai-tampered/quick`: input cu disclosure explicit
|
||||
+ ChatGPT mention → ai_probability=85, verdict=LIKELY_AI ✓
|
||||
- All 27 unit tests trec (executor-output.test.ts)
|
||||
- Public API neatins: ambele `execute()` și `quickAnalyze()` exportate identic
|
||||
- Re-export types din executor.ts pentru consumers existenți
|
||||
|
||||
Techniques executor split done (962 → 7 files). Same pattern (no disclosure
|
||||
sub-module — techniques nu are pattern matching). Cea mai mare reducere
|
||||
proporțional: 962 → 237 LOC pentru class shell.
|
||||
- 3 stages (screening, deep-analysis, scoring) + types + llm-utils + _helpers
|
||||
- buildTechniquesList stays cu deep-analysis.ts (e prompt building, nu scoring)
|
||||
- findTechniqueById/calculateManipulationScore/buildCouplingContext în scoring.ts
|
||||
- resolveStageAssignment exportată din types.ts (re-export prin executor.ts)
|
||||
- All 23 unit tests trec
|
||||
- Workers techniques pornesc curat, consumă din 6 queues
|
||||
|
||||
Source-assessment executor split done (932 → 8 files). Structură ușor
|
||||
diferită — nu e 2-stage screening+deep, e 4-step pipeline (extract → search +
|
||||
domain → map → score). Cea mai mare reducere proporțional: 932 → **137 LOC**
|
||||
class shell (a 4-a aplicare a pattern-ului). Public API: doar
|
||||
`SourceAssessmentExecutor` class, niciun type re-export (rezultatul e definit
|
||||
în shared/types/component-results).
|
||||
- 3 stages (extraction, mapping, scoring) + types + defaults (prompts +
|
||||
framework hierarchy + models) + external (M17 + Domain Check API) + config-loader
|
||||
- Anti-pattern fix: `process.env.DOMAIN_CHECK_API_URL || '...'` → `optionalEnv`
|
||||
(hostname intern cu safe default acceptabil)
|
||||
- `M17_WEB_API_URL`: `requireEnv` în loc de inline throw
|
||||
- Smoke test: `/api/v3/source-assessment/health` + `/config` răspund corect
|
||||
- worker-domain rulează source-assessment în-process (nu există worker separat)
|
||||
- Nu există __tests__ folder pentru source-assessment, doar TS check + smoke
|
||||
|
||||
Providers + sync-redis (didiFramework) split done. Două routes mari
|
||||
(921 + 898 LOC) → 17 fișiere total. Shell-uri 6 LOC re-export.
|
||||
- `providers/`: 8 files (configs/models/assignments/keys/all/prompts/test + _helpers).
|
||||
Cel mai mare: assignments.ts (182 LOC). Smoke: `/api/providers/configs` + `/all` OK.
|
||||
- `sync-redis/`: 7 files (sync/status/data + fetch-data + fetch-config + _shared + index).
|
||||
Cel mai mare: sync.ts (328 LOC). Anti-pattern fix: `error.message` în catch-uri →
|
||||
`internalError(res, err, ctx)`. Smoke: GET /status, POST / sync 60 keys în 733ms.
|
||||
- Au inclus mici cleanup-uri: `error: any` → `error` (typed unknown), `catch (e)` cu unused → `catch`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Sfaturi pentru sesiunea nouă
|
||||
|
||||
### Întâi citește
|
||||
|
||||
1. Acest document
|
||||
2. `backend/CLAUDE.md`
|
||||
3. Scurt scan al fișierelor noi din §2 ca să vezi pattern-urile
|
||||
|
||||
### Înainte de orice modificare
|
||||
|
||||
1. **Verifică TS baseline**: `npx tsc --noEmit` în ambele services → trebuie 0 erori
|
||||
2. **Backup tar.gz** la `/home/admin365/didi_mono/backups/` cu timestamp clar
|
||||
3. **NU re-introduce** anti-pattern-urile din §1
|
||||
|
||||
### Pentru split-uri viitoare
|
||||
|
||||
1. Backup
|
||||
2. Identifică boundaries clare (comentarii section, doc comments)
|
||||
3. Extrage helpers shared întâi (în `_init.ts` sau `_helpers/`)
|
||||
4. Folosește `sed -n 'X,Yp'` pentru extracții — atenție la `});` (ușor de pierdut)
|
||||
5. Header cu doc comment + imports + `const router = Router();`
|
||||
6. Footer cu `\nexport default router;`
|
||||
7. Înlocuiește originalul cu un barrel
|
||||
8. `tsc --noEmit` după FIECARE fișier creat
|
||||
9. Rebuild docker — verifică `.dockerignore` are `dist/`
|
||||
10. Smoke test fiecare endpoint group
|
||||
|
||||
### Anti-pattern-uri identificate
|
||||
|
||||
- Mă grăbeam să plec ce face fiecare endpoint sub-router fără să verific exact
|
||||
boundary line. **Verifică `head -3` și `tail -3` la fiecare extracție**.
|
||||
- Sed range `X,Y` extrage inclusiv. Începe cu doc comment line, termină cu `});`.
|
||||
- Atenție la `export default router` care era pe ultima linie a originalului —
|
||||
nu-l copia de două ori.
|
||||
|
||||
### Ce să NU faci
|
||||
|
||||
- ❌ Nu introduce `console.*` direct (folosește `log`)
|
||||
- ❌ Nu introduce `error.message` în response (folosește `internalError`)
|
||||
- ❌ Nu introduce `new Pool()` (folosește pool-ul shared)
|
||||
- ❌ Nu folosi `\|\| 'fallback'` pentru parole/secrets (folosește `requireEnv`)
|
||||
- ❌ Nu adăuga `as any` decât cu comentariu explicit explicând de ce
|
||||
- ❌ Nu modifica `requireAdmin` să fie no-op (asta era bug-ul critic original)
|
||||
- ❌ Nu commita fără să rulezi `tsc --noEmit` întâi
|
||||
24
backend/admin-dashboard/.env.example
Normal file
24
backend/admin-dashboard/.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Admin Dashboard - Build Environment
|
||||
# These vars are baked into the React build at docker build time.
|
||||
# Runtime env vars come from docker-compose.yml.
|
||||
REACT_APP_SERVER_HOST=10.11.10.11
|
||||
REACT_APP_HOST=10.11.10.11
|
||||
# Keycloak — local on didi11, proxied through admin nginx at /auth/
|
||||
REACT_APP_KEYCLOAK_URL=CHANGE_ME
|
||||
REACT_APP_KEYCLOAK_REALM=CHANGE_ME
|
||||
REACT_APP_KEYCLOAK_CLIENT_ID=CHANGE_ME
|
||||
# API — through local Kong (admin nginx /api/ → didi-kong:8000)
|
||||
REACT_APP_API_BASE_URL=https://10.11.10.11:3001
|
||||
# Staging Mode (bypasses Keycloak auth — disable in production!)
|
||||
REACT_APP_STAGING_MODE=false
|
||||
# Service Ports (displayed on service cards)
|
||||
REACT_APP_KEYCLOAK_PORT=CHANGE_ME
|
||||
REACT_APP_KONG_PROXY_PORT=443
|
||||
REACT_APP_KONG_ADMIN_PORT=18101
|
||||
REACT_APP_KONG_MANAGER_PORT=18102
|
||||
REACT_APP_POSTGRES_PORT=5000
|
||||
REACT_APP_PGADMIN_PORT=5050
|
||||
REACT_APP_REDIS_PORT=6379
|
||||
REACT_APP_COMMANDER_PORT=8081
|
||||
REACT_APP_RABBITMQ_MGMT_PORT=15672
|
||||
REACT_APP_MINIO_CONSOLE_PORT=9001
|
||||
76
backend/admin-dashboard/Dockerfile
Normal file
76
backend/admin-dashboard/Dockerfile
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time environment variables
|
||||
# These should be passed from docker-compose using ${PROTOCOL}://${HOST}:${PORT}
|
||||
# CRA bakes these into the static build at compile time
|
||||
ARG REACT_APP_KEYCLOAK_URL
|
||||
ARG REACT_APP_KEYCLOAK_REALM=didi-admins
|
||||
ARG REACT_APP_KEYCLOAK_CLIENT_ID=admin-dashboard
|
||||
ARG REACT_APP_API_BASE_URL
|
||||
ARG REACT_APP_HOST
|
||||
ARG REACT_APP_DIDIAI_GATEWAY_URL
|
||||
ARG REACT_APP_KONG_GATEWAY_URL
|
||||
ARG REACT_APP_KONG_ADMIN_URL
|
||||
ARG REACT_APP_STAGING_MODE=false
|
||||
ENV REACT_APP_STAGING_MODE=$REACT_APP_STAGING_MODE
|
||||
|
||||
# Service ports for dashboard UI
|
||||
ARG REACT_APP_POSTGRES_PORT
|
||||
ARG REACT_APP_PGADMIN_PORT
|
||||
ARG REACT_APP_REDIS_PORT
|
||||
ARG REACT_APP_COMMANDER_PORT
|
||||
ARG REACT_APP_MINIO_CONSOLE_PORT
|
||||
ARG REACT_APP_RABBITMQ_MGMT_PORT
|
||||
ARG REACT_APP_ORCHESTRATOR_PORT
|
||||
ARG REACT_APP_ANALYSIS_PORT
|
||||
ARG REACT_APP_KONG_PROXY_PORT
|
||||
ARG REACT_APP_KONG_ADMIN_PORT
|
||||
ARG REACT_APP_KONG_MANAGER_PORT
|
||||
ARG REACT_APP_KEYCLOAK_PORT
|
||||
|
||||
# DIDI Platform service ports
|
||||
ARG REACT_APP_DIDI_PLATFORM_IP
|
||||
ARG REACT_APP_DIDI_CATALOG_PORT
|
||||
ARG REACT_APP_DIDI_LLM_PORT
|
||||
ARG REACT_APP_DIDI_GPT_OSS_PORT
|
||||
ARG REACT_APP_DIDI_QWEN_VL_PORT
|
||||
ARG REACT_APP_DIDI_WHISPER_PORT
|
||||
ARG REACT_APP_DIDI_BUSTERX_PORT
|
||||
ARG REACT_APP_DIDI_VIDEO_PORT
|
||||
ARG REACT_APP_DIDI_WEB_PORT
|
||||
|
||||
# Copy package files and lockfile for reproducible builds
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies - using npm install due to outdated lock file
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy application code including .env
|
||||
COPY . .
|
||||
|
||||
# Verify react-scripts is installed and build
|
||||
RUN ls -la node_modules/.bin/ && \
|
||||
node node_modules/react-scripts/scripts/build.js
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx page, copy built assets
|
||||
# Files go to both /admin/ (for direct access) and root (for Kong strip_path)
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY --from=build /app/build /usr/share/nginx/html/admin
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx SSL configuration and certificates
|
||||
COPY nginx-ssl.conf /etc/nginx/conf.d/default.conf
|
||||
COPY ssl/server.crt /etc/nginx/ssl/server.crt
|
||||
COPY ssl/server.key /etc/nginx/ssl/server.key
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 443 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
24
backend/admin-dashboard/Dockerfile.dev
Normal file
24
backend/admin-dashboard/Dockerfile.dev
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Development Dockerfile for Admin Dashboard
|
||||
# Uses Node.js directly for hot reload support
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "start"]
|
||||
879
backend/admin-dashboard/INDEX.md
Normal file
879
backend/admin-dashboard/INDEX.md
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
# admin-dashboard - Index
|
||||
|
||||
Dashboard administrativ pentru platforma DIDI. Aplicatie React SPA (Single Page Application) cu Material UI, montata la `/admin`. Ofera interfata grafica pentru managementul complet al platformei: monitorizare servicii, configurare framework analiza, management utilizatori, configurare modele LLM, vizualizare si executie pipeline-uri, si istoric analize.
|
||||
|
||||
**Stack**: React 19 + TypeScript + Material UI 7 + React Router 7
|
||||
**Container**: didi-admin-local (nginx) -- porturi host `3081` -> 80 (HTTP) si `3001` -> 443 (HTTPS)
|
||||
**Port intern**: 80/443 (nginx)
|
||||
**Basename**: /admin (toate rutele prefixate cu /admin)
|
||||
**Autentificare**: Keycloak (client `admin-dashboard`, PKCE S256, realm `didi-admins`), Keycloak LOCAL `didi-keycloak` servit sub `/auth` prin nginx-ul dashboard-ului
|
||||
**Build**: node:20-alpine (build) -> nginx:alpine (serve)
|
||||
**Network**: `didi-network` (single unified network for all DIDI + AI platform stacks since 2026-05-04)
|
||||
|
||||
## URL-uri de acces
|
||||
|
||||
| URL | Cum funcționează |
|
||||
|---|---|
|
||||
| `https://<host-local>:3001/admin/` | direct la containerul `didi-admin-local` (nginx HTTPS, port host 3001) |
|
||||
| `http://<host-local>:3081/admin/` | direct la containerul `didi-admin-local` (nginx HTTP, port host 3081) |
|
||||
| `https://didi365.eu/admin/` | public via Cloudflare tunnel + frontend nginx (când Cloudflare permite path) |
|
||||
| `https://didi365.eu/admin-backend/` | public alias 301 → `/admin/` |
|
||||
|
||||
`<host-local>` = mașina de deployment (host-ul local). Containerul `didi-admin-local` are propria nginx (porturi host 3081/3001); frontend `didi-frontend` adaugă proxy `/admin/*`. Detalii: `frontend/INDEX.md` secțiunea 21.
|
||||
|
||||
---
|
||||
|
||||
## Ce face
|
||||
|
||||
1. **Monitorizare servicii** -- health check pentru toate 14 serviciile backend (polling 30s)
|
||||
2. **Configurare framework analiza** -- CRUD complet pe 20+ tabele de parametri (dimensiuni, tehnici, indicatori, verdicte, ponderi, surse, claims)
|
||||
3. **Management utilizatori** -- lista, cautare, editare, stergere, sincronizare Keycloak, management planuri abonament
|
||||
4. **Configurare modele LLM** -- provideri, modele, assignments pe componente/etape, chei API, teste conectivitate
|
||||
5. **Istoric analize** -- vizualizare si stergere sesiuni analiza din agent-v3 (filtre: risk, status, date)
|
||||
6. **Pipeline builder** -- editor vizual DAG (ReactFlow) pentru pipeline-uri de analiza
|
||||
7. **Executie si monitorizare** -- lansare pipeline-uri, tracking progres real-time (WebSocket + polling)
|
||||
8. **Catalog componente** -- registry pentru functii, resurse AI, transformatoare, modele LLM
|
||||
9. **Sincronizare Redis** -- trigger manual sync parametri framework -> Redis (pentru agent-v3)
|
||||
|
||||
---
|
||||
|
||||
## Rute (App.tsx)
|
||||
|
||||
Toate rutele sunt protejate cu ProtectedRoute (verifica autentificare Keycloak).
|
||||
|
||||
| Path (relativ la /admin) | Componenta | Scop |
|
||||
|---------------------------|-----------|------|
|
||||
| / | ServicesDashboard | Landing page: monitorizare health servicii |
|
||||
| /dashboard | ServicesDashboard | Alias pentru / |
|
||||
| /login | LoginRedirect | Trigger Keycloak login |
|
||||
| /orchestrator | OrchestratorDashboard | Pipeline builder + catalog + executie + istoric |
|
||||
| /orchestrator/runs/:runId | RunDetailsPage | Detalii run individual (timeline, I/O, raw) |
|
||||
| /pipelines | PipelinesPage | **NEW** — CRUD + lifecycle pipeline-uri reale (`input_type_profile`) + dry-run/cancel (Modul 1); admin-only |
|
||||
| /analysis | UniversalAnalysisDashboard | Executie analiza multi-modal (text/image/video/audio/URL) |
|
||||
| /analysis/results/:runId | UniversalResultsPage | Rezultate live cu polling |
|
||||
| /framework | FrameworkDashboard | CRUD complet parametri framework DIDI |
|
||||
| /framework/progressive | ProgressiveAnalysisTree | Vizualizare arbore analiza progresiva |
|
||||
| /users | UserManagement | Management utilizatori + planuri abonament |
|
||||
| /history | AnalysisHistory | Istoric sesiuni analiza admin |
|
||||
| /providers | ProvidersManagement | CRUD provideri LLM, modele, assignments, chei API |
|
||||
| /techniques-v3 | TechniquesV3Config | Configurare modele pe etape techniques |
|
||||
| /llm-components | LLMComponentsConfig | Configurare unificata LLM (Techniques + AI-Tampered + Claims + Verdict) |
|
||||
| /text-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) |
|
||||
| /image-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) |
|
||||
| /video-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) |
|
||||
| /audio-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) |
|
||||
| /moderation | ModerationQueue | Lista pending HIL review queue cu filtre + paginatie |
|
||||
| /moderation/:queueId | ModerationDetail | Detalii sesiune + Resolve dialog (action + corrections JSON + notes) |
|
||||
| /moderation/stats | ModerationStats | Cards count (pending, in_review, resolved_24h) + by-priority + avg time |
|
||||
| /unauthorized | Unauthorized | 403 page pentru useri fara rol staff |
|
||||
|
||||
---
|
||||
|
||||
## Structura fisiere
|
||||
|
||||
```
|
||||
admin-dashboard/
|
||||
Dockerfile -- Multi-stage: node:20-alpine build -> nginx:alpine
|
||||
Dockerfile.dev -- Dev build
|
||||
nginx.conf -- Nginx dev (port 80)
|
||||
nginx-ssl.conf -- Nginx productie (HTTPS 443 + HTTP 80)
|
||||
package.json -- React 19, MUI 7, ReactFlow, Recharts, Keycloak JS
|
||||
.env -- REACT_APP_KEYCLOAK_URL, STAGING_MODE=false
|
||||
src/
|
||||
App.tsx -- Router principal (toate rutele) + AuthProvider + ThemeProvider
|
||||
index.tsx -- Entry point (randeaza App)
|
||||
services/
|
||||
api.ts -- Client API principal (axios, 100+ endpoint-uri)
|
||||
analysisService.ts -- Serviciu analiza legacy (submit/status/results)
|
||||
keycloak.ts -- Configurare Keycloak JS adapter
|
||||
contexts/
|
||||
AuthContext.tsx -- Provider autentificare (token refresh 30s, logout pe 401)
|
||||
ThemeContext.tsx -- Provider tema dark/light (persistat in localStorage)
|
||||
hooks/
|
||||
useAnalysisResults.ts -- Rezultate analiza cu auto-refresh
|
||||
useRunsHistory.ts -- Istoric run-uri paginat cu filtre
|
||||
usePipelineProgress.ts -- WebSocket progres pipeline real-time
|
||||
useDeleteDialog.ts -- Dialog confirmare stergere generic
|
||||
useExpandedItems.ts -- Stare accordion expandat
|
||||
useTabState.ts -- Stare tab per item
|
||||
pages/
|
||||
LandingPage.tsx -- Pagina marketing (nefolosita in rute)
|
||||
FrameworkDashboard.tsx -- CRUD framework (20+ tabele)
|
||||
OrchestratorDashboard.tsx -- Pipeline builder + catalog
|
||||
RunDetailsPage.tsx -- Detalii run (3 tab-uri)
|
||||
UniversalAnalysisDashboard.tsx -- Executie analiza multi-modal
|
||||
UniversalResultsPage.tsx -- Rezultate live polling
|
||||
AudioAnalysisDashboard.tsx -- Legacy
|
||||
ImageAnalysisDashboard.tsx -- Legacy
|
||||
TextAnalysisDashboard.tsx -- Legacy
|
||||
VideoAnalysisDashboard.tsx -- Legacy
|
||||
CostDashboard/ -- Dashboard costuri LLM (orfan, nerutuit)
|
||||
components/
|
||||
auth/
|
||||
LoginRedirect.tsx -- Redirect la Keycloak login
|
||||
ProtectedRoute.tsx -- Guard autentificare pe rute (suporta requiredAnyRole?: string[] any-of)
|
||||
Unauthorized.tsx -- NEW: 403 page cu lista roluri + "Go to Public App" + "Log out"
|
||||
dashboard/
|
||||
ServicesDashboard.tsx -- Grid servicii cu health check
|
||||
services/
|
||||
ServiceCard.tsx -- Card individual serviciu
|
||||
framework/
|
||||
CrudDataTable.tsx -- Tabel CRUD generic (GET/POST/PUT/DELETE)
|
||||
TechniquesTreeView.tsx -- Arbore tehnici
|
||||
ProgressiveAnalysisTree.tsx -- Arbore analiza progresiva
|
||||
AnalysisFlowVisualization/ -- Vizualizare ReactFlow DAG framework
|
||||
orchestrator/
|
||||
PipelineBuilder.tsx -- Editor vizual DAG (ReactFlow)
|
||||
PipelineManager.tsx -- Lista + CRUD pipeline-uri
|
||||
PipelineList.tsx -- Lista pipeline-uri
|
||||
PipelineExecutor.tsx -- Executor pipeline cu polling 500ms
|
||||
ComponentRegistry.tsx -- Catalog componente (functii, resurse, transformatoare)
|
||||
benchmark/
|
||||
PipelineBenchmark.tsx -- Executie si monitorizare benchmark
|
||||
useBenchmarkRunner.ts -- Hook benchmark (execute + poll)
|
||||
runs-history/
|
||||
RunsHistoryPage.tsx -- Istoric run-uri paginat
|
||||
RunsHistoryTable.tsx -- Tabel run-uri
|
||||
RunDetailsDrawer.tsx -- Drawer detalii run
|
||||
NodeWaterfallTimeline.tsx -- Timeline executie noduri
|
||||
nodes/, edges/, dialogs/, hooks/ -- Sub-componente pipeline builder
|
||||
Pipelines/ -- NEW (Modul 1)
|
||||
PipelinesPage.tsx -- Tabel pipeline-uri reale (input_type_profile) + lifecycle (clone/activate/versions/restore/import/export)
|
||||
DryRunDialog.tsx -- Run Console-lite: dry-run pe agent-v3 (flow + noduri + verdict profile, fara executie reala)
|
||||
pipelinesApi.ts -- Client catre /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run|cancel
|
||||
UserManagement/
|
||||
UserManagement.tsx -- Tab utilizatori + planuri
|
||||
UserEditModal.tsx -- Dialog editare utilizator
|
||||
AnalysisHistory/
|
||||
AnalysisHistory.tsx -- Tabel istoric sesiuni analiza
|
||||
AnalysisDetailModal.tsx -- Modal detalii sesiune
|
||||
ProvidersManagement/
|
||||
ProvidersManagement.tsx -- CRUD 4 tabele: provideri, modele, assignments, chei
|
||||
TechniquesV3/
|
||||
TechniquesV3Config.tsx -- Configurare modele techniques
|
||||
LLMComponentsConfig/
|
||||
index.tsx -- Config unificata 3 componente + verdict + provideri
|
||||
VerdictConfig.tsx -- Configurare verdict (ponderi, categorii, severitate)
|
||||
Moderation/ -- NEW
|
||||
api.ts -- fetch helpers + types + UI helpers (priorityColor, statusColor)
|
||||
ModerationQueue.tsx -- paginated table + filters (status, priority), auto-refresh 30s pe pending tab
|
||||
ModerationDetail.tsx -- back btn + action buttons (Claim/Approve/Resolve corrected/Reject) + side-by-side cards (Input + AI Verdict) + Resolve dialog cu JSON corrections editor
|
||||
ModerationStats.tsx -- 3 stat cards + by-priority chips + avg time
|
||||
ModerationSettings/ -- NEW
|
||||
index.tsx -- main panel cu 4 cards + auto-sync after save
|
||||
TriageCard.tsx -- toggle, slider confidence_low, TextFields risk_grey + queue thresholds
|
||||
BrainClientCard.tsx -- toggle, URL, timeouts, sliders confidence_min_silver + semantic_threshold, per-component checkboxes (techniques/ai_tampered/claims). Include alert "CLIENT settings only -- server config in AI platform"
|
||||
SensitiveTopicsCard.tsx -- chip list cu delete + add form (code regex validation)
|
||||
RolesCard.tsx -- table cu toggles (can_resolve, can_escalate, can_force_gold_brain, is_active)
|
||||
api.ts -- fetch helpers pentru /api/moderation-config, /api/sensitive-topics, /api/moderation-roles + sync-redis trigger
|
||||
text-analysis/ -- Componente analiza text legacy
|
||||
image-analysis/ -- Componente analiza imagini legacy
|
||||
audio-analysis/ -- Componente analiza audio legacy
|
||||
video-analysis/ -- Componente analiza video legacy
|
||||
config/
|
||||
serviceGroups.ts -- Definitie grupuri servicii sidebar
|
||||
providerConfigs.ts -- Configurare provideri LLM
|
||||
theme/
|
||||
index.ts -- Tema MUI (light/dark, culori, tipografie)
|
||||
types/
|
||||
catalog.ts -- Tipuri catalog componente
|
||||
keycloak.d.ts -- Tipuri Keycloak
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Servicii backend consumate
|
||||
|
||||
Aplicatia nu are backend propriu. Toate datele vin prin nginx reverse proxy de la 3 servicii + health checks directe.
|
||||
|
||||
### 1. didiFramework (proxiat la /framework)
|
||||
|
||||
Nginx: `/framework/` -> `http://didi-framework:3005`
|
||||
|
||||
Folosit de: FrameworkDashboard, UserManagement, ProvidersManagement, LLMComponentsConfig, VerdictConfig
|
||||
|
||||
### 2. agent-v3 (proxiat la /agent-v3)
|
||||
|
||||
Nginx: `/agent-v3/` -> `http://didi-agent-v3:24803`
|
||||
|
||||
Folosit de: AnalysisHistory, TechniquesV3Config, LLMComponentsConfig, ServicesDashboard (health)
|
||||
|
||||
### 3. Orchestrator API (proxiat la /api/v1 prin Kong)
|
||||
|
||||
Nginx: `/api/` -> `http://didi-kong:8000` (Kong LOCAL)
|
||||
|
||||
Folosit de: OrchestratorDashboard, UniversalAnalysisDashboard, ComponentRegistry, PipelineManager, RunsHistory, CostDashboard
|
||||
|
||||
### 4. Health checks directe
|
||||
|
||||
Nginx proxiaza catre containere individuale pentru verificare disponibilitate.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint-uri API consumate
|
||||
|
||||
### Health checks (ServicesDashboard, polling 30s)
|
||||
|
||||
| Metoda | Endpoint | Serviciu verificat |
|
||||
|--------|----------|--------------------|
|
||||
| GET | /framework/ | didiFramework |
|
||||
| GET | /agent-v3/api/v3/health | agent-v3 |
|
||||
| GET | /api/status | Kong |
|
||||
| GET | /auth/realms/didi-admins | Keycloak (local didi-keycloak) |
|
||||
| GET | /health-check/redis-commander | Redis Commander |
|
||||
| GET | /health-check/rabbitmq | RabbitMQ Management |
|
||||
| GET | /health-check/minio | MinIO |
|
||||
| GET | /health-check/pgadmin | pgAdmin |
|
||||
|
||||
### Framework CRUD (FrameworkDashboard, baza /framework)
|
||||
|
||||
Fiecare endpoint suporta GET (lista), POST (creare), PUT /{id} (update), DELETE /{id} (stergere). Implementat prin componenta generica CrudDataTable.
|
||||
|
||||
| Categorie | Endpoint-uri |
|
||||
|-----------|-------------|
|
||||
| Analysis Core | /api/dimensions, /api/subdimensions |
|
||||
| Tehnici | /api/techniques, /api/indicators, /api/validation-rules |
|
||||
| Evaluare Sursa | /api/platforms, /api/platform-modifiers, /api/source-credibility, /api/domain-age-scores, /api/domain-risk-levels, /api/domain-red-flags, /api/author-classifications, /api/author-credibility |
|
||||
| Claims | /api/claims/status, /api/claims/types, /api/claims/confidence, /api/claims/interpretation |
|
||||
| Verdicte | /api/verdicts/categories, /api/verdicts/risk, /api/verdicts/severity |
|
||||
| Ponderi | /api/weights/components, /api/weights/scenarios, /api/weights/multipliers |
|
||||
| Provideri | /api/providers/configs, /api/providers/models, /api/providers/assignments, /api/providers/keys |
|
||||
|
||||
Extra:
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /api/overview/stats | Statistici framework |
|
||||
| GET | /api/sync-redis/status | Status ultima sincronizare |
|
||||
| POST | /api/sync-redis | Trigger sincronizare Redis |
|
||||
|
||||
### Management utilizatori (UserManagement, baza /framework)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /api/admin/users?page=&limit=&search=&planId= | Lista utilizatori paginata cu filtre |
|
||||
| GET | /api/admin/plans | Lista planuri abonament |
|
||||
| DELETE | /api/admin/users/:id | Dezactivare utilizator |
|
||||
| PUT | /api/admin/users/:id/email-verified | Toggle email verificat |
|
||||
| POST | /api/admin/users/sync | Sincronizare utilizatori Keycloak -> PostgreSQL |
|
||||
| PUT | /api/admin/plans/:id | Editare plan (nume, descriere, credite, pret) |
|
||||
|
||||
### Istoric analize admin (AnalysisHistory, baza /agent-v3/api/v3)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /pipeline/history/admin?page=&limit=&search=&risk_level=&status=&from_date=&to_date= | Lista sesiuni cu filtre |
|
||||
| GET | /pipeline/history/admin/:sessionId | Detalii sesiune |
|
||||
| DELETE | /pipeline/history/admin/:sessionId | Stergere sesiune |
|
||||
|
||||
### Configurare modele LLM (TechniquesV3Config + LLMComponentsConfig, baza /agent-v3/api/v3)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /{component}/config | Configurare componenta |
|
||||
| GET | /{component}/models | Modele disponibile |
|
||||
| GET | /{component}/stage-assignments | Assignments pe etape |
|
||||
| PUT | /{component}/stage-assignments | Salvare assignments |
|
||||
| POST | /{component}/test-model | Test conectivitate model |
|
||||
| POST | /{component}/analyze | Test analiza (doar techniques) |
|
||||
| GET | /{component}/prompts | Lista prompturi componenta |
|
||||
| PUT | /{component}/prompts/:id | Update prompt |
|
||||
|
||||
Unde {component} = techniques, ai-tampered, claims.
|
||||
|
||||
### Moderation queue (ModerationQueue + Detail + Stats, baza /agent-v3/api/v3/moderation)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /queue?status=&priority=&assigned_to=me&limit=20&offset=0 | Lista paginata cu filtre |
|
||||
| GET | /queue/:queueId | Detail (queue entry + full session JOIN) |
|
||||
| POST | /queue/:queueId/claim | Atomic claim by current moderator |
|
||||
| PUT | /queue/:queueId/resolve | body: {action, corrections?, notes?, user_id?} |
|
||||
| POST | /flag | User report (browser extension) |
|
||||
| GET | /stats | Counts + averages |
|
||||
|
||||
### Moderation Settings (ModerationSettings, baza /framework/api)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /moderation-config | Single-row config |
|
||||
| PUT | /moderation-config | Update whitelisted fields |
|
||||
| GET | /sensitive-topics?active=all | List topics |
|
||||
| POST | /sensitive-topics | Create topic |
|
||||
| PUT | /sensitive-topics/:id | Update label/is_active |
|
||||
| DELETE | /sensitive-topics/:id | Soft delete |
|
||||
| GET | /moderation-roles | List roles |
|
||||
| PUT | /moderation-roles/:code | Update permission toggles |
|
||||
|
||||
### Provideri LLM (ProvidersManagement, baza /framework/api/providers)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /all | Toti providerii cu modele |
|
||||
| POST | /configs | Creare provider |
|
||||
| PUT | /configs/:id | Update provider |
|
||||
| DELETE | /configs/:id | Stergere provider |
|
||||
| POST | /models | Creare model |
|
||||
| PUT | /models/:id | Update model |
|
||||
| DELETE | /models/:id | Stergere model |
|
||||
| POST | /assignments | Creare assignment |
|
||||
| PUT | /assignments/:id | Update assignment |
|
||||
| DELETE | /assignments/:id | Stergere assignment |
|
||||
| POST | /keys | Creare cheie API |
|
||||
| PUT | /keys/:id | Update cheie |
|
||||
| DELETE | /keys/:id | Stergere cheie |
|
||||
| POST | /test/:providerId | Test conexiune provider |
|
||||
| GET | /prompts?component={comp} | Lista prompturi componenta |
|
||||
| PUT | /prompts/:id | Update prompt |
|
||||
|
||||
### Orchestrator - Catalog componente (ComponentRegistry, baza /api/v1/catalog)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /functions/ | Lista feature extractors |
|
||||
| GET | /resources/ | Lista resurse AI |
|
||||
| GET | /transformers/ | Lista transformatoare |
|
||||
| GET | /models/ | Lista modele LLM |
|
||||
| GET | /models/filters/options | Optiuni filtre modele |
|
||||
| POST | /{type}/ | Creare componenta |
|
||||
| PUT | /{type}/:id | Update componenta |
|
||||
| DELETE | /{type}/:id | Stergere componenta |
|
||||
| PATCH | /{type}/:id | Toggle enabled/disabled |
|
||||
|
||||
### Orchestrator - Pipeline-uri (PipelineManager, baza /api/v1/pipelines)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | / | Lista pipeline-uri |
|
||||
| GET | /:id | Detalii pipeline |
|
||||
| POST | / | Creare pipeline |
|
||||
| PUT | /:id | Update pipeline |
|
||||
| DELETE | /:id | Stergere pipeline |
|
||||
| GET | /:id/versions | Lista versiuni |
|
||||
| POST | /:id/versions | Creare versiune |
|
||||
| GET | /:id/preview | Preview executie |
|
||||
| POST | /:id/execute | Executie pipeline |
|
||||
| POST | /:id/versions/:ver/execute | Executie versiune specifica |
|
||||
|
||||
### Orchestrator - Run-uri (RunsHistory + RunDetailsPage, baza /api/v1/runs)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | ?limit=&offset=&status=&date_from= | Lista run-uri cu filtre |
|
||||
| GET | /:runId | Detalii run |
|
||||
| GET | /:runId?include_live_status=true | Detalii cu status live |
|
||||
| GET | /:runId/results?include_intermediate=true | Rezultate complete |
|
||||
| GET | /status/:runId | Status polling |
|
||||
| GET | /list?limit= | Lista run-uri (analysisResults) |
|
||||
| GET | /history/enriched?skip=&limit=&pipeline_slug=&verdict= | Istoric imbogatit cu filtre |
|
||||
| DELETE | /:runId | Stergere run |
|
||||
|
||||
### Orchestrator - Template-uri si API-uri externe
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET/POST/PUT/DELETE | /api/v1/dynamic-templates/templates | CRUD template-uri |
|
||||
| GET | /api/v1/dynamic-templates/templates/tasks/list | Lista task-uri |
|
||||
| GET | /api/v1/dynamic-templates/templates/external-apis/list | Lista API-uri externe |
|
||||
| GET | /api/v1/dynamic-templates/templates/feature-extractors/list | Lista extractori |
|
||||
| GET/POST/PUT/DELETE | /api/v1/task-templates | CRUD task template-uri |
|
||||
| POST | /api/v1/task-templates/:id/validate | Validare template |
|
||||
| POST | /api/v1/task-templates/:id/test | Test template |
|
||||
| POST | /api/v1/task-templates/:id/execute | Executie template |
|
||||
| GET | /api/v1/external-apis/providers | Lista provideri externi |
|
||||
| GET/POST/PUT/DELETE | /api/v1/external-apis/apis | CRUD API-uri externe |
|
||||
| POST | /api/v1/sync/openapi/discover | Descoperire OpenAPI |
|
||||
| POST | /api/v1/sync/openapi/import | Import servicii descoperite |
|
||||
| GET | /api/v1/catalog/litellm-catalogue | Catalog LiteLLM |
|
||||
|
||||
### Costuri LLM (CostDashboard -- pagina orfana, nerutata)
|
||||
|
||||
| Metoda | Endpoint | Scop |
|
||||
|--------|----------|------|
|
||||
| GET | /api/v1/costs/summary?start_date=&end_date=&period= | Sumar costuri |
|
||||
| GET | /api/v1/costs/by-model?start_date=&end_date= | Costuri per model |
|
||||
| GET | /api/v1/costs/by-job?start_date=&end_date= | Costuri per job |
|
||||
| GET | /api/v1/costs/thresholds | Praguri configurate |
|
||||
| GET | /api/v1/costs/alerts | Alerte active |
|
||||
| POST | /api/v1/costs/thresholds | Creare prag |
|
||||
| DELETE | /api/v1/costs/thresholds/:id | Stergere prag |
|
||||
| PUT | /api/v1/costs/alerts/:id/acknowledge | Confirmare alerta |
|
||||
|
||||
### WebSocket (progres real-time)
|
||||
|
||||
| Endpoint | Scop |
|
||||
|----------|------|
|
||||
| wss://{host}/ws/runs/:runId/progress?include_outputs=true | Progres executie pipeline |
|
||||
|
||||
Mesaje WebSocket: connected, node_status (update per nod), run_complete, error.
|
||||
Reconnect cu exponential backoff: 1s -> 2s -> 4s -> ... -> 30s max.
|
||||
|
||||
---
|
||||
|
||||
## Pagini -- ce face fiecare
|
||||
|
||||
### ServicesDashboard (/, /dashboard)
|
||||
|
||||
Grid cu carduri pentru toate serviciile grupate pe layere (Data, Gateway/Auth, Orchestration, Monitoring). Fiecare card face health check la 30s si afiseaza status (verde/rosu). Sidebar cu navigare catre paginile admin (Providers, Users, History -- vizibile doar cu rol admin).
|
||||
|
||||
Logica: src/components/dashboard/ServicesDashboard.tsx + src/components/services/ServiceCard.tsx
|
||||
Configurare servicii: src/config/serviceGroups.ts (14 servicii in 4 grupuri)
|
||||
|
||||
### FrameworkDashboard (/framework)
|
||||
|
||||
Management CRUD complet pentru parametrii framework-ului de analiza. 7 categorii cu sub-tab-uri, fiecare cu tabel editabil (CrudDataTable). Include vizualizare ReactFlow a fluxului de analiza si buton "Sync to Redis" pentru a trimite parametrii catre Redis (agent-v3 ii citeste de acolo).
|
||||
|
||||
Logica: src/pages/FrameworkDashboard.tsx (orchestrare tab-uri)
|
||||
Tabel generic: src/components/framework/CrudDataTable.tsx (GET/POST/PUT/DELETE pe orice endpoint)
|
||||
Vizualizare: src/components/framework/AnalysisFlowVisualization/
|
||||
|
||||
### OrchestratorDashboard (/orchestrator)
|
||||
|
||||
Sidebar cu 2 sectiuni: Component Registry (Extractori, Componente AI, Transformatoare) si Pipelines (Lista, Creare, Executie, Istoric). Fiecare sectiune randeaza componenta corespunzatoare.
|
||||
|
||||
Logica: src/pages/OrchestratorDashboard.tsx (layout sidebar)
|
||||
Pipeline builder: src/components/orchestrator/PipelineBuilder.tsx (editor ReactFlow DAG)
|
||||
Pipeline manager: src/components/orchestrator/PipelineManager.tsx (lista + CRUD)
|
||||
Catalog: src/components/orchestrator/ComponentRegistry.tsx (tabele + dialoguri CRUD)
|
||||
Benchmark: src/components/orchestrator/benchmark/PipelineBenchmark.tsx (executie + monitorizare)
|
||||
Istoric: src/components/orchestrator/runs-history/RunsHistoryPage.tsx (tabel paginat + filtre)
|
||||
|
||||
### RunDetailsPage (/orchestrator/runs/:runId)
|
||||
|
||||
Detalii complete pentru un run individual. 3 tab-uri: Timeline (waterfall executie noduri), Input/Output (date intrare/iesire), Raw Data (JSON complet). Auto-refresh la 3s cand run-ul e activ. Foloseste fetch() in loc de axios (workaround pentru extensie browser).
|
||||
|
||||
Logica: src/pages/RunDetailsPage.tsx
|
||||
Timeline: src/components/orchestrator/runs-history/NodeWaterfallTimeline.tsx
|
||||
|
||||
### PipelinesPage (/pipelines) -- NEW (Modul 1)
|
||||
|
||||
Livrabil Modul 1 caiet ("Workflow Builder: CRUD/versionare pipeline; Run Console"). Opereaza pe **definitiile reale de pipeline** (`input_type_profile` din didiFramework), NU pe `pipelineApi` mort (`/api/v1/pipelines`, fara backend). Tabel cu profilurile + actiuni de lifecycle: dry-run, clone, activate/deactivate, versiuni (istoric + restore), import/export.
|
||||
|
||||
- **Dry-run** (`DryRunDialog.tsx`): apeleaza `POST /agent-v3/api/v3/pipeline/dry-run` -> arata flow-ul, nodurile (component/queue/priority/stages/prompt keys), componentele skipped si verdict profile-ul rezolvat, fara a rula analiza reala. **Cancel** disponibil pe run-uri active.
|
||||
- Backing endpoints: `/framework/api/pipelines/*` (list/get/clone/activate/deactivate/PUT cu versionare/versions/restore/import) + `/agent-v3/api/v3/pipeline/dry-run|:id/cancel`.
|
||||
|
||||
Logica: src/components/Pipelines/PipelinesPage.tsx + DryRunDialog.tsx + pipelinesApi.ts
|
||||
Acces: admin-only (`ProtectedRoute requiredRole="admin"`).
|
||||
|
||||
### UniversalAnalysisDashboard (/analysis)
|
||||
|
||||
Executie analiza multi-modal: text, imagine, video, audio, URL. Sidebar cu 3 sectiuni: Analysis (formular + executor), Results (run-uri active, polling 10s), History (paginat cu filtre). Suporta upload fisiere in MinIO si tracking progres executie.
|
||||
|
||||
Logica: src/pages/UniversalAnalysisDashboard.tsx
|
||||
Polling executie: 2s (GET /api/v1/runs/:runId?include_live_status=true)
|
||||
Polling run-uri active: 10s (GET /api/v1/runs?limit=50)
|
||||
|
||||
### UniversalResultsPage (/analysis/results/:runId)
|
||||
|
||||
Pagina rezultate live. Afiseaza progress bar, status per nod (accordion), rezultate finale JSON. Polling la 2s.
|
||||
|
||||
Logica: src/pages/UniversalResultsPage.tsx
|
||||
|
||||
### UserManagement (/users)
|
||||
|
||||
2 tab-uri: Utilizatori (tabel paginat cu cautare, filtrare plan, editare, stergere, sync Keycloak, toggle email-verified) si Planuri Abonament (editare inline: nume, descriere, credite, pret).
|
||||
|
||||
Logica: src/components/UserManagement/UserManagement.tsx
|
||||
Dialog editare: src/components/UserManagement/UserEditModal.tsx
|
||||
|
||||
### AnalysisHistory (/history)
|
||||
|
||||
Tabel paginat cu toate sesiunile de analiza din agent-v3. Filtre: cautare text, nivel risc (LOW/MODERATE/HIGH/VERY_HIGH/CRITICAL), status, interval date. Coloane: data, email, input preview, risk score, tehnici, AI %, claims, durata. View deschide AnalysisDetailModal cu toate detaliile sesiunii.
|
||||
|
||||
**Cost display per analiza (USD)** — in AnalysisDetailModal, sectiunea "LLM Usage" afiseaza:
|
||||
- Badge total cost langa titlul accordionului (ex: `$0.0321`)
|
||||
- Grid cu 5 celule: Calls / Prompt Tokens / Completion Tokens / Total Tokens / **Est. Cost**
|
||||
- Per Component: fiecare card contine cost chip individual (ex: Techniques: `$0.0054`)
|
||||
|
||||
Costul se calculeaza client-side dintr-o combinatie de:
|
||||
1. `llm_usage.by_component[comp]` — prompt_tokens, completion_tokens, models_used[] din agent-v3
|
||||
2. Fetch parallel la `/framework/api/providers/models` — care returneaza `input_cost_per_1m` + `output_cost_per_1m` per model
|
||||
3. Formula: `cost = (prompt_tokens / 1M) * input_cost + (completion_tokens / 1M) * output_cost`
|
||||
4. Cost-ul e charged pe **primul model** din `models_used[]` (aproximare: daca primary a picat si s-a dus pe fallback, cost-ul ar putea fi putin subestimat pe free chain, supra-estimat pe premium — dar in happy path e 100% corect)
|
||||
|
||||
Free tier afiseaza tot `$0.00` (Qwen local nu are cost input/output). Premium arata cost real per analiza — tipic $0.02-0.05 pentru text simplu, pana la $0.10-0.15 pentru media cu claims multiple.
|
||||
|
||||
Logica: src/components/AnalysisHistory/AnalysisHistory.tsx
|
||||
Modal detalii: src/components/AnalysisHistory/AnalysisDetailModal.tsx (helpers: `normalizeModelKey`, `computeTokenCost`, `formatUsd`)
|
||||
|
||||
### ProvidersManagement (/providers)
|
||||
|
||||
CRUD pe 4 tab-uri: Providers (configurare provideri LLM), Models (modele per provider), Assignments (assignment componenta -> model pe etapa), API Keys (chei de acces). Fiecare tab cu dialoguri add/edit/delete. Buton test conexiune.
|
||||
|
||||
Logica: src/components/ProvidersManagement/ProvidersManagement.tsx
|
||||
|
||||
### TechniquesV3Config (/techniques-v3)
|
||||
|
||||
3 tab-uri: Stage Assignments (model primar + 3 fallback-uri per etapa screening/deep), Available Models (catalog cu info speed/quality/cost), Test Analysis (textarea + buton executie test). Comunicare cu agent-v3.
|
||||
|
||||
Logica: src/components/TechniquesV3/TechniquesV3Config.tsx
|
||||
|
||||
### LLMComponentsConfig (/llm-components)
|
||||
|
||||
Pagina unificata pentru configurare LLM si scoring pe toate componentele. Top-level tab-uri: Techniques, AI Tampered, Claims, Source Assessment, Verdict, **Moderation** (NEW), Providers.
|
||||
|
||||
**Moderation** (NEW) — al 6-lea buton (alaturi de Techniques, AI Tampered, Claims, Source Assessment, Verdict). Randeaza `<ModerationSettings />` cu 4 cards pentru HIL triage + brain client + sensitive topics + roles. Auto-syncs to Redis dupa fiecare save (snackbar feedback).
|
||||
|
||||
**Toggle FREE | PREMIUM** (Etapa 6) — peste Stage Assignments, un `ToggleButtonGroup` permite switch-ul intre chain-ul `free` si `premium` pentru componenta curenta:
|
||||
- Cand selectezi **FREE**, tabelul arata chain-ul primary+fallbacks din rows cu `tier='free'` (Qwen local primary + cloud fallbacks)
|
||||
- Cand selectezi **PREMIUM**, tabelul arata chain-ul din rows cu `tier='premium'` (Gemini 3 Flash / Claude Sonnet primary + Qwen local safety)
|
||||
- Badge-ul stage (`[FREE]` verde / `[PREMIUM]` galben) reflecta tier-ul curent selectat
|
||||
- Daca un stage NU are rows premium configurate, apare un `Alert` warning si fallback automat la chain-ul `free`
|
||||
- Editare: cand userul schimba un model in tabel, se aplica doar pe tier-ul curent selectat; salvarea itereaza ambele tiers si trimite PUT la `/api/providers/assignments/:id`
|
||||
|
||||
State-ul `selectedTier` e stored in componenta main LLMComponentsConfig; `StageAssignmentsPanel` primeste `selectedTier` ca prop si filtreaza `stage.modelsByTier[selectedTier]` la randare. Structura `modelsByTier: Record<'free'|'premium', ModelConfig[]>` e construita in `fetchStageAssignments()` prin grouping pe `tier` din response-ul GET /api/providers/assignments (care include acum campul `tier`).
|
||||
|
||||
Per componenta analiza (Techniques, AI Tampered, Claims, Source Assessment):
|
||||
- **Stage Assignments**: modele LLM pe fiecare etapa (primary + 3 fallback-uri) — **cu toggle Free/Premium**
|
||||
- **Vision Models**: cascade viziune (doar AI Tampered)
|
||||
- **Available Models**: catalog modele din PG
|
||||
- **Parameters**: scoring_config editabil — toti parametrii de calcul scor (din PG via didiFramework)
|
||||
- Techniques: count_scaler, severe_threshold, intensity bonus, dimension/count bonuses
|
||||
- AI Tampered: blend_weights, disclosure_impact, undisclosed_threshold, verdict thresholds
|
||||
- Claims: status_weights (VT/LT/UV/OP/LF/VF), all_unverified behavior/credibility
|
||||
- Source Assessment: axis_weights (publication/domain/author/platform), verdict_thresholds
|
||||
- **Prompts**: system prompt + user template editabile
|
||||
- **Test Analysis**: test cu text sample
|
||||
|
||||
Verdict (VerdictConfig) — 8 tab-uri:
|
||||
- Component Weights (ponderile globale, fallback daca profilurile nu exista)
|
||||
- Verdict Categories (RELIABLE..DISINFORMATION ranges, editabile)
|
||||
- Risk Levels (VERY_LOW..CRITICAL ranges, editabile)
|
||||
- Severity & Actions (LOW..CRITICAL + actiuni recomandate)
|
||||
- Overrides & Synergy (configurare override-uri globale)
|
||||
- Confidence (bonusuri/penalitati)
|
||||
- Topic Multipliers (elections, health, climate — editabile)
|
||||
- **Input Profiles** (NOU): ponderi per input type (text/image/audio/video/url), override-uri per profil, AI disclosure multipliers, reguli INCONCLUSIVE. Editabil cu save + sync to Redis.
|
||||
|
||||
Logica: src/components/LLMComponentsConfig/index.tsx
|
||||
Verdict: src/components/LLMComponentsConfig/VerdictConfig.tsx
|
||||
|
||||
### ModerationQueue (/moderation)
|
||||
|
||||
Paginated table cu filtre (status: pending/in_review/resolved/all + priority CSV). Auto-refresh 30s pe pending tab. Click row -> detail. Top-right: link "View Stats".
|
||||
|
||||
Logica: src/components/Moderation/ModerationQueue.tsx
|
||||
Polling: 30s (auto-refresh pe pending tab)
|
||||
|
||||
### ModerationDetail (/moderation/:queueId)
|
||||
|
||||
Side-by-side: Input (text/url + meta) | AI Verdict (risk_score, confidence, severity, components run/skipped). Card metadata queue cu enqueue_reason + meta JSONB. Action buttons:
|
||||
- Claim for Review (pe pending)
|
||||
- Approve as is / Resolve with Corrections / Reject (pe in_review)
|
||||
|
||||
Resolve dialog: action toggle + JSON corrections editor (validated client-side) + notes.
|
||||
|
||||
Logica: src/components/Moderation/ModerationDetail.tsx
|
||||
|
||||
### ModerationStats (/moderation/stats)
|
||||
|
||||
3 stat cards (pending, in_review, resolved_24h) cu colored borders. Open by Priority chips. Avg time in queue. Auto-refresh 30s.
|
||||
|
||||
Logica: src/components/Moderation/ModerationStats.tsx
|
||||
|
||||
---
|
||||
|
||||
## Autentificare (Keycloak)
|
||||
|
||||
### Configurare
|
||||
|
||||
Fisier: src/services/keycloak.ts
|
||||
|
||||
```
|
||||
Client ID: admin-dashboard (din REACT_APP_KEYCLOAK_CLIENT_ID, obligatoriu)
|
||||
Realm: didi-admins (din REACT_APP_KEYCLOAK_REALM, obligatoriu)
|
||||
PKCE: S256 (obligatoriu)
|
||||
URL: din REACT_APP_KEYCLOAK_URL (requireEnv) — pointeaza spre /auth pe nginx-ul dashboard-ului, care proxiaza catre Keycloak LOCAL didi-keycloak
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
Fisier: src/contexts/AuthContext.tsx
|
||||
|
||||
1. La pornire: `keycloak.init({ onLoad: 'login-required' })`
|
||||
2. Token stocat in localStorage.keycloak_token
|
||||
3. Refresh token la fiecare 30s (updateToken(70) -- refresh daca expira in < 70s)
|
||||
4. La 401 de la axios: event auth:unauthorized -> logout automat
|
||||
5. REACT_APP_STAGING_MODE=true sare peste toata autentificarea
|
||||
|
||||
### Protectie rute
|
||||
|
||||
Fisier: src/components/auth/ProtectedRoute.tsx
|
||||
|
||||
- Verifica isAuthenticated inainte de a randa componenta
|
||||
- Redirect la /login daca neautentificat
|
||||
- Suporta requiredRole (verificare rol Keycloak)
|
||||
- Suporta `requiredAnyRole?: string[]` (any-of) pentru rute partajate intre mai multe roluri staff
|
||||
|
||||
Fisier: src/components/auth/LoginRedirect.tsx -- apeleaza keycloak.login() imediat
|
||||
Fisier: src/components/auth/Unauthorized.tsx -- 403 page (lista roluri necesare + "Go to Public App" + "Log out")
|
||||
|
||||
### Role gating (admin / moderator / senior_moderator)
|
||||
|
||||
Roluri definite pe Keycloak LOCAL `didi-keycloak`, realm `didi-admins`: `moderator`, `senior_moderator` (langa `admin` existent).
|
||||
|
||||
- `App.tsx`: AdminLayout wrapped in `ProtectedRoute requiredAnyRole=[admin, moderator, senior_moderator]` (end-users tip `viewer` primesc 403 -> Unauthorized page)
|
||||
- Per-route guards:
|
||||
- /framework, /users, /providers, /llm-components -> admin-only
|
||||
- /history + /moderation/* -> admin OR moderator
|
||||
- AdminLayout sidebar: sectiunea Configuration admin-only; sectiunea Management split (Users=admin, History=staff, Moderation=moderator-aware)
|
||||
- Staging mode (`REACT_APP_STAGING_MODE=true`) face `hasRole()` sa returneze mereu true -> toate gates bypass pentru testare locala. In prod, Keycloak JWT roles enforcate strict.
|
||||
|
||||
### Context expus
|
||||
|
||||
AuthContext expune: isAuthenticated, initialized, user (tokenParsed), token, login(), logout(), hasRole(role)
|
||||
|
||||
---
|
||||
|
||||
## Configurare Nginx
|
||||
|
||||
### Productie (nginx-ssl.conf)
|
||||
|
||||
HTTPS pe port 443 (TLS 1.2/1.3) + HTTP pe port 80.
|
||||
|
||||
| Location | Proxy catre | Scop |
|
||||
|----------|------------|------|
|
||||
| / | SPA fallback /admin/index.html | React app |
|
||||
| /api/ | http://didi-kong:8000 | Orchestrator API prin Kong LOCAL |
|
||||
| /auth/ | http://keycloak:8080 | Keycloak LOCAL (container `didi-keycloak`); NU strip — Keycloak servit sub `/auth` (KC_HTTP_RELATIVE_PATH) |
|
||||
| /agent-v3/ | http://didi-agent-v3:24803 | agent-v3 direct (strip prefix) |
|
||||
| /framework/ | http://didi-framework:3005 | didiFramework direct (strip prefix) |
|
||||
| /health-check/redis-commander | http://staging-dataLayer-redis-commander:8081 | Health check Redis Commander |
|
||||
| /health-check/rabbitmq | http://staging-dataLayer-rabbitmq:15672 | Health check RabbitMQ |
|
||||
| /health-check/minio | http://staging-dataLayer-minio:9000 | Health check MinIO |
|
||||
| /health-check/pgadmin | http://staging-dataLayer-pgadmin:80 | Health check pgAdmin |
|
||||
| /health-check/postgres | http://staging-dataLayer-postgres:5432 | Health check PostgreSQL |
|
||||
|
||||
Headere securitate: X-Frame-Options SAMEORIGIN, X-Content-Type-Options nosniff, X-XSS-Protection.
|
||||
Cache static: 1 an + immutable pentru .js, .css, .png, .svg, .ico.
|
||||
|
||||
### Dev (nginx.conf)
|
||||
|
||||
Acelasi layout fara SSL, porturi 3000 si 80. Nu are proxy pentru /api/, /auth/, sau health checks (doar /agent-v3/ si /framework/).
|
||||
|
||||
---
|
||||
|
||||
## Comunicare real-time
|
||||
|
||||
### WebSocket (usePipelineProgress)
|
||||
|
||||
Fisier: src/hooks/usePipelineProgress.ts
|
||||
|
||||
Conectare la `wss://{host}/ws/runs/:runId/progress?include_outputs=true` pentru tracking progres pipeline.
|
||||
|
||||
Mesaje primite:
|
||||
- connected -- confirmare subscriptie
|
||||
- node_status -- update executie nod (key, status, label, type, timestamp, duration_ms)
|
||||
- run_complete -- pipeline terminat (status: success/error/stopped, total_duration_ms)
|
||||
- error -- mesaj eroare
|
||||
|
||||
Reconnect exponential backoff: 1s -> 2s -> 4s -> ... -> 30s max.
|
||||
|
||||
### Polling HTTP
|
||||
|
||||
| Componenta | Interval | Endpoint |
|
||||
|-----------|----------|----------|
|
||||
| ServicesDashboard | 30s | Health checks (8 endpoint-uri) |
|
||||
| UniversalAnalysisDashboard | 2s | GET /api/v1/runs/:runId?include_live_status=true |
|
||||
| UniversalAnalysisDashboard | 10s | GET /api/v1/runs?limit=50 |
|
||||
| PipelineExecutor | 500ms | GET /api/v1/runs/:runId?include_live_status=true |
|
||||
| UniversalResultsPage | 2s | GET /api/v1/runs/status/:runId |
|
||||
| RunDetailsPage | 3s | GET /api/v1/runs/:runId/results |
|
||||
| RunsHistory | 10s | GET /api/v1/runs (optional, toggle) |
|
||||
| useAnalysisResults | 5s | GET /api/v1/runs/:id?include_live_status=true |
|
||||
|
||||
---
|
||||
|
||||
## Hooks custom
|
||||
|
||||
| Hook | Fisier | Scop |
|
||||
|------|--------|------|
|
||||
| useAnalysisResults | src/hooks/useAnalysisResults.ts | Rezultate analiza cu auto-refresh 5s, filtrare pe tags |
|
||||
| useRunsHistory | src/hooks/useRunsHistory.ts | Istoric run-uri paginat (fetch(), nu axios), filtre status/date/search |
|
||||
| usePipelineProgress | src/hooks/usePipelineProgress.ts | WebSocket progres pipeline, reconnect backoff |
|
||||
| useDeleteDialog | src/hooks/useDeleteDialog.ts | State generic dialog confirmare stergere |
|
||||
| useExpandedItems | src/hooks/useExpandedItems.ts | State accordion expandat (Set) |
|
||||
| useTabState | src/hooks/useTabState.ts | State tab per item (Record) |
|
||||
| useBenchmarkRunner | src/components/orchestrator/benchmark/useBenchmarkRunner.ts | Executie benchmark pipeline + polling status |
|
||||
| useAutoLayout | src/components/orchestrator/hooks/useAutoLayout.ts | Auto-layout noduri ReactFlow |
|
||||
| useDagValidation | src/components/orchestrator/hooks/useDagValidation.ts | Validare DAG (detectie cicluri) |
|
||||
| useEdgeSuggestions | src/components/orchestrator/hooks/useEdgeSuggestions.ts | Sugestii edge-uri data-flow |
|
||||
| useProviderModels | src/components/orchestrator/hooks/useProviderModels.ts | Fetch modele si resurse catalog |
|
||||
| useResourceEndpoints | src/components/orchestrator/hooks/useResourceEndpoints.ts | Fetch resurse catalog |
|
||||
|
||||
---
|
||||
|
||||
## Tema (light/dark)
|
||||
|
||||
Fisier: src/theme/index.ts
|
||||
Persistenta: localStorage['didi-admin-theme']
|
||||
Toggle: src/components/ThemeToggle.tsx (buton in sidebar ServicesDashboard)
|
||||
|
||||
### Culori
|
||||
|
||||
| Rol | Valoare | Nume |
|
||||
|-----|---------|------|
|
||||
| Primary | #0052CC | Deep Trust Blue |
|
||||
| Secondary | #00BFA6 | Honest Teal |
|
||||
| Success | #28A745 | Truth Green |
|
||||
| Error | #E63946 | Caution Red |
|
||||
| Warning | #FF8C42 | Insight Orange |
|
||||
| Background (light) | #F5F7FA | -- |
|
||||
| Background (dark) | #121212 | -- |
|
||||
| Text (light) | #1F2933 | Slate Charcoal |
|
||||
|
||||
### Tipografie
|
||||
|
||||
Font: Inter, Roboto, Helvetica, Arial
|
||||
- h1: 32px/700, h2: 24px/600, h3: 20px/500, body1: 16px/400, button: 14px/500/uppercase
|
||||
|
||||
### Overrides MUI
|
||||
|
||||
- Button: radius 8, padding 12px 24px
|
||||
- Card: radius 12, hover shadow
|
||||
- TextField: primary border on focus
|
||||
- Chip: rounded 16px
|
||||
|
||||
---
|
||||
|
||||
## Dependente principale (package.json)
|
||||
|
||||
| Pachet | Versiune | Scop |
|
||||
|--------|----------|------|
|
||||
| react | ^19.1.1 | UI framework |
|
||||
| react-router-dom | ^7.7.1 | Routing |
|
||||
| @mui/material | ^7.3.1 | Component library |
|
||||
| axios | ^1.11.0 | HTTP client |
|
||||
| keycloak-js | ^26.2.0 | Autentificare Keycloak |
|
||||
| @react-keycloak/web | ^3.4.0 | React bindings Keycloak |
|
||||
| reactflow | ^11.11.4 | Vizualizare DAG pipeline |
|
||||
| recharts | ^3.1.0 | Grafice costuri |
|
||||
| react-dnd | ^16.0.1 | Drag-and-drop |
|
||||
| react-dropzone | ^14.3.8 | Upload fisiere |
|
||||
| zod | ^3.25.76 | Validare schema |
|
||||
| neverthrow | ^8.1.1 | Result types (Ok/Err) |
|
||||
| ts-pattern | ^5.6.0 | Pattern matching |
|
||||
|
||||
---
|
||||
|
||||
## Configurare Docker
|
||||
|
||||
### Dockerfile (productie)
|
||||
|
||||
```
|
||||
Stage 1: node:20-alpine
|
||||
- npm install --legacy-peer-deps
|
||||
- react-scripts build (20+ ARG-uri pentru REACT_APP_*)
|
||||
- ARG REACT_APP_KEYCLOAK_REALM default = didi-admins (= realm-ul real); .env confirma aceeasi valoare
|
||||
- ARG REACT_APP_STAGING_MODE=false (+ ENV explicit — CRA altfel bake-uia ARG default in loc sa citeasca .env)
|
||||
|
||||
Stage 2: nginx:alpine
|
||||
- Copie build in /usr/share/nginx/html/admin si /usr/share/nginx/html
|
||||
- Copie nginx-ssl.conf + certificate SSL
|
||||
- Expose 443, 80
|
||||
```
|
||||
|
||||
### Variabile de mediu (.env)
|
||||
|
||||
`<host-local>` = mașina de deployment (host-ul local). Valorile reale din `.env` (bake-uite in build la build time):
|
||||
|
||||
```
|
||||
# General
|
||||
REACT_APP_SERVER_HOST=<host-local>
|
||||
REACT_APP_HOST=<host-local>
|
||||
REACT_APP_API_BASE_URL=https://<host-local>:3001 # prin Kong LOCAL (admin nginx /api/ → didi-kong:8000)
|
||||
REACT_APP_STAGING_MODE=false
|
||||
|
||||
# Keycloak — LOCAL, proxiat prin admin nginx la /auth/
|
||||
REACT_APP_KEYCLOAK_URL=https://<host-local>:3001/auth
|
||||
REACT_APP_KEYCLOAK_REALM=didi-admins
|
||||
REACT_APP_KEYCLOAK_CLIENT_ID=admin-dashboard
|
||||
|
||||
# Kong Gateway (LOCAL)
|
||||
REACT_APP_KONG_PROXY_PORT=443
|
||||
REACT_APP_KONG_ADMIN_PORT=18101
|
||||
REACT_APP_KONG_MANAGER_PORT=18102
|
||||
|
||||
# Porturi servicii (afisate pe service cards)
|
||||
REACT_APP_KEYCLOAK_PORT=28000
|
||||
REACT_APP_POSTGRES_PORT=5000
|
||||
REACT_APP_PGADMIN_PORT=5050
|
||||
REACT_APP_REDIS_PORT=6379
|
||||
REACT_APP_COMMANDER_PORT=8081
|
||||
REACT_APP_RABBITMQ_MGMT_PORT=15672
|
||||
REACT_APP_MINIO_CONSOLE_PORT=9001
|
||||
```
|
||||
|
||||
Nota: `REACT_APP_KEYCLOAK_URL` **este** folosit de `keycloak.ts` (`requireEnv`), pointand spre `/auth` pe nginx-ul dashboard-ului, care proxiaza catre Keycloak LOCAL `didi-keycloak`.
|
||||
|
||||
`REACT_APP_STAGING_MODE=false` (real) → autentificarea Keycloak e ACTIVA; cand ar fi `true`, s-ar sari peste toata autentificarea (doar pentru testare locala).
|
||||
|
||||
---
|
||||
|
||||
## Ce NU face
|
||||
|
||||
- Nu are backend propriu (doar nginx reverse proxy + SPA static)
|
||||
- Nu scrie direct in baza de date (toate operatiile prin didiFramework sau orchestrator API)
|
||||
- Nu proceseaza analize (doar le lanseaza si monitorizeaza)
|
||||
- Nu gestioneaza fisiere (upload-urile merg prin orchestrator API -> MinIO)
|
||||
- Nu are SSR (Server Side Rendering) -- e un SPA clasic
|
||||
- Nu are teste unitare (doar un e2e dark-mode.spec.ts cu Playwright)
|
||||
- CostDashboard exista dar nu e rutata (pagina orfana)
|
||||
|
||||
## Functionalitati suplimentare (in afara celor 9 module de caiet)
|
||||
|
||||
| Functionalitate | Unde in cod | Note |
|
||||
|-----------------|-------------|------|
|
||||
| CostDashboard (costuri LLM) | `src/pages/CostDashboard/` | Pagina orfana — NU e rutata in App.tsx; consuma `/api/v1/costs/*` de pe orchestrator |
|
||||
|
||||
(HIL Moderation din dashboard — ModerationQueue/Detail/Stats + ModerationSettings — este integrarea UI a Modulului de moderare din backend; ramane in sectiunile de mai sus.)
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes (2026-05-05)
|
||||
|
||||
- **Keycloak login fix CRITICAL**: `src/services/keycloak.ts` ignora `REACT_APP_KEYCLOAK_URL` (folosea `${window.location.origin}/auth` hardcoded) — cauza "Cookie not found" cross-domain. Acum foloseste `requireEnv('REACT_APP_KEYCLOAK_URL')` direct.
|
||||
- **`.env`**: `REACT_APP_STAGING_MODE=false` (era `true` -> bypass auth complet), `REACT_APP_KEYCLOAK_REALM=didi-admins` (era gresit `didi-clients`), `REACT_APP_KEYCLOAK_URL=https://<host-local>:3001/auth` (Keycloak LOCAL `didi-keycloak` prin admin nginx `/auth`; nu SSO cluster).
|
||||
- **`Dockerfile`**: `ARG REACT_APP_STAGING_MODE=false` (era `true`) + `ENV REACT_APP_STAGING_MODE=$REACT_APP_STAGING_MODE` adaugat (CRA bake-uia ARG default in loc sa citeasca .env).
|
||||
- **`Moderation/api.ts` + `ModerationSettings/api.ts`**: helper nou `authedFetch()` care injecteaza `Authorization: Bearer ${localStorage.keycloak_token}`. Inainte faceau `fetch()` fara auth -> 401 pe Kong.
|
||||
- **Role-based UI guard `AdminLayout.tsx`**: Dashboard + service groups (Data Layer, Gateway Layer, etc.) acum vizibile doar pentru `admin`. Moderator/senior_moderator vad doar Management section (Analysis History + Moderation).
|
||||
- **`App.tsx` `DefaultLanding`**: redirect automat la `/moderation` pentru non-admin (in loc de ServicesDashboard). Plus route `/dashboard` cere rol `admin`.
|
||||
- **UI cosmetics**: `ModerationQueue.tsx` Status/Priority labels cu `display: block, mb: 0.5` (erau lipite de butoane); priority 1,2,3,4,5 (erau doar 1,3,4); `AdminLayout.tsx` sidebar logo + main navbar header ambele la `height: 64px` (linii divider aliniate); divider deasupra "Management" ascuns pentru non-admin.
|
||||
|
||||
---
|
||||
|
||||
## User Management overhaul (2026-05-05)
|
||||
|
||||
`UserManagement.tsx` complet rescris (3 tabs) + 2 componente noi + `UserEditModal.tsx` extins. Toate hook-uite la endpoint-urile noi din didiFramework `/api/admin/*`.
|
||||
|
||||
### Tab #1 — Users (extins)
|
||||
|
||||
Coloane: ☑ Checkbox (bulk), Email (+ keycloak_id snippet), Name, **Sync** chip (Synced/KC only — warning bg), Plan, **Group** chip, **Roles** chips (top-3 + "+N more"), Email Verified Switch, Active, Credits (tooltip "Spent: N"), **Storage** progress bar (>90% red / >70% warning), Actions (Edit modal / 🛡️ RolesDialog / 📜 UsageHistory / 🗑️ Delete hard).
|
||||
|
||||
Filter row: search + Plan + **Sync filter** (All/Synced/Keycloak only).
|
||||
|
||||
Bulk toolbar (apare pe selectare): activate / deactivate / mark email verified / set credits to N / delete. Sequential per id (evită hammer Keycloak).
|
||||
|
||||
### Tab #2 — Subscription Plans (nemodificat)
|
||||
|
||||
### Tab #3 — Audit Log (NOU)
|
||||
|
||||
Citește `/api/admin/audit-log` paginat. Filtre Action preset + Actor email (ILIKE) + Since (datetime-local). Coloane: When / Action chip color-coded / Target / Actor / Payload (truncat + tooltip JSON pretty) / IP.
|
||||
|
||||
### Componente noi
|
||||
|
||||
| File | Rol |
|
||||
|------|-----|
|
||||
| `RolesDialog.tsx` | Multi-select check-list pe realm roles, diff summary (add/remove), save → `PUT /:id/roles`. |
|
||||
| `UsageHistoryModal.tsx` | Last 100 rows din `bos_sysadmin.ai_credit_usage`. Defensive render (input_type chip + credits + session_id + JSON fallback pe coloane necunoscute). |
|
||||
|
||||
### `UserEditModal.tsx` extins
|
||||
|
||||
În plus față de existent: **Group select** (dropdown grupuri Keycloak, "(no group)" = remove) + **"Send password reset email" button** (apelează `POST /:id/reset-password` lifespan 86400s, Snackbar succes).
|
||||
|
||||
### `SensitiveTopicsCard.tsx` extins (Phase D1)
|
||||
|
||||
Rânduri rich cu volatility chip color-coded (volatile=red, evolving=warning, stable=success) + TTL/recency chips + iconul ⚙️ Tune → `EditVolatilityDialog` (Select tier auto-seed defaults + override fine pe ttl/recency/half_life). Add-form extins cu Volatility select.
|
||||
|
||||
### Live state
|
||||
|
||||
20 users live. Reset-password fail cunoscut: cluster Keycloak SMTP not configured (502 cu detail în audit_log) — operațional, nu cod.
|
||||
64
backend/admin-dashboard/README.md
Normal file
64
backend/admin-dashboard/README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Admin Dashboard
|
||||
|
||||
React admin interface for the DIDI misinformation detection platform.
|
||||
|
||||
## Stack
|
||||
|
||||
React 19, Material UI 7, TypeScript, Keycloak auth (PKCE S256).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t didi-admin:latest .
|
||||
|
||||
# Deploy (container defined in data-layer compose)
|
||||
cd ../services/data-layer && docker compose up -d --force-recreate didi-admin
|
||||
```
|
||||
|
||||
Access: `https://10.11.10.12:3000` (self-signed cert, STAGING_MODE bypasses auth)
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | Page | What it does |
|
||||
|-------|------|-------------|
|
||||
| `/admin` | Services Dashboard | Health check grid for all containers |
|
||||
| `/admin/framework` | Framework Config | CRUD for techniques, verdicts, weights, claims, sources |
|
||||
| `/admin/llm-components` | LLM Components | Stage assignments, scoring params, prompts per component |
|
||||
| `/admin/history` | Analysis History | Browse/delete analysis sessions (admin view) |
|
||||
| `/admin/users` | User Management | Users list, plans, email verification |
|
||||
| `/admin/providers` | Providers | LLM providers, models, API keys |
|
||||
|
||||
## Architecture
|
||||
|
||||
Nginx serves the React SPA and proxies API calls:
|
||||
- `/framework/` → didi-framework:3005
|
||||
- `/agent-v3/` → didi-agent-v3:24803
|
||||
- `/health-check/*` → direct to service containers
|
||||
|
||||
Health checks use Docker container state via framework API (`GET /api/admin/containers`).
|
||||
|
||||
## Key Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/api.ts # API client, health checks, container mapping
|
||||
├── components/
|
||||
│ ├── services/ServiceCard.tsx # Service card with Logs button
|
||||
│ ├── services/LogsModal.tsx # Docker logs viewer (colorized)
|
||||
│ ├── AnalysisHistory/ # Analysis sessions table + detail modal
|
||||
│ ├── UserManagement/ # Users + subscription plans
|
||||
│ ├── LLMComponentsConfig/ # LLM config per component + VerdictConfig
|
||||
│ └── framework/CrudDataTable.tsx # Generic CRUD table for framework params
|
||||
├── config/serviceGroups.ts # Service grouping for dashboard layout
|
||||
└── contexts/AuthContext.tsx # Keycloak auth (bypass in staging mode)
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Build-time vars (baked into JS at `docker build`):
|
||||
- `REACT_APP_STAGING_MODE` — bypasses Keycloak auth
|
||||
- `REACT_APP_HOST` — used for service UI URLs
|
||||
|
||||
Runtime vars (from docker-compose):
|
||||
- `REACT_APP_KEYCLOAK_URL`, `REACT_APP_KEYCLOAK_REALM`, `REACT_APP_KEYCLOAK_CLIENT_ID`
|
||||
54
backend/admin-dashboard/e2e/auth.setup.ts
Normal file
54
backend/admin-dashboard/e2e/auth.setup.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { test as setup, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
|
||||
|
||||
/**
|
||||
* Authentication setup - logs in once and saves storage state
|
||||
* All tests will reuse this authenticated session
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Navigate to app (will redirect to Keycloak)
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for page to settle
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if we're on a login page (Keycloak at port 18280 or has login form)
|
||||
const hasLoginForm = await page.locator('input[name="username"], input[id="username"]').isVisible().catch(() => false);
|
||||
const isKeycloakUrl = page.url().includes(':18280');
|
||||
|
||||
if (hasLoginForm || isKeycloakUrl) {
|
||||
console.log('Login page detected, filling credentials...');
|
||||
|
||||
// Fill in login credentials
|
||||
const testEmail = process.env.TEST_USER_EMAIL || 'admin@local.dev';
|
||||
const testPassword = process.env.TEST_USER_PASSWORD || 'admin';
|
||||
|
||||
// Try different input selectors for username/email
|
||||
const usernameInput = page.locator('input[name="username"], input[id="username"], input[type="email"]').first();
|
||||
await usernameInput.fill(testEmail);
|
||||
|
||||
// Fill password
|
||||
const passwordInput = page.locator('input[name="password"], input[id="password"], input[type="password"]').first();
|
||||
await passwordInput.fill(testPassword);
|
||||
|
||||
// Click sign in button
|
||||
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Sign In")').first().click();
|
||||
|
||||
// Wait for redirect back to app
|
||||
await page.waitForURL('http://localhost:13003/**', { timeout: 30000 });
|
||||
}
|
||||
|
||||
// Wait for app to be fully loaded
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify we're authenticated by checking for dashboard content
|
||||
// Look for "Service Monitor" text which appears in the header
|
||||
await expect(page.locator('text=Service Monitor').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
console.log('Authentication successful, saving storage state...');
|
||||
|
||||
// Save storage state (cookies, localStorage)
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
199
backend/admin-dashboard/e2e/dark-mode.spec.ts
Normal file
199
backend/admin-dashboard/e2e/dark-mode.spec.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Dark Mode Toggle Integration Tests
|
||||
* Tests the theme switching functionality across the admin dashboard
|
||||
*/
|
||||
|
||||
test.describe('Dark Mode Toggle', () => {
|
||||
// Clear theme preference before each test (keep auth tokens)
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
// Only clear theme preference, not auth tokens
|
||||
await page.evaluate(() => localStorage.removeItem('didi-admin-theme'));
|
||||
});
|
||||
|
||||
test.describe('Service Monitor Page', () => {
|
||||
test('should display theme toggle in sidebar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Theme toggle should be visible in the sidebar header
|
||||
const sidebarToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]');
|
||||
await expect(sidebarToggle.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch to dark mode when toggle is clicked', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Click the theme toggle (switch to dark)
|
||||
const toggleButton = page.locator('button[aria-label*="Switch to dark"]').first();
|
||||
await toggleButton.click();
|
||||
|
||||
// Verify localStorage was updated
|
||||
const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme'));
|
||||
expect(theme).toBe('dark');
|
||||
|
||||
// Verify the toggle now shows "switch to light"
|
||||
const lightToggle = page.locator('button[aria-label*="Switch to light"]').first();
|
||||
await expect(lightToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test('should apply dark background color in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Switch to dark mode
|
||||
await page.locator('button[aria-label*="Switch to dark"]').first().click();
|
||||
|
||||
// Check that background color changed to dark
|
||||
const bgColor = await page.evaluate(() => {
|
||||
const main = document.querySelector('main') || document.body;
|
||||
return getComputedStyle(main).backgroundColor;
|
||||
});
|
||||
|
||||
// Dark mode background should be dark (#121212 = rgb(18, 18, 18))
|
||||
expect(bgColor).toMatch(/rgb\(18,\s*18,\s*18\)|rgb\(30,\s*30,\s*30\)/);
|
||||
});
|
||||
|
||||
test('should switch back to light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Switch to dark mode first
|
||||
await page.locator('button[aria-label*="Switch to dark"]').first().click();
|
||||
|
||||
// Switch back to light mode
|
||||
await page.locator('button[aria-label*="Switch to light"]').first().click();
|
||||
|
||||
// Verify localStorage
|
||||
const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme'));
|
||||
expect(theme).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Theme Persistence', () => {
|
||||
test('should persist dark mode after page refresh', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Switch to dark mode
|
||||
await page.locator('button[aria-label*="Switch to dark"]').first().click();
|
||||
// Wait for theme to be saved
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Refresh the page
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for app to stabilize and verify toggle is visible first
|
||||
const lightToggle = page.locator('button[aria-label*="Switch to light"]').first();
|
||||
await expect(lightToggle).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Now verify localStorage
|
||||
const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme'));
|
||||
expect(theme).toBe('dark');
|
||||
});
|
||||
|
||||
test('should persist light mode after page refresh', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Refresh the page
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for app to stabilize and verify toggle is visible
|
||||
const darkToggle = page.locator('button[aria-label*="Switch to dark"]').first();
|
||||
await expect(darkToggle).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Orchestrator Page', () => {
|
||||
test('should display theme toggle in sidebar header', async ({ page }) => {
|
||||
await page.goto('/orchestrator');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Theme toggle should be visible in the drawer header
|
||||
const sidebarToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]');
|
||||
await expect(sidebarToggle.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should share theme state with Service Monitor', async ({ page }) => {
|
||||
// Set dark mode on Service Monitor
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('button[aria-label*="Switch to dark"]').first().click();
|
||||
// Wait for theme to be saved
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to Orchestrator
|
||||
await page.goto('/orchestrator', { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for toggle to be visible first (proves page is loaded)
|
||||
const lightToggle = page.locator('button[aria-label*="Switch to light"]').first();
|
||||
await expect(lightToggle).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Now verify localStorage
|
||||
const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme'));
|
||||
expect(theme).toBe('dark');
|
||||
});
|
||||
|
||||
test('should apply dark mode styling on Orchestrator page', async ({ page }) => {
|
||||
await page.goto('/orchestrator');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Switch to dark mode
|
||||
await page.locator('button[aria-label*="Switch to dark"]').first().click();
|
||||
|
||||
// Main content should have dark background
|
||||
const bgColor = await page.evaluate(() => {
|
||||
const main = document.querySelector('main') || document.body;
|
||||
return getComputedStyle(main).backgroundColor;
|
||||
});
|
||||
|
||||
expect(bgColor).toMatch(/rgb\(18,\s*18,\s*18\)|rgb\(30,\s*30,\s*30\)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Cross-Page Consistency', () => {
|
||||
test('theme toggle position should be consistent (sidebar)', async ({ page }) => {
|
||||
// Check Service Monitor
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const serviceMonitorToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]').first();
|
||||
await expect(serviceMonitorToggle).toBeVisible();
|
||||
|
||||
// Check Orchestrator
|
||||
await page.goto('/orchestrator');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const orchestratorToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]').first();
|
||||
await expect(orchestratorToggle).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Dark Mode', () => {
|
||||
test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE
|
||||
|
||||
test('should have theme toggle in mobile app bar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// On mobile, toggle should be in the AppBar
|
||||
const appBarToggle = page.locator('.MuiAppBar-root button[aria-label*="Switch to"]');
|
||||
await expect(appBarToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle dark mode on mobile', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Click toggle in AppBar
|
||||
await page.locator('.MuiAppBar-root button[aria-label*="Switch to dark"]').click();
|
||||
|
||||
// Verify dark mode
|
||||
const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme'));
|
||||
expect(theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
287
backend/admin-dashboard/nginx-ssl.conf
Normal file
287
backend/admin-dashboard/nginx-ssl.conf
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost 10.11.10.12;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Global: allow large file uploads (video up to 100MB)
|
||||
client_max_body_size 100M;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Redirect root to /admin/ so browser URL matches React Router basename
|
||||
location = / {
|
||||
return 301 $scheme://$http_host/admin/;
|
||||
}
|
||||
|
||||
# /admin-backend/ — branding alias for /admin/. Both paths land users on
|
||||
# the same SPA. Keeps /admin/ as the canonical basename so we don't have
|
||||
# to touch Keycloak redirect URIs or React Router config.
|
||||
location = /admin-backend { return 301 $scheme://$http_host/admin/; }
|
||||
location /admin-backend/ { return 301 $scheme://$http_host/admin/; }
|
||||
|
||||
# /admin-ai/ — reverse-proxy to AI platform dashboard (didiAI-dashboard).
|
||||
# AI platform's React SPA is built with base=/admin-ai/ and FastAPI mounts
|
||||
# the SPA at /admin-ai/, so paths line up — no rewrite needed for assets.
|
||||
# `^~` ensures this prefix beats the .js/.css regex location below.
|
||||
location ^~ /admin-ai/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $aidash didiAI-dashboard;
|
||||
proxy_pass http://$aidash:51300;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_buffer_size 64k;
|
||||
proxy_buffers 4 64k;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_connect_timeout 5s;
|
||||
}
|
||||
|
||||
# Proxy ALL /api/ requests to local Kong (didi-kong container on didi-network)
|
||||
# Host header overridden to didi365.eu so Kong matches the DIDI route hosts.
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host didi365.eu;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# Proxy /auth/ directly to Keycloak (KC_HTTP_RELATIVE_PATH=/auth, no rewrite)
|
||||
# ^~ forces this prefix to beat the static .css/.js regex location below
|
||||
location ^~ /auth/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $kc_upstream keycloak;
|
||||
proxy_pass http://$kc_upstream:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Proxy to Agent V3 API (video processing can take up to 10 min)
|
||||
location /agent-v3/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $agent_upstream didi-agent-v3;
|
||||
rewrite ^/agent-v3/(.*) /$1 break;
|
||||
proxy_pass http://$agent_upstream:24803;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# Proxy to Framework API
|
||||
location /framework/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $framework_upstream didi-framework;
|
||||
rewrite ^/framework/(.*) /$1 break;
|
||||
proxy_pass http://$framework_upstream:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check proxies for internal services
|
||||
location /health-check/postgres {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $pg staging-dataLayer-postgres;
|
||||
proxy_pass http://$pg:5432/;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
location /health-check/redis-commander {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $rc staging-dataLayer-redis-commander;
|
||||
proxy_pass http://$rc:8081/redis-commander/;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
location /health-check/rabbitmq {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $rmq staging-dataLayer-rabbitmq;
|
||||
proxy_pass http://$rmq:15672/;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
location /health-check/minio {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $minio staging-dataLayer-minio;
|
||||
proxy_pass http://$minio:9000/minio/health/live;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
location /health-check/pgadmin {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $pga staging-dataLayer-pgadmin;
|
||||
proxy_pass http://$pga:80/;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 3s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
# HTTP server (for Kong proxy + direct access)
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost 10.11.10.12;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location = / {
|
||||
return 301 $scheme://$http_host/admin/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host didi365.eu;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location ^~ /auth/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $kc_upstream keycloak;
|
||||
proxy_pass http://$kc_upstream:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
location /agent-v3/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $agent_upstream didi-agent-v3;
|
||||
rewrite ^/agent-v3/(.*) /$1 break;
|
||||
proxy_pass http://$agent_upstream:24803;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location /framework/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $framework_upstream didi-framework;
|
||||
rewrite ^/framework/(.*) /$1 break;
|
||||
proxy_pass http://$framework_upstream:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check proxies (same as HTTPS block)
|
||||
location /health-check/postgres {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $pg staging-dataLayer-postgres;
|
||||
proxy_pass http://$pg:5432/;
|
||||
proxy_connect_timeout 3s;
|
||||
}
|
||||
location /health-check/redis-commander {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $rc staging-dataLayer-redis-commander;
|
||||
proxy_pass http://$rc:8081/redis-commander/;
|
||||
proxy_connect_timeout 3s;
|
||||
}
|
||||
location /health-check/rabbitmq {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $rmq staging-dataLayer-rabbitmq;
|
||||
proxy_pass http://$rmq:15672/;
|
||||
proxy_connect_timeout 3s;
|
||||
}
|
||||
location /health-check/minio {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $minio staging-dataLayer-minio;
|
||||
proxy_pass http://$minio:9000/minio/health/live;
|
||||
proxy_connect_timeout 3s;
|
||||
}
|
||||
location /health-check/pgadmin {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $pga staging-dataLayer-pgadmin;
|
||||
proxy_pass http://$pga:80/;
|
||||
proxy_connect_timeout 3s;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
53
backend/admin-dashboard/nginx.conf
Normal file
53
backend/admin-dashboard/nginx.conf
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
server {
|
||||
listen 3000;
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Proxy to Agent V3 API
|
||||
location /agent-v3/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $agent_upstream didi-agent-v3;
|
||||
rewrite ^/agent-v3/(.*) /$1 break;
|
||||
proxy_pass http://$agent_upstream:24803;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy to Framework API
|
||||
location /framework/ {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $framework_upstream didi-framework;
|
||||
rewrite ^/framework/(.*) /$1 break;
|
||||
proxy_pass http://$framework_upstream:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
16339
backend/admin-dashboard/package-lock.json
generated
Normal file
16339
backend/admin-dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
71
backend/admin-dashboard/package.json
Normal file
71
backend/admin-dashboard/package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "admin-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/admin",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.2.0",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@react-keycloak/web": "^3.4.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-dropzone": "^5.1.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"axios": "^1.11.0",
|
||||
"keycloak-js": "^26.2.0",
|
||||
"neverthrow": "^8.1.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.1.0",
|
||||
"ts-pattern": "^5.6.0",
|
||||
"typescript": "^5.0.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"type-check": "tsc --noEmit",
|
||||
"generate:types": "python3 scripts/generate_node_types.py",
|
||||
"generate:api-types": "npx openapi-typescript http://localhost:8080/api/v1/openapi.json -o src/types/generated-api.ts"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/keycloak-js": "^3.4.1",
|
||||
"openapi-typescript": "^7.4.1"
|
||||
}
|
||||
}
|
||||
101
backend/admin-dashboard/playwright.config.ts
Normal file
101
backend/admin-dashboard/playwright.config.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
const authFile = path.join(__dirname, 'playwright/.auth/user.json');
|
||||
|
||||
/**
|
||||
* Playwright configuration for admin-dashboard e2e tests
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Directory containing test files
|
||||
testDir: './e2e',
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry failed tests (2 retries on CI, 0 locally)
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Limit parallel workers on CI
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
|
||||
// Shared settings for all projects
|
||||
use: {
|
||||
// Base URL for navigation (parameterized via ADMIN_DASHBOARD_PORT)
|
||||
baseURL: `http://localhost:${process.env.ADMIN_DASHBOARD_PORT}`,
|
||||
|
||||
// Capture screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Capture trace on failure (for debugging)
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Default timeout for actions
|
||||
actionTimeout: 10000,
|
||||
},
|
||||
|
||||
// Test timeout
|
||||
timeout: 30000,
|
||||
|
||||
// Configure projects for different browsers
|
||||
projects: [
|
||||
// Setup project - runs authentication once
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
// Main test projects - depend on setup for authentication
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
// Mobile viewports
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
// Run local dev server before starting tests (optional)
|
||||
// Uncomment if you want Playwright to start the server automatically
|
||||
// webServer: {
|
||||
// command: 'npm start',
|
||||
// url: `http://localhost:${process.env.ADMIN_DASHBOARD_PORT}`,
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// timeout: 120000,
|
||||
// },
|
||||
});
|
||||
BIN
backend/admin-dashboard/public/favicon.ico
Normal file
BIN
backend/admin-dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
4
backend/admin-dashboard/public/favicon.svg
Normal file
4
backend/admin-dashboard/public/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#4A148C"/>
|
||||
<text x="32" y="46" text-anchor="middle" font-family="Arial,sans-serif" font-weight="bold" font-size="40" fill="white">d</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
35
backend/admin-dashboard/public/index.html
Normal file
35
backend/admin-dashboard/public/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#4A148C" />
|
||||
<meta
|
||||
name="description"
|
||||
content="didi Administration Platform - Misinformation Detection System"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!-- Import Inter font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>didi Administration Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
backend/admin-dashboard/public/logo.png
Normal file
BIN
backend/admin-dashboard/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
backend/admin-dashboard/public/logo192.png
Normal file
BIN
backend/admin-dashboard/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
backend/admin-dashboard/public/logo512.png
Normal file
BIN
backend/admin-dashboard/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
25
backend/admin-dashboard/public/manifest.json
Normal file
25
backend/admin-dashboard/public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "didi Admin",
|
||||
"name": "didi Administration Platform",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
backend/admin-dashboard/public/robots.txt
Normal file
3
backend/admin-dashboard/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
backend/admin-dashboard/public/silent-check-sso.html
Normal file
12
backend/admin-dashboard/public/silent-check-sso.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Silent SSO Check</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
384
backend/admin-dashboard/scripts/generate_node_types.py
Normal file
384
backend/admin-dashboard/scripts/generate_node_types.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate TypeScript types from Pydantic node schemas.
|
||||
|
||||
This script introspects the Pydantic models in shared.node_schemas and generates:
|
||||
1. TypeScript interfaces for each node spec
|
||||
2. Validation rules extracted from Field constraints (ge, le, min_length, etc.)
|
||||
3. Discriminated union type for all node specs
|
||||
|
||||
Usage:
|
||||
python generate_node_types.py
|
||||
|
||||
Output:
|
||||
../src/components/orchestrator/types/nodeSpecs.generated.ts
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import get_type_hints, get_origin, get_args, Any, Optional, List, Dict, Literal, Union
|
||||
from datetime import datetime
|
||||
|
||||
# Add the orchestration-layer to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "services" / "orchestration-layer"))
|
||||
|
||||
from shared.node_schemas import (
|
||||
NODE_SPEC_SCHEMAS,
|
||||
NODE_OUTPUT_SCHEMAS,
|
||||
LLMNodeSpec,
|
||||
APINodeSpec,
|
||||
TransformNodeSpec,
|
||||
StorageNodeSpec,
|
||||
FunctionNodeSpec,
|
||||
PythonREPLNodeSpec,
|
||||
AudioTranscriptionNodeSpec,
|
||||
ConditionalNodeSpec,
|
||||
SubagentNodeSpec,
|
||||
AggregatorNodeSpec,
|
||||
InputNodeSpec,
|
||||
OutputNodeSpec,
|
||||
ForEachNodeSpec,
|
||||
JoinNodeSpec,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
|
||||
def python_type_to_ts(python_type: Any) -> str:
|
||||
"""Convert Python type annotation to TypeScript type."""
|
||||
origin = get_origin(python_type)
|
||||
args = get_args(python_type)
|
||||
|
||||
# Handle None/NoneType
|
||||
if python_type is type(None):
|
||||
return "null"
|
||||
|
||||
# Handle Optional (Union with None)
|
||||
if origin is Union:
|
||||
non_none_args = [a for a in args if a is not type(None)]
|
||||
if len(non_none_args) == 1:
|
||||
# Optional[X] -> X | null
|
||||
return f"{python_type_to_ts(non_none_args[0])} | null"
|
||||
else:
|
||||
# Union of multiple types
|
||||
return " | ".join(python_type_to_ts(a) for a in args)
|
||||
|
||||
# Handle Literal
|
||||
if origin is Literal:
|
||||
return " | ".join(f"'{a}'" if isinstance(a, str) else str(a) for a in args)
|
||||
|
||||
# Handle List
|
||||
if origin is list:
|
||||
inner = python_type_to_ts(args[0]) if args else "any"
|
||||
return f"{inner}[]"
|
||||
|
||||
# Handle Dict
|
||||
if origin is dict:
|
||||
key_type = python_type_to_ts(args[0]) if args else "string"
|
||||
value_type = python_type_to_ts(args[1]) if len(args) > 1 else "any"
|
||||
return f"Record<{key_type}, {value_type}>"
|
||||
|
||||
# Handle basic types
|
||||
type_map = {
|
||||
str: "string",
|
||||
int: "number",
|
||||
float: "number",
|
||||
bool: "boolean",
|
||||
Any: "any",
|
||||
type(None): "null",
|
||||
}
|
||||
|
||||
if python_type in type_map:
|
||||
return type_map[python_type]
|
||||
|
||||
# Handle Pydantic models (nested)
|
||||
if isinstance(python_type, type) and issubclass(python_type, BaseModel):
|
||||
return python_type.__name__
|
||||
|
||||
# Fallback
|
||||
return "any"
|
||||
|
||||
|
||||
def extract_field_constraints(field: FieldInfo) -> Dict[str, Any]:
|
||||
"""Extract validation constraints from a Pydantic field."""
|
||||
constraints = {}
|
||||
|
||||
# Get default value
|
||||
if field.default is not None and field.default is not ...:
|
||||
constraints["default"] = field.default
|
||||
|
||||
# Extract from metadata (Pydantic v2 style)
|
||||
for constraint in field.metadata:
|
||||
constraint_type = type(constraint).__name__
|
||||
if constraint_type == "Ge":
|
||||
constraints["min"] = constraint.ge
|
||||
elif constraint_type == "Le":
|
||||
constraints["max"] = constraint.le
|
||||
elif constraint_type == "Gt":
|
||||
constraints["min"] = constraint.gt
|
||||
constraints["exclusiveMin"] = True
|
||||
elif constraint_type == "Lt":
|
||||
constraints["max"] = constraint.lt
|
||||
constraints["exclusiveMax"] = True
|
||||
elif constraint_type == "MinLen":
|
||||
constraints["minLength"] = constraint.min_length
|
||||
elif constraint_type == "MaxLen":
|
||||
constraints["maxLength"] = constraint.max_length
|
||||
|
||||
# Check if required
|
||||
constraints["required"] = field.is_required()
|
||||
|
||||
return constraints
|
||||
|
||||
|
||||
def generate_interface(model: type[BaseModel], model_name: str) -> tuple[str, Dict[str, Dict[str, Any]]]:
|
||||
"""Generate TypeScript interface and validation rules for a Pydantic model."""
|
||||
lines = [f"export interface {model_name} {{"]
|
||||
validation_rules = {}
|
||||
|
||||
type_hints = get_type_hints(model)
|
||||
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
python_type = type_hints.get(field_name, Any)
|
||||
ts_type = python_type_to_ts(python_type)
|
||||
|
||||
# Check if optional
|
||||
is_optional = not field_info.is_required()
|
||||
optional_marker = "?" if is_optional else ""
|
||||
|
||||
# Get description for JSDoc
|
||||
description = field_info.description or ""
|
||||
|
||||
# Add JSDoc comment
|
||||
if description:
|
||||
lines.append(f" /** {description} */")
|
||||
|
||||
lines.append(f" {field_name}{optional_marker}: {ts_type};")
|
||||
|
||||
# Extract validation constraints
|
||||
constraints = extract_field_constraints(field_info)
|
||||
if constraints:
|
||||
validation_rules[field_name] = constraints
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines), validation_rules
|
||||
|
||||
|
||||
def generate_nested_models() -> str:
|
||||
"""Generate interfaces for nested models used in specs."""
|
||||
# Import nested models
|
||||
from shared.node_schemas.llm_node import ToolConfig
|
||||
from shared.node_schemas.api_node import FallbackEndpoint
|
||||
from shared.node_schemas.subagent_node import SubagentPass
|
||||
from shared.node_schemas.aggregator_node import SignalConfig, ThresholdConfig
|
||||
from shared.node_schemas.conditional_node import ConditionalBranch
|
||||
|
||||
nested_models = [
|
||||
ToolConfig,
|
||||
FallbackEndpoint,
|
||||
SubagentPass,
|
||||
SignalConfig,
|
||||
ThresholdConfig,
|
||||
ConditionalBranch,
|
||||
]
|
||||
|
||||
interfaces = []
|
||||
for model in nested_models:
|
||||
interface, _ = generate_interface(model, model.__name__)
|
||||
interfaces.append(interface)
|
||||
|
||||
return "\n\n".join(interfaces)
|
||||
|
||||
|
||||
def generate_output_fields(model: type[BaseModel]) -> List[str]:
|
||||
"""Extract output field names from an output schema."""
|
||||
return list(model.model_fields.keys())
|
||||
|
||||
|
||||
def extract_conditional_output_fields(model: type[BaseModel]) -> Dict[str, Dict[str, List[Any]]]:
|
||||
"""
|
||||
Extract conditional field availability from json_schema_extra.
|
||||
Returns: { "field_name": { "spec_field": [allowed_values] } }
|
||||
"""
|
||||
conditionals = {}
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
if field_name in ['error', 'error_type', 'error_message', 'details']:
|
||||
continue
|
||||
extra = field_info.json_schema_extra or {}
|
||||
if isinstance(extra, dict) and 'available_when' in extra:
|
||||
conditionals[field_name] = extra['available_when']
|
||||
return conditionals
|
||||
|
||||
|
||||
def main():
|
||||
output_path = Path(__file__).parent.parent / "src" / "components" / "orchestrator" / "types" / "nodeSpecs.generated.ts"
|
||||
|
||||
# Node specs to generate
|
||||
node_specs = {
|
||||
"llm": LLMNodeSpec,
|
||||
"api": APINodeSpec,
|
||||
"transform": TransformNodeSpec,
|
||||
"storage": StorageNodeSpec,
|
||||
"function": FunctionNodeSpec,
|
||||
"python_repl": PythonREPLNodeSpec,
|
||||
"audio_transcription": AudioTranscriptionNodeSpec,
|
||||
"conditional": ConditionalNodeSpec,
|
||||
"subagent": SubagentNodeSpec,
|
||||
"aggregator": AggregatorNodeSpec,
|
||||
"input": InputNodeSpec,
|
||||
"output": OutputNodeSpec,
|
||||
"foreach": ForEachNodeSpec,
|
||||
"join": JoinNodeSpec,
|
||||
}
|
||||
|
||||
all_interfaces = []
|
||||
all_validation_rules = {}
|
||||
all_output_fields = {}
|
||||
|
||||
# Header
|
||||
header = f"""/**
|
||||
* Auto-generated TypeScript types from Pydantic node schemas
|
||||
*
|
||||
* DO NOT EDIT MANUALLY - run 'npm run generate:types' to regenerate
|
||||
* Generated: {datetime.now().isoformat()}
|
||||
*
|
||||
* Source: backend/services/orchestration-layer/shared/node_schemas/
|
||||
*/
|
||||
|
||||
"""
|
||||
|
||||
# Generate nested model interfaces first
|
||||
nested_interfaces = generate_nested_models()
|
||||
all_interfaces.append(nested_interfaces)
|
||||
|
||||
# Generate spec interfaces
|
||||
for node_type, spec_class in node_specs.items():
|
||||
interface_name = f"{spec_class.__name__}"
|
||||
interface, validation = generate_interface(spec_class, interface_name)
|
||||
all_interfaces.append(interface)
|
||||
|
||||
# Convert to TypeScript-friendly key (snake_case -> UPPER_SNAKE_CASE)
|
||||
validation_key = f"{node_type.upper()}_VALIDATION"
|
||||
all_validation_rules[validation_key] = validation
|
||||
|
||||
# Get output fields from corresponding output schema
|
||||
output_class = NODE_OUTPUT_SCHEMAS.get(node_type.replace("_", ""))
|
||||
if output_class:
|
||||
all_output_fields[node_type] = generate_output_fields(output_class)
|
||||
|
||||
# Generate discriminated union
|
||||
union_members = []
|
||||
for node_type, spec_class in node_specs.items():
|
||||
union_members.append(f" | {{ type: '{node_type}' }} & {spec_class.__name__}")
|
||||
|
||||
discriminated_union = f"""/**
|
||||
* Discriminated union of all node specs.
|
||||
* Use with type narrowing: if (spec.type === 'llm') {{ spec.model_ref... }}
|
||||
*/
|
||||
export type NodeSpec =
|
||||
{chr(10).join(union_members)};
|
||||
"""
|
||||
|
||||
# Generate validation rules export
|
||||
validation_export = "/**\n * Validation rules extracted from Pydantic Field constraints\n */\n"
|
||||
for key, rules in all_validation_rules.items():
|
||||
validation_export += f"export const {key} = {{\n"
|
||||
for field_name, constraints in rules.items():
|
||||
constraints_str = ", ".join(
|
||||
f"{k}: {repr(v) if isinstance(v, str) else str(v).lower() if isinstance(v, bool) else v}"
|
||||
for k, v in constraints.items()
|
||||
)
|
||||
validation_export += f" {field_name}: {{ {constraints_str} }},\n"
|
||||
validation_export += "} as const;\n\n"
|
||||
|
||||
# Generate output fields export
|
||||
output_fields_export = "/**\n * Output fields available for each node type (for NodeConfig.Header)\n */\n"
|
||||
output_fields_export += "export const NODE_OUTPUT_FIELDS: Record<string, string[]> = {\n"
|
||||
for node_type, fields in all_output_fields.items():
|
||||
fields_str = ", ".join(f"'{f}'" for f in fields if f not in ['error', 'error_type', 'error_message', 'details'])
|
||||
output_fields_export += f" {node_type}: [{fields_str}],\n"
|
||||
output_fields_export += "};\n"
|
||||
|
||||
# Generate conditional output fields
|
||||
all_conditional_fields = {}
|
||||
for node_type in node_specs.keys():
|
||||
output_class = NODE_OUTPUT_SCHEMAS.get(node_type.replace("_", ""))
|
||||
if output_class:
|
||||
conditionals = extract_conditional_output_fields(output_class)
|
||||
if conditionals:
|
||||
all_conditional_fields[node_type] = conditionals
|
||||
|
||||
conditional_export = """
|
||||
/**
|
||||
* Conditional output field availability based on node spec values.
|
||||
* Maps: node_type -> { field_name -> { spec_field -> allowed_values[] } }
|
||||
* Fields without conditions are always available.
|
||||
*/
|
||||
export const CONDITIONAL_OUTPUT_FIELDS: Record<string, Record<string, Record<string, unknown[]>>> = """
|
||||
conditional_export += json.dumps(all_conditional_fields, indent=2) + ";\n"
|
||||
|
||||
helper_function = """
|
||||
/**
|
||||
* Get context-aware output fields based on node spec configuration.
|
||||
* Filters NODE_OUTPUT_FIELDS using CONDITIONAL_OUTPUT_FIELDS metadata.
|
||||
*
|
||||
* @param nodeType - The node type
|
||||
* @param spec - The node's spec configuration
|
||||
* @returns Array of field names relevant for the current configuration
|
||||
*/
|
||||
export function getContextAwareOutputFields(
|
||||
nodeType: NodeType,
|
||||
spec?: Record<string, unknown> | null
|
||||
): string[] {
|
||||
const staticFields = NODE_OUTPUT_FIELDS[nodeType] || ['result'];
|
||||
const conditions = CONDITIONAL_OUTPUT_FIELDS[nodeType];
|
||||
|
||||
// No conditions for this node type - return all fields
|
||||
if (!conditions || !spec) {
|
||||
return staticFields;
|
||||
}
|
||||
|
||||
// Filter fields based on conditions
|
||||
return staticFields.filter(field => {
|
||||
const fieldConditions = conditions[field];
|
||||
// No conditions for this field - always include
|
||||
if (!fieldConditions) return true;
|
||||
|
||||
// Check if current spec values satisfy any condition
|
||||
for (const [specField, allowedValues] of Object.entries(fieldConditions)) {
|
||||
const specValue = spec[specField];
|
||||
if (specValue !== undefined && (allowedValues as unknown[]).includes(specValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
# Generate node type literal
|
||||
node_types_literal = "/**\n * All valid node types\n */\n"
|
||||
node_types_literal += "export type NodeType = " + " | ".join(f"'{t}'" for t in node_specs.keys()) + ";\n"
|
||||
|
||||
# Combine all
|
||||
content = header
|
||||
content += "\n".join(all_interfaces)
|
||||
content += "\n\n"
|
||||
content += discriminated_union
|
||||
content += "\n"
|
||||
content += node_types_literal
|
||||
content += "\n"
|
||||
content += validation_export
|
||||
content += output_fields_export
|
||||
content += conditional_export
|
||||
content += helper_function
|
||||
|
||||
# Write output
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(content)
|
||||
print(f"Generated: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
38
backend/admin-dashboard/src/App.css
Normal file
38
backend/admin-dashboard/src/App.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
101
backend/admin-dashboard/src/App.tsx
Normal file
101
backend/admin-dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||
import { Unauthorized } from './components/auth/Unauthorized';
|
||||
import { LoginRedirect } from './components/auth/LoginRedirect';
|
||||
import { AdminLayout } from './components/layout/AdminLayout';
|
||||
import { ServicesDashboard } from './components/dashboard/ServicesDashboard';
|
||||
import { FrameworkDashboard } from './pages/FrameworkDashboard';
|
||||
import { ProgressiveAnalysisTree } from './components/framework/ProgressiveAnalysisTree';
|
||||
import { UserManagement } from './components/UserManagement';
|
||||
import { AnalysisHistory } from './components/AnalysisHistory';
|
||||
import { ProvidersManagement } from './components/ProvidersManagement';
|
||||
import { LLMComponentsConfig } from './components/LLMComponentsConfig';
|
||||
import { PipelinesPage } from './components/Pipelines/PipelinesPage';
|
||||
import { ModerationQueue } from './components/Moderation/ModerationQueue';
|
||||
import { ModerationDetail } from './components/Moderation/ModerationDetail';
|
||||
import { ModerationStats } from './components/Moderation/ModerationStats';
|
||||
|
||||
const DefaultLanding: React.FC = () => {
|
||||
const { hasRole } = useAuth();
|
||||
if (hasRole('admin')) return <ServicesDashboard />;
|
||||
return <Navigate to="/moderation" replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router basename="/admin">
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirect />} />
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
|
||||
{/* AdminLayout requires staff role (admin / moderator / senior_moderator).
|
||||
End users (viewer / free_tier / paid_tier / etc.) get 403. */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DefaultLanding />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute requiredRole="admin"><ServicesDashboard /></ProtectedRoute>} />
|
||||
|
||||
{/* Admin-only pages — require 'admin' role */}
|
||||
<Route path="/framework" element={<ProtectedRoute requiredRole="admin"><FrameworkDashboard /></ProtectedRoute>} />
|
||||
<Route path="/framework/progressive" element={<ProtectedRoute requiredRole="admin"><ProgressiveAnalysisTree /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute requiredRole="admin"><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/providers" element={<ProtectedRoute requiredRole="admin"><ProvidersManagement /></ProtectedRoute>} />
|
||||
<Route path="/llm-components" element={<ProtectedRoute requiredRole="admin"><LLMComponentsConfig /></ProtectedRoute>} />
|
||||
<Route path="/pipelines" element={<ProtectedRoute requiredRole="admin"><PipelinesPage /></ProtectedRoute>} />
|
||||
|
||||
{/* History — visible to admin + moderators (audit trail) */}
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<AnalysisHistory />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Moderation — visible to all staff (admin/moderator/senior_moderator) */}
|
||||
<Route
|
||||
path="/moderation"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationQueue />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/stats"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationStats />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/:queueId"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* AnalysisDetailModal — color maps, cost calc, layout primitives
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import type { ModelPricing } from './types';
|
||||
|
||||
export const REDIS_COLOR_MAP: Record<string, string> = {
|
||||
green: '#22c55e',
|
||||
lightgreen: '#84cc16',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
darkred: '#7f1d1d',
|
||||
};
|
||||
|
||||
export const getRiskColor = (verdict: any): string => {
|
||||
if (verdict?.risk_level_color && REDIS_COLOR_MAP[verdict.risk_level_color]) {
|
||||
return REDIS_COLOR_MAP[verdict.risk_level_color];
|
||||
}
|
||||
switch (verdict?.risk_level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW': return '#22c55e';
|
||||
case 'MEDIUM': return '#eab308';
|
||||
case 'HIGH': return '#f97316';
|
||||
case 'VERY_HIGH': return '#ef4444';
|
||||
case 'CRITICAL': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryColor = (verdict: any): string => {
|
||||
if (verdict?.risk_category_color && REDIS_COLOR_MAP[verdict.risk_category_color]) {
|
||||
return REDIS_COLOR_MAP[verdict.risk_category_color];
|
||||
}
|
||||
switch (verdict?.risk_category) {
|
||||
case 'RELIABLE': return '#22c55e';
|
||||
case 'MOSTLY_RELIABLE': return '#84cc16';
|
||||
case 'MIXED': return '#eab308';
|
||||
case 'QUESTIONABLE': return '#f97316';
|
||||
case 'UNRELIABLE': return '#ef4444';
|
||||
case 'DISINFORMATION': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
/** Normalize model_key to lookup key used for pricing map. Strips provider prefix. */
|
||||
export function normalizeModelKey(key: string): string {
|
||||
if (!key) return '';
|
||||
// "openrouter:google/gemini-3-flash-preview" → "google/gemini-3-flash-preview"
|
||||
const colonIdx = key.indexOf(':');
|
||||
return colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
|
||||
}
|
||||
|
||||
export function computeTokenCost(
|
||||
promptTokens: number,
|
||||
completionTokens: number,
|
||||
pricing: ModelPricing | undefined,
|
||||
): number {
|
||||
if (!pricing) return 0;
|
||||
return (promptTokens / 1_000_000) * pricing.input_per_1m + (completionTokens / 1_000_000) * pricing.output_per_1m;
|
||||
}
|
||||
|
||||
export function formatUsd(amount: number): string {
|
||||
if (amount === 0) return '$0.00';
|
||||
if (amount < 0.0001) return `<$0.0001`;
|
||||
if (amount < 0.01) return `$${amount.toFixed(5)}`;
|
||||
return `$${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export const formatDate = (dateStr: string | null) =>
|
||||
dateStr ? new Date(dateStr).toLocaleString() : '-';
|
||||
|
||||
export const formatDuration = (ms: number | null | undefined) => {
|
||||
if (!ms) return '-';
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export const GridRow: React.FC<{ children: React.ReactNode; columns?: number; sx?: any }> = ({ children, columns = 4, sx = {} }) => (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 2, ...sx }}>{children}</Box>
|
||||
);
|
||||
|
||||
export const GridCell: React.FC<{ children: React.ReactNode }> = ({ children }) => <Box>{children}</Box>;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
Box, Typography, Chip, CircularProgress, Alert,
|
||||
} from '@mui/material';
|
||||
import type { AnalysisDetailModalProps, AnalysisDetail, PricingMap } from './types';
|
||||
import { getRiskColor } from './helpers';
|
||||
import { SessionInfoSection, InputContentSection } from './sections/meta';
|
||||
import { VerdictSection } from './sections/verdict';
|
||||
import { TechniquesSection, AISection } from './sections/components';
|
||||
import { ClaimsSection } from './sections/claims';
|
||||
import { SourceAssessmentSection, LegacyDomainSection } from './sections/source';
|
||||
import { LLMUsageSection } from './sections/llm-usage';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export const AnalysisDetailModal: React.FC<AnalysisDetailModalProps> = ({ open, sessionId, onClose }) => {
|
||||
const [data, setData] = useState<AnalysisDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pricingMap, setPricingMap] = useState<PricingMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
|
||||
const fetchDetails = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const [detailRes, modelsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
|
||||
}),
|
||||
fetch(`${API_BASE}/framework/api/providers/models`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
|
||||
}),
|
||||
]);
|
||||
if (!detailRes.ok) throw new Error('Failed to fetch analysis details');
|
||||
const result = await detailRes.json();
|
||||
setData(result.data);
|
||||
|
||||
// Build pricing map from model_code → {input_per_1m, output_per_1m}
|
||||
if (modelsRes.ok) {
|
||||
const modelsData = await modelsRes.json();
|
||||
const map: PricingMap = {};
|
||||
for (const m of modelsData.data || []) {
|
||||
map[m.model_code] = {
|
||||
input_per_1m: parseFloat(m.input_cost_per_1m) || 0,
|
||||
output_per_1m: parseFloat(m.output_cost_per_1m) || 0,
|
||||
};
|
||||
}
|
||||
setPricingMap(map);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}, [open, sessionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Analysis Details</Typography>
|
||||
{data?.verdict && (
|
||||
<Chip
|
||||
label={`Risk: ${data.verdict.risk_score} - ${data.verdict.risk_category}`}
|
||||
sx={{ bgcolor: getRiskColor(data.verdict), color: 'white', fontWeight: 'bold' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{data && !loading && (
|
||||
<Box>
|
||||
<SessionInfoSection data={data} />
|
||||
<InputContentSection data={data} />
|
||||
<VerdictSection data={data} />
|
||||
<TechniquesSection data={data} />
|
||||
<AISection data={data} />
|
||||
<ClaimsSection data={data} />
|
||||
<SourceAssessmentSection data={data} />
|
||||
<LegacyDomainSection data={data} />
|
||||
<LLMUsageSection data={data} pricingMap={pricingMap} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Cancel as CancelIcon,
|
||||
Help as HelpIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, REDIS_COLOR_MAP } from '../helpers';
|
||||
|
||||
export const ClaimsSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.claims) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<FactCheckIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Claims Verification ({data.claims.total_claims} claims)</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'success.light' }}>
|
||||
<CheckCircleIcon color="success" />
|
||||
<Typography variant="h6">{data.claims.verified_true}</Typography>
|
||||
<Typography variant="caption">Verified True</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'error.light' }}>
|
||||
<CancelIcon color="error" />
|
||||
<Typography variant="h6">{data.claims.verified_false}</Typography>
|
||||
<Typography variant="caption">Verified False</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'grey.200' }}>
|
||||
<HelpIcon />
|
||||
<Typography variant="h6">{data.claims.unverified}</Typography>
|
||||
<Typography variant="caption">Unverified</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="h6">{Math.round(Number(data.claims.credibility_score))}%</Typography>
|
||||
<Typography variant="caption">Credibility</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{/* Meta info */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{data.claims.interpretation && (
|
||||
<Chip label={data.claims.interpretation} size="small" variant="outlined" />
|
||||
)}
|
||||
{data.claims.web_searches_made != null && (
|
||||
<Chip icon={<SearchIcon />} label={`${data.claims.web_searches_made} web searches`} size="small" variant="outlined" />
|
||||
)}
|
||||
{data.claims.llm_extraction && <Chip label={`Extraction: ${data.claims.llm_extraction}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.claims.llm_verification && <Chip label={`Verification: ${data.claims.llm_verification}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.claims.claims_by_status && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{Object.entries(data.claims.claims_by_status).map(([status, count]) => (
|
||||
<Chip key={status} label={`${status}: ${count}`} size="small"
|
||||
color={status === 'VT' || status === 'LT' ? 'success' : status === 'VF' || status === 'LF' ? 'error' : 'default'} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Individual claims */}
|
||||
{data.claims.claims_verified?.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{data.claims.claims_verified.map((claim: any, idx: number) => (
|
||||
<Paper key={idx} sx={{
|
||||
p: 2, mb: 1.5, borderLeft: 4,
|
||||
borderColor: claim.status === 'VT' || claim.status === 'LT' ? 'success.main'
|
||||
: claim.status === 'VF' || claim.status === 'LF' ? 'error.main'
|
||||
: 'grey.400',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="body1" fontWeight="bold" sx={{ flex: 1, mr: 1 }}>
|
||||
{claim.text || claim.claim}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<Chip
|
||||
label={claim.status_name || claim.status}
|
||||
size="small"
|
||||
color={claim.status === 'VT' || claim.status === 'LT' ? 'success' : claim.status === 'VF' || claim.status === 'LF' ? 'error' : 'default'}
|
||||
sx={claim.status_color ? { bgcolor: REDIS_COLOR_MAP[claim.status_color] || undefined, color: '#fff' } : {}}
|
||||
/>
|
||||
{claim.type_name && <Chip label={claim.type_name} size="small" variant="outlined" />}
|
||||
{claim.priority && <Chip label={claim.priority} size="small" variant="outlined"
|
||||
color={claim.priority === 'high' ? 'error' : claim.priority === 'medium' ? 'warning' : 'default'} />}
|
||||
</Box>
|
||||
</Box>
|
||||
{claim.context && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, fontStyle: 'italic' }}>
|
||||
{claim.context}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Confidence & agreement */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||
{claim.confidence != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence: <strong>{claim.confidence}%</strong>
|
||||
</Typography>
|
||||
)}
|
||||
{claim.agreement_score != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Agreement: <strong>{claim.agreement_score}%</strong>
|
||||
</Typography>
|
||||
)}
|
||||
{claim.verification_method && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Method: <strong>{claim.verification_method}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{/* Reasoning */}
|
||||
{claim.reasoning && (
|
||||
<Paper sx={{ p: 1.5, bgcolor: 'grey.50', mb: 1 }}>
|
||||
<Typography variant="body2">{claim.reasoning}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Sources */}
|
||||
{claim.sources?.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Sources ({claim.sources.length}):
|
||||
</Typography>
|
||||
{claim.sources.map((src: any, si: number) => (
|
||||
<Paper key={si} sx={{ p: 1, mt: 0.5, bgcolor: 'grey.50', display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Chip
|
||||
label={src.stance}
|
||||
size="small"
|
||||
color={src.stance === 'SUPPORTS' ? 'success' : src.stance === 'CONTRADICTS' ? 'error' : 'default'}
|
||||
sx={{ minWidth: 90, mt: 0.3 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'primary.main', wordBreak: 'break-all', cursor: 'pointer' }}
|
||||
component="a" href={src.url} target="_blank" rel="noopener noreferrer">
|
||||
{src.url}
|
||||
</Typography>
|
||||
<OpenInNewIcon sx={{ fontSize: 12, color: 'text.secondary' }} />
|
||||
{src.reliability && <Chip label={src.reliability} size="small" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
</Box>
|
||||
{src.relevant_quote && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontStyle: 'italic' }}>
|
||||
"{src.relevant_quote}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
List, ListItem, ListItemText,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, GridCell, formatDuration } from '../helpers';
|
||||
|
||||
export const TechniquesSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.techniques) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<PsychologyIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Manipulation Techniques ({data.techniques.techniques_count} detected)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Manipulation Score</Typography>
|
||||
<Typography variant="h6">{Math.round(Number(data.techniques.manipulation_score))}%</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Total Severity</Typography>
|
||||
<Typography variant="h6">{data.techniques.total_severity}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="h6">{formatDuration(data.techniques.total_duration_ms || data.techniques.duration_ms?.total)}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
{(data.techniques.llm_screening || data.techniques.llm_deep) && (
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{data.techniques.llm_screening && <Chip label={`Screening: ${data.techniques.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.techniques.llm_deep && <Chip label={`Deep: ${data.techniques.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
</Box>
|
||||
)}
|
||||
{data.techniques.dimensions_affected?.length > 0 && (
|
||||
<Box sx={{ my: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">Dimensions Affected</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
{data.techniques.dimensions_affected.map((dim: string) => <Chip key={dim} label={dim} size="small" />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{data.techniques.techniques_detected?.length > 0 && (
|
||||
<List dense>
|
||||
{data.techniques.techniques_detected.map((tech: any, idx: number) => (
|
||||
<ListItem key={idx} sx={{ bgcolor: 'grey.50', mb: 0.5, borderRadius: 1 }}>
|
||||
<ListItemText primary={tech.name || `Technique ${idx + 1}`} secondary={tech.description || tech.evidence} />
|
||||
{tech.severity && <Chip label={`Severity: ${tech.severity}`} size="small" color={tech.severity > 3 ? 'error' : 'warning'} />}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const AISection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.ai_tampered) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<PsychologyIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">AI Content Detection - {data.ai_tampered.verdict}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">AI Probability</Typography>
|
||||
<Typography variant="h6">{Math.round(Number(data.ai_tampered.ai_probability))}%</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Indicators Found</Typography>
|
||||
<Typography variant="h6">{data.ai_tampered.indicators_count}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="h6">{formatDuration(data.ai_tampered.total_duration_ms || data.ai_tampered.duration_ms?.total)}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
{(data.ai_tampered.llm_screening || data.ai_tampered.llm_deep) && (
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{data.ai_tampered.llm_screening && <Chip label={`Screening: ${data.ai_tampered.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.ai_tampered.llm_deep && <Chip label={`Deep: ${data.ai_tampered.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
</Box>
|
||||
)}
|
||||
{data.ai_tampered.disclosure_detected && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>AI Disclosure detected: {data.ai_tampered.disclosure_text}</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
GeneratingTokens as TokenIcon,
|
||||
AttachMoney as MoneyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail, PricingMap } from '../types';
|
||||
import { GridRow, normalizeModelKey, computeTokenCost, formatUsd } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
data: AnalysisDetail;
|
||||
pricingMap: PricingMap;
|
||||
}
|
||||
|
||||
export const LLMUsageSection: React.FC<Props> = ({ data, pricingMap }) => {
|
||||
if (!data.llm_usage) return null;
|
||||
|
||||
// Compute cost per component + grand total. We don't have per-model token split
|
||||
// in by_component, so we charge all tokens at the FIRST model used by that
|
||||
// component (good enough for single-model primary path).
|
||||
const byComponent = data.llm_usage.by_component || {};
|
||||
const componentCosts: Record<string, { cost: number; model: string | null }> = {};
|
||||
let totalCost = 0;
|
||||
for (const [comp, usage] of Object.entries(byComponent) as [string, any][]) {
|
||||
const firstModel = usage.models_used?.[0];
|
||||
const pricingKey = firstModel ? normalizeModelKey(firstModel) : '';
|
||||
const pricing = pricingKey ? pricingMap[pricingKey] : undefined;
|
||||
const cost = computeTokenCost(
|
||||
usage.prompt_tokens || 0,
|
||||
usage.completion_tokens || 0,
|
||||
pricing,
|
||||
);
|
||||
componentCosts[comp] = { cost, model: firstModel || null };
|
||||
totalCost += cost;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<TokenIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold" sx={{ flex: 1 }}>
|
||||
LLM Usage — {data.llm_usage.total?.total_tokens?.toLocaleString() || 0} tokens ({data.llm_usage.total?.calls || 0} calls)
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<MoneyIcon />}
|
||||
label={formatUsd(totalCost)}
|
||||
size="small"
|
||||
color={totalCost === 0 ? 'success' : 'warning'}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={5}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{data.llm_usage.total?.calls || 0}</Typography>
|
||||
<Typography variant="caption">Total Calls</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.prompt_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Prompt Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.completion_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Completion Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.total_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Total Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center', bgcolor: totalCost === 0 ? 'success.50' : 'warning.50' }}>
|
||||
<Typography variant="h5" color={totalCost === 0 ? 'success.main' : 'warning.main'}>
|
||||
{formatUsd(totalCost)}
|
||||
</Typography>
|
||||
<Typography variant="caption">Est. Cost</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{Object.keys(byComponent).length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Per Component</Typography>
|
||||
<GridRow columns={Object.keys(byComponent).length}>
|
||||
{Object.entries(byComponent).map(([comp, usage]: [string, any]) => {
|
||||
const cc = componentCosts[comp];
|
||||
return (
|
||||
<Paper key={comp} sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="capitalize">{comp.replace('_', ' ')}</Typography>
|
||||
<Typography variant="h6">{(usage.total_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{usage.calls} calls • {(usage.prompt_tokens || 0).toLocaleString()} in / {(usage.completion_tokens || 0).toLocaleString()} out
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip
|
||||
icon={<MoneyIcon />}
|
||||
label={formatUsd(cc.cost)}
|
||||
size="small"
|
||||
color={cc.cost === 0 ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
sx={{ height: 22, fontSize: 11 }}
|
||||
/>
|
||||
</Box>
|
||||
{usage.models_used?.length > 0 && (
|
||||
<Box sx={{ mt: 0.5, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{usage.models_used.map((m: string) => (
|
||||
<Chip key={m} label={m} size="small" variant="outlined" sx={{ height: 20, fontSize: 10 }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</GridRow>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Typography, Paper, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
|
||||
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, GridCell, formatDate, formatDuration } from '../helpers';
|
||||
|
||||
export const SessionInfoSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<GridRow columns={2}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Session ID</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{data.session_id}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">User</Typography>
|
||||
<Typography variant="body2">{data.user_email || data.user_id}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
<GridRow columns={4} sx={{ mt: 2 }}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Started</Typography>
|
||||
<Typography variant="body2">{formatDate(data.started_at)}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="body2">{formatDuration(data.total_duration_ms)}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Type</Typography>
|
||||
<Typography variant="body2">{data.input_type}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Source</Typography>
|
||||
<Typography variant="body2">{data.source_app}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export const InputContentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Input Content</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{data.input_text && (
|
||||
<Paper sx={{ p: 2, bgcolor: 'grey.100', maxHeight: 200, overflow: 'auto' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
|
||||
{data.input_text}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{data.input_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>URL:</strong> {data.input_url}</Typography>}
|
||||
{data.input_media_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>Media:</strong> {data.input_media_url}</Typography>}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Language as LanguageIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, formatDuration } from '../helpers';
|
||||
|
||||
export const SourceAssessmentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.source_assessment) return null;
|
||||
const sa = data.source_assessment;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<LanguageIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Source Assessment</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: sa.trust_score >= 60 ? '#22c55e' : sa.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
|
||||
{sa.trust_score}
|
||||
</Typography>
|
||||
<Typography variant="caption">Trust Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{
|
||||
color: sa.verdict === 'TRUSTED' ? '#22c55e' : sa.verdict === 'NEUTRAL' ? '#eab308'
|
||||
: sa.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
|
||||
}}>{sa.verdict}</Typography>
|
||||
<Typography variant="caption">Verdict</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="body1">{sa.risk_level}</Typography>
|
||||
<Typography variant="caption">Risk Level</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{/* 4 Axes */}
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>Assessment Axes</Typography>
|
||||
<GridRow columns={4}>
|
||||
{sa.publication && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Publication ({sa.formula?.publication_weight ? `${Math.round(sa.formula.publication_weight * 100)}%` : '35%'})</Typography>
|
||||
<Typography variant="h6">{sa.publication.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.publication.name || '-'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sa.publication.source_type}</Typography>
|
||||
{sa.publication.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.domain && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Domain ({sa.formula?.domain_weight ? `${Math.round(sa.formula.domain_weight * 100)}%` : '25%'})</Typography>
|
||||
<Typography variant="h6">{sa.domain.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.domain.name || '-'}</Typography>
|
||||
{sa.domain.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
{sa.domain.has_ssl != null && (
|
||||
<Chip label={sa.domain.has_ssl ? 'SSL' : 'No SSL'} size="small" variant="outlined" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.author && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Author ({sa.formula?.author_weight ? `${Math.round(sa.formula.author_weight * 100)}%` : '25%'})</Typography>
|
||||
<Typography variant="h6">{sa.author.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.author.name || '-'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sa.author.classification}</Typography>
|
||||
{sa.author.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.platform && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Platform ({sa.formula?.platform_weight ? `${Math.round(sa.formula.platform_weight * 100)}%` : '15%'})</Typography>
|
||||
<Typography variant="h6">{sa.platform.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.platform.name || '-'}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</GridRow>
|
||||
{sa.formula?.breakdown && (
|
||||
<Paper sx={{ p: 1, mt: 1, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">Formula: </Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{sa.formula.breakdown}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Meta info */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
|
||||
{sa.llm_model_used && <Chip label={`LLM: ${sa.llm_model_used}`} size="small" variant="outlined" />}
|
||||
{sa.search_results_count != null && (
|
||||
<Chip icon={<SearchIcon />} label={`${sa.search_results_count} search results`} size="small" variant="outlined" />
|
||||
)}
|
||||
{sa.duration_ms != null && <Chip label={formatDuration(sa.duration_ms)} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
{sa.red_flags?.length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<strong>Red Flags:</strong>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{sa.red_flags.map((flag: string, idx: number) => <li key={idx}>{flag}</li>)}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
{sa.warnings?.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<strong>Warnings:</strong>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{sa.warnings.map((w: string, idx: number) => <li key={idx}>{w}</li>)}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const LegacyDomainSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (data.source_assessment || !data.domain) return null;
|
||||
const d = data.domain;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<LanguageIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Domain Analysis - {d.domain}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ color: d.trust_score >= 60 ? '#22c55e' : d.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
|
||||
{d.trust_score}
|
||||
</Typography>
|
||||
<Typography variant="caption">Trust Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{
|
||||
color: d.verdict === 'TRUSTED' ? '#22c55e' : d.verdict === 'NEUTRAL' ? '#eab308'
|
||||
: d.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
|
||||
}}>{d.verdict}</Typography>
|
||||
<Typography variant="caption">Verdict</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6">{d.age_days != null ? `${d.age_days} days` : '-'}</Typography>
|
||||
<Typography variant="caption">Age</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: d.has_ssl ? (d.ssl_valid ? '#22c55e' : '#ef4444') : '#9e9e9e' }}>
|
||||
{d.has_ssl ? (d.ssl_valid ? 'Valid' : 'Invalid') : 'None'}
|
||||
</Typography>
|
||||
<Typography variant="caption">SSL</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
|
||||
{d.risk_level && <Chip label={`Risk: ${d.risk_level}`} size="small" variant="outlined" />}
|
||||
{d.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" />}
|
||||
{d.registrar && <Chip label={`Registrar: ${d.registrar}`} size="small" variant="outlined" />}
|
||||
{d.country && <Chip label={`Country: ${d.country}`} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, getRiskColor, getCategoryColor } from '../helpers';
|
||||
|
||||
export const VerdictSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.verdict) return null;
|
||||
return (
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<AssessmentIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Final Verdict</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h3" sx={{ color: getRiskColor(data.verdict) }}>{data.verdict.risk_score}</Typography>
|
||||
<Typography variant="caption">Risk Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ color: getCategoryColor(data.verdict) }}>{data.verdict.risk_category}</Typography>
|
||||
<Typography variant="caption">Category</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{data.verdict.confidence}%</Typography>
|
||||
<Typography variant="caption">Confidence ({data.verdict.confidence_level})</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body1">{data.verdict.recommended_action}</Typography>
|
||||
<Typography variant="caption">Action</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{(data.verdict.score_manipulation != null || data.verdict.score_claims != null || data.verdict.score_ai != null || data.verdict.score_source != null || data.verdict.score_context != null || data.verdict.component_scores) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Component Scores</Typography>
|
||||
<GridRow columns={5}>
|
||||
{data.verdict.component_scores
|
||||
? Object.entries(data.verdict.component_scores).map(([key, value]) => (
|
||||
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
|
||||
</Paper>
|
||||
))
|
||||
: [
|
||||
{ key: 'manipulation', value: data.verdict.score_manipulation },
|
||||
{ key: 'claims', value: data.verdict.score_claims },
|
||||
{ key: 'ai', value: data.verdict.score_ai },
|
||||
{ key: 'source', value: data.verdict.score_source },
|
||||
{ key: 'context', value: data.verdict.score_context },
|
||||
].map(({ key, value }) => (
|
||||
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
|
||||
</Paper>
|
||||
))
|
||||
}
|
||||
</GridRow>
|
||||
</Box>
|
||||
)}
|
||||
{(data.verdict.explanation_ro || data.verdict.explanation_en) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Explanation</Typography>
|
||||
{data.verdict.explanation_ro && (
|
||||
<Paper sx={{ p: 2, mb: 1, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">RO</Typography>
|
||||
<Typography variant="body2">{data.verdict.explanation_ro}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{data.verdict.explanation_en && (
|
||||
<Paper sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">EN</Typography>
|
||||
<Typography variant="body2">{data.verdict.explanation_en}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{data.verdict.override_applied && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<strong>Override Applied:</strong> {data.verdict.override_type}
|
||||
{data.verdict.override_reason && <> — {data.verdict.override_reason}</>}
|
||||
{data.verdict.override_adjustment != null && <> (adjustment: {data.verdict.override_adjustment})</>}
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* AnalysisDetailModal — types
|
||||
*/
|
||||
|
||||
export interface AnalysisDetailModalProps {
|
||||
open: boolean;
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AnalysisDetail {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
input_type: string;
|
||||
input_text: string | null;
|
||||
input_url: string | null;
|
||||
input_media_url: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
total_duration_ms: number | null;
|
||||
source_app: string;
|
||||
components_run: string[] | null;
|
||||
components_skipped: string[] | null;
|
||||
verdict: any | null;
|
||||
techniques: any | null;
|
||||
ai_tampered: any | null;
|
||||
claims: any | null;
|
||||
domain: any | null;
|
||||
source_assessment: any | null;
|
||||
llm_usage: any | null;
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
input_per_1m: number;
|
||||
output_per_1m: number;
|
||||
}
|
||||
|
||||
export type PricingMap = Record<string, ModelPricing>;
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container, Typography, Button, Alert,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
} from '@mui/material';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { AnalysisDetailModal } from './AnalysisDetailModal';
|
||||
import type { AnalysisSession, PaginationInfo } from './history-types';
|
||||
import { HistoryFilters } from './HistoryFilters';
|
||||
import { HistoryTable } from './HistoryTable';
|
||||
import { SocialPostModal } from './SocialPostModal';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export const AnalysisHistory: React.FC = () => {
|
||||
const [analyses, setAnalyses] = useState<AnalysisSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
|
||||
// Filters
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [riskLevelFilter, setRiskLevelFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [fromDate, setFromDate] = useState<string>('');
|
||||
const [toDate, setToDate] = useState<string>('');
|
||||
|
||||
// UI State
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<AnalysisSession | null>(null);
|
||||
const [socialPostOpen, setSocialPostOpen] = useState(false);
|
||||
const [socialPostSession, setSocialPostSession] = useState<AnalysisSession | null>(null);
|
||||
|
||||
const handleCopyId = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(id).then(() => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); });
|
||||
};
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const fetchAnalyses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
});
|
||||
|
||||
if (searchFilter) params.append('search', searchFilter);
|
||||
if (riskLevelFilter) params.append('risk_level', riskLevelFilter);
|
||||
if (statusFilter) params.append('status', statusFilter);
|
||||
if (fromDate) params.append('from_date', fromDate);
|
||||
if (toDate) params.append('to_date', toDate);
|
||||
|
||||
const response = await fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin?${params}`, {
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch analyses');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAnalyses(data.data?.items || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.data?.pagination?.total || 0,
|
||||
total_pages: data.data?.pagination?.total_pages || 0,
|
||||
has_next: data.data?.pagination?.has_next || false,
|
||||
has_prev: data.data?.pagination?.has_prev || false,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, searchFilter, riskLevelFilter, statusFilter, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyses();
|
||||
}, [fetchAnalyses]);
|
||||
|
||||
const handlePageChange = (_event: unknown, newPage: number) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage + 1 }));
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPagination(prev => ({ ...prev, limit: parseInt(event.target.value, 10), page: 1 }));
|
||||
};
|
||||
|
||||
const handleViewDetails = (sessionId: string) => {
|
||||
setSelectedSession(sessionId);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (session: AnalysisSession) => {
|
||||
setSessionToDelete(session);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!sessionToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionToDelete.session_id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete analysis');
|
||||
}
|
||||
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (session: AnalysisSession) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// user_id sent for staging fallback; JWT identity used when present.
|
||||
body: JSON.stringify({ user_id: session.user_id }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || 'Failed to cancel analysis');
|
||||
}
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async (session: AnalysisSession) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/resume`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: session.user_id }),
|
||||
}
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
// 409 = expired payload / already complete — surface the reason.
|
||||
throw new Error(body.error || 'Failed to resume analysis');
|
||||
}
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchFilter('');
|
||||
setRiskLevelFilter('');
|
||||
setStatusFilter('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchFilter || riskLevelFilter || statusFilter || fromDate || toDate;
|
||||
|
||||
// Filter changes reset page to 1
|
||||
const onSearchChange = (v: string) => { setSearchFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onRiskLevelChange = (v: string) => { setRiskLevelFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onStatusChange = (v: string) => { setStatusFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onFromDateChange = (v: string) => { setFromDate(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onToDateChange = (v: string) => { setToDate(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Analysis History</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Browse and manage analysis sessions
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<HistoryFilters
|
||||
searchFilter={searchFilter}
|
||||
riskLevelFilter={riskLevelFilter}
|
||||
statusFilter={statusFilter}
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onSearchChange={onSearchChange}
|
||||
onRiskLevelChange={onRiskLevelChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onFromDateChange={onFromDateChange}
|
||||
onToDateChange={onToDateChange}
|
||||
onRefresh={fetchAnalyses}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<HistoryTable
|
||||
loading={loading}
|
||||
analyses={analyses}
|
||||
pagination={pagination}
|
||||
copiedId={copiedId}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
onViewDetails={handleViewDetails}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onCancelClick={handleCancel}
|
||||
onResumeClick={handleResume}
|
||||
onSocialPostClick={(s) => { setSocialPostSession(s); setSocialPostOpen(true); }}
|
||||
onCopyId={handleCopyId}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedSession && (
|
||||
<AnalysisDetailModal
|
||||
open={detailModalOpen}
|
||||
sessionId={selectedSession}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedSession(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Social Post Modal */}
|
||||
<SocialPostModal
|
||||
open={socialPostOpen}
|
||||
session={socialPostSession}
|
||||
onClose={() => {
|
||||
setSocialPostOpen(false);
|
||||
setSocialPostSession(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete Analysis</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Are you sure you want to delete this analysis?</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Session: {sessionToDelete?.session_id}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Button, TextField, InputAdornment,
|
||||
FormControl, InputLabel, Select, MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Refresh as RefreshIcon,
|
||||
FilterAltOff as ClearIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Props {
|
||||
searchFilter: string;
|
||||
riskLevelFilter: string;
|
||||
statusFilter: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
hasActiveFilters: string | boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRiskLevelChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onFromDateChange: (value: string) => void;
|
||||
onToDateChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export const HistoryFilters: React.FC<Props> = ({
|
||||
searchFilter,
|
||||
riskLevelFilter,
|
||||
statusFilter,
|
||||
fromDate,
|
||||
toDate,
|
||||
hasActiveFilters,
|
||||
onSearchChange,
|
||||
onRiskLevelChange,
|
||||
onStatusChange,
|
||||
onFromDateChange,
|
||||
onToDateChange,
|
||||
onRefresh,
|
||||
onClearFilters,
|
||||
}) => (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
placeholder="Search by email or user..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 220 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Risk Level</InputLabel>
|
||||
<Select
|
||||
value={riskLevelFilter}
|
||||
onChange={(e) => onRiskLevelChange(e.target.value)}
|
||||
label="Risk Level"
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="VERY_LOW">Very Low</MenuItem>
|
||||
<MenuItem value="LOW">Low</MenuItem>
|
||||
<MenuItem value="MEDIUM">Medium</MenuItem>
|
||||
<MenuItem value="HIGH">High</MenuItem>
|
||||
<MenuItem value="VERY_HIGH">Very High</MenuItem>
|
||||
<MenuItem value="CRITICAL">Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
<MenuItem value="running">Running</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
size="small"
|
||||
value={fromDate}
|
||||
onChange={(e) => onFromDateChange(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
size="small"
|
||||
value={toDate}
|
||||
onChange={(e) => onToDateChange(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onRefresh}
|
||||
size="medium"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={onClearFilters}
|
||||
size="medium"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Chip, IconButton, Tooltip, LinearProgress,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
History as HistoryIcon,
|
||||
Delete as DeleteIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Cancel as CancelIcon,
|
||||
Replay as ResumeIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisSession, PaginationInfo } from './history-types';
|
||||
import { getRiskIcon, getRiskCategoryColor, formatDuration, formatDate } from './history-helpers';
|
||||
|
||||
// Sessions still in flight can be canceled (agent-v3 /:id/cancel).
|
||||
const CANCELABLE = new Set(['pending', 'running', 'processing']);
|
||||
// Failed/canceled sessions can be resumed from checkpoint (/:id/resume).
|
||||
const RESUMABLE = new Set(['failed', 'canceled']);
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
analyses: AnalysisSession[];
|
||||
pagination: PaginationInfo;
|
||||
copiedId: string | null;
|
||||
onPageChange: (event: unknown, newPage: number) => void;
|
||||
onRowsPerPageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onViewDetails: (sessionId: string) => void;
|
||||
onDeleteClick: (session: AnalysisSession) => void;
|
||||
onCopyId: (e: React.MouseEvent, id: string) => void;
|
||||
onSocialPostClick?: (session: AnalysisSession) => void;
|
||||
onCancelClick?: (session: AnalysisSession) => void;
|
||||
onResumeClick?: (session: AnalysisSession) => void;
|
||||
}
|
||||
|
||||
export const HistoryTable: React.FC<Props> = ({
|
||||
loading,
|
||||
analyses,
|
||||
pagination,
|
||||
copiedId,
|
||||
onPageChange,
|
||||
onRowsPerPageChange,
|
||||
onViewDetails,
|
||||
onDeleteClick,
|
||||
onCopyId,
|
||||
onSocialPostClick,
|
||||
onCancelClick,
|
||||
onResumeClick,
|
||||
}) => (
|
||||
<Paper>
|
||||
{loading && <LinearProgress />}
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Session ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Date</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Source</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Risk</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Techniques</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>AI %</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Claims</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Duration</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!loading && analyses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
|
||||
<HistoryIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography color="text.secondary">No analyses found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
analyses.map((analysis) => (
|
||||
<TableRow
|
||||
key={analysis.session_id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => onViewDetails(analysis.session_id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Tooltip title={analysis.session_id}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{analysis.session_id.substring(0, 8)}...
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Tooltip title={copiedId === analysis.session_id ? 'Copied!' : 'Copy'}>
|
||||
<IconButton size="small" onClick={(e) => onCopyId(e, analysis.session_id)}>
|
||||
<CopyIcon sx={{ fontSize: 14, color: copiedId === analysis.session_id ? 'success.main' : 'action.active' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(analysis.started_at)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{analysis.input_type}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={`User ID: ${analysis.user_id}`}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 180,
|
||||
}}
|
||||
>
|
||||
{analysis.user_email || <span style={{ color: '#999' }}>No email</span>}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={analysis.input_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={analysis.input_type === 'url' ? 'primary' : analysis.input_type === 'text' ? 'default' : 'secondary'}
|
||||
sx={{ textTransform: 'uppercase', fontSize: 11, height: 22 }}
|
||||
/>
|
||||
<Chip label={analysis.source_app} size="small" variant="outlined" sx={{ fontSize: 11, height: 22 }} />
|
||||
</Box>
|
||||
{(analysis.source_verdict || analysis.domain) && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.3 }}>
|
||||
{analysis.source_publication || analysis.domain || ''}
|
||||
{(analysis.source_verdict || analysis.domain_verdict) && (
|
||||
<Chip
|
||||
label={analysis.source_verdict || analysis.domain_verdict}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5, height: 18, fontSize: 10,
|
||||
bgcolor: (analysis.source_verdict || analysis.domain_verdict) === 'TRUSTED' ? '#22c55e'
|
||||
: (analysis.source_verdict || analysis.domain_verdict) === 'SUSPICIOUS' ? '#f97316'
|
||||
: (analysis.source_verdict || analysis.domain_verdict) === 'UNTRUSTED' ? '#ef4444' : undefined,
|
||||
color: (analysis.source_verdict || analysis.domain_verdict) !== 'NEUTRAL' ? '#fff' : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.risk_score !== null ? (
|
||||
<Tooltip title={`${analysis.risk_category} - ${analysis.risk_level}`}>
|
||||
<Chip
|
||||
icon={getRiskIcon(analysis.risk_level)}
|
||||
label={`${analysis.risk_score} ${analysis.risk_category || ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getRiskCategoryColor(analysis.risk_category),
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': { color: '#fff' },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Chip label="-" size="small" variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.techniques_count !== null ? (
|
||||
<Chip
|
||||
label={analysis.techniques_count}
|
||||
size="small"
|
||||
color={Number(analysis.techniques_count) > 0 ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.ai_probability !== null ? (
|
||||
<Tooltip title={analysis.ai_verdict || ''}>
|
||||
<Chip
|
||||
label={`${Math.round(Number(analysis.ai_probability))}%`}
|
||||
size="small"
|
||||
color={Number(analysis.ai_probability) > 50 ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.total_claims !== null ? (
|
||||
<Tooltip title={`True: ${analysis.verified_true}, False: ${analysis.verified_false}`}>
|
||||
<Typography variant="body2">
|
||||
{analysis.total_claims}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDuration(analysis.total_duration_ms)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onViewDetails(analysis.session_id)}
|
||||
title="View details"
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{onSocialPostClick && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSocialPostClick(analysis)}
|
||||
title="Post to Facebook"
|
||||
sx={{ color: '#1877F2' }}
|
||||
>
|
||||
<FacebookIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onCancelClick && CANCELABLE.has(analysis.status) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCancelClick(analysis)}
|
||||
title="Cancel running analysis"
|
||||
color="warning"
|
||||
>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onResumeClick && RESUMABLE.has(analysis.status) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onResumeClick(analysis)}
|
||||
title="Resume from checkpoint (re-runs only unfinished components)"
|
||||
color="primary"
|
||||
>
|
||||
<ResumeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDeleteClick(analysis)}
|
||||
title="Delete"
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={pagination.total}
|
||||
page={pagination.page - 1}
|
||||
onPageChange={onPageChange}
|
||||
rowsPerPage={pagination.limit}
|
||||
onRowsPerPageChange={onRowsPerPageChange}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Box, TextField, Button, Alert, Chip, CircularProgress,
|
||||
Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Facebook as FacebookIcon, Close as CloseIcon,
|
||||
Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon,
|
||||
Visibility as PreviewIcon,
|
||||
} from '@mui/icons-material';
|
||||
import axios from 'axios';
|
||||
import type { AnalysisSession } from './history-types';
|
||||
|
||||
const FRAMEWORK_BASE = process.env.REACT_APP_FRAMEWORK_URL || '/framework';
|
||||
const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3';
|
||||
const SHARE_BASE = 'https://didi365.eu/share';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error';
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
session: AnalysisSession | null;
|
||||
onSuccess?: (postId: string, externalUrl: string) => void;
|
||||
}
|
||||
|
||||
const buildBlocks = (detail: any): Block[] => {
|
||||
const risk = detail.risk_category || 'NEUTRAL';
|
||||
const score = detail.risk_score ?? 0;
|
||||
const emoji =
|
||||
score >= 80 ? '🚨' : score >= 60 ? '⚠️' : score >= 40 ? 'ℹ️' : '✅';
|
||||
|
||||
const tech = detail.techniques || {};
|
||||
const claims = detail.claims || {};
|
||||
const ai = detail.ai_tampered || {};
|
||||
const domain = detail.domain || {};
|
||||
const verdict = detail.verdict || {};
|
||||
|
||||
const techNames: string = (tech.techniques_detected || [])
|
||||
.map((t: any) => t.name_ro || t.name_en || t.name)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(', ');
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: 'header',
|
||||
label: 'Antet (verdict + scor)',
|
||||
enabled: true,
|
||||
content: `${emoji} DiDi · ${risk} · Scor risc ${score}/100`,
|
||||
},
|
||||
{
|
||||
id: 'verdict',
|
||||
label: 'Explicație verdict',
|
||||
enabled: !!verdict.explanation_ro,
|
||||
content: (verdict.explanation_ro || verdict.explanation_en || '').trim(),
|
||||
},
|
||||
{
|
||||
id: 'techniques',
|
||||
label: 'Tehnici de manipulare detectate',
|
||||
enabled: (tech.techniques_count || 0) > 0,
|
||||
content: techNames
|
||||
? `Tehnici de manipulare detectate (${tech.techniques_count}): ${techNames}\nScor manipulare: ${tech.manipulation_score ?? 0}/100`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'claims',
|
||||
label: 'Afirmații verificate',
|
||||
enabled: (claims.total_claims || 0) > 0,
|
||||
content: claims.total_claims
|
||||
? `Afirmații verificate: ${claims.verified_true || 0} confirmate, ${claims.verified_false || 0} false (credibilitate ${claims.credibility_score ?? 0}/100)`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Detectare conținut AI',
|
||||
enabled: (ai.ai_probability || 0) >= 50,
|
||||
content:
|
||||
typeof ai.ai_probability === 'number'
|
||||
? `Probabilitate generat AI: ${ai.ai_probability}% (${ai.verdict || 'N/A'})`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
label: 'Sursă / Domeniu',
|
||||
enabled: !!(domain.domain && domain.domain_verdict),
|
||||
content: domain.domain
|
||||
? `Sursă: ${domain.domain} · ${domain.domain_verdict || ''} (trust ${domain.domain_trust_score ?? 0}/100)`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'hashtags',
|
||||
label: 'Hashtags',
|
||||
enabled: true,
|
||||
content: '#DiDi #Antifake #VerificatDeDiDi #DESI',
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
label: 'Link analiză publică',
|
||||
enabled: false,
|
||||
content: detail.session_id
|
||||
? `Vezi analiza completă: ${SHARE_BASE}/${detail.session_id}`
|
||||
: '',
|
||||
},
|
||||
];
|
||||
return blocks.filter((b) => b.content || b.id === 'header' || b.id === 'link');
|
||||
};
|
||||
|
||||
const joinBlocks = (blocks: Block[]): string =>
|
||||
blocks
|
||||
.filter((b) => b.enabled && b.content.trim())
|
||||
.map((b) => b.content.trim())
|
||||
.join('\n\n');
|
||||
|
||||
export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuccess }) => {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [postId, setPostId] = useState<string | null>(null);
|
||||
const [externalUrl, setExternalUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !session) return;
|
||||
setStatus('loading');
|
||||
setBlocks([]);
|
||||
setImageUrl('');
|
||||
setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : '');
|
||||
setScheduledAt('');
|
||||
setError(null);
|
||||
setPostId(null);
|
||||
setExternalUrl(null);
|
||||
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
axios
|
||||
.get(`${AGENT_BASE}/api/v3/pipeline/history/admin/${session.session_id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
const detail = res.data?.data || res.data;
|
||||
if (!detail) throw new Error('Răspuns gol de la API');
|
||||
setBlocks(buildBlocks(detail));
|
||||
setStatus('editing');
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e?.response?.data?.error || e.message);
|
||||
setStatus('error');
|
||||
});
|
||||
}, [open, session]);
|
||||
|
||||
const previewText = useMemo(() => joinBlocks(blocks), [blocks]);
|
||||
const charCount = previewText.length;
|
||||
|
||||
const updateBlock = (id: string, patch: Partial<Block>) => {
|
||||
setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b)));
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!previewText.trim()) {
|
||||
setError('Niciun bloc bifat sau toate sunt goale');
|
||||
return;
|
||||
}
|
||||
setStatus('publishing');
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const draftRes = await axios.post(
|
||||
`${FRAMEWORK_BASE}/api/admin/social/draft`,
|
||||
{
|
||||
session_id: session?.session_id,
|
||||
content: previewText,
|
||||
image_url: imageUrl || undefined,
|
||||
link_url: linkUrl || undefined,
|
||||
platform: 'facebook',
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const createdId = draftRes.data?.data?.post_id?.toString();
|
||||
if (!createdId) throw new Error(draftRes.data?.error || 'Salvare draft eșuată');
|
||||
setPostId(createdId);
|
||||
|
||||
const pubRes = await axios.post(
|
||||
`${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`,
|
||||
scheduledAt ? { scheduled_at: new Date(scheduledAt).toISOString() } : {},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (pubRes.data?.success && pubRes.data.data) {
|
||||
setExternalUrl(pubRes.data.data.external_url || null);
|
||||
setStatus('published');
|
||||
if (onSuccess) onSuccess(pubRes.data.data.post_id, pubRes.data.data.external_url);
|
||||
} else {
|
||||
throw new Error(pubRes.data?.error || 'Publishing failed');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.error || e?.message || 'Publishing failed');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const minScheduleDate = new Date(Date.now() + 11 * 60 * 1000).toISOString().slice(0, 16);
|
||||
const enabledCount = blocks.filter((b) => b.enabled && b.content.trim()).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
Post to Facebook
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{status === 'loading' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 3 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Se încarcă datele analizei...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(status === 'editing' || status === 'publishing' || status === 'error') && (
|
||||
<Stack spacing={2}>
|
||||
{session && (
|
||||
<Alert severity="info">
|
||||
Analiza <code style={{ fontSize: '0.85em' }}>{session.session_id.slice(0, 8)}...</code>
|
||||
{session.risk_score !== null && session.risk_score !== undefined && (
|
||||
<> · Risc: <strong>{session.risk_score}</strong> ({session.risk_category})</>
|
||||
)}
|
||||
{' '}— bifează blocurile pe care vrei să le incluzi în post și editează textul.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Blocuri ({enabledCount} active din {blocks.length})
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
{blocks.map((block) => (
|
||||
<Paper
|
||||
key={block.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: block.enabled ? 'background.paper' : 'action.disabledBackground',
|
||||
opacity: block.enabled ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={block.enabled}
|
||||
onChange={(e) => updateBlock(block.id, { enabled: e.target.checked })}
|
||||
disabled={status === 'publishing'}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{block.label}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
value={block.content}
|
||||
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!block.enabled || status === 'publishing'}
|
||||
placeholder="(gol — nu va apărea în post)"
|
||||
/>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PreviewIcon fontSize="small" color="action" />
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Preview post · {charCount} caractere
|
||||
{charCount > 500 && (
|
||||
<Chip label="Lung" color="warning" size="small" sx={{ ml: 1, height: 18 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: '#f5f7fa',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '0.9em',
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{previewText || <em style={{ color: '#999' }}>(post gol — bifează cel puțin un bloc)</em>}
|
||||
</Paper>
|
||||
|
||||
<TextField
|
||||
label="URL imagine (opțional — atașament foto)"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={status === 'publishing'}
|
||||
placeholder="https://didi365.eu/share/image.png"
|
||||
/>
|
||||
<TextField
|
||||
label="URL link (opțional — link clickable atașat la post)"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={status === 'publishing'}
|
||||
placeholder="https://didi365.eu/analyses/..."
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
icon={<ScheduleIcon />}
|
||||
label={
|
||||
scheduledAt
|
||||
? `Programat: ${new Date(scheduledAt).toLocaleString('ro-RO')}`
|
||||
: 'Publicare imediată'
|
||||
}
|
||||
color={scheduledAt ? 'warning' : 'default'}
|
||||
onDelete={scheduledAt ? () => setScheduledAt('') : undefined}
|
||||
/>
|
||||
<Tooltip title="Programează publicarea (min 10 min în viitor)">
|
||||
<TextField
|
||||
type="datetime-local"
|
||||
size="small"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
inputProps={{ min: minScheduleDate }}
|
||||
disabled={status === 'publishing'}
|
||||
sx={{ width: 220 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{status === 'published' && (
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
<Alert severity="success">
|
||||
{scheduledAt ? '✓ Programat cu success pe Facebook!' : '✓ Publicat cu success pe Facebook!'}
|
||||
</Alert>
|
||||
{externalUrl && (
|
||||
<Button
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Deschide pe Facebook
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{status === 'published' ? (
|
||||
<Button onClick={onClose} variant="contained">
|
||||
Închide
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose} disabled={status === 'publishing'}>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
status === 'publishing' ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<SendIcon />
|
||||
)
|
||||
}
|
||||
onClick={handlePublish}
|
||||
disabled={status !== 'editing' || !previewText.trim()}
|
||||
sx={{ bgcolor: '#1877F2', '&:hover': { bgcolor: '#0e5fc8' } }}
|
||||
>
|
||||
{status === 'publishing'
|
||||
? 'Se publică...'
|
||||
: scheduledAt
|
||||
? 'Programează'
|
||||
: 'Publică pe Facebook'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* AnalysisHistory — color helpers + formatters
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export const getRiskColor = (level: string | null): 'success' | 'warning' | 'error' | 'default' => {
|
||||
switch (level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW':
|
||||
return 'success';
|
||||
case 'MEDIUM':
|
||||
case 'HIGH':
|
||||
return 'warning';
|
||||
case 'VERY_HIGH':
|
||||
case 'CRITICAL':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRiskIcon = (level: string | null): React.ReactElement | undefined => {
|
||||
switch (level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW':
|
||||
return <CheckCircleIcon fontSize="small" />;
|
||||
case 'MEDIUM':
|
||||
return <InfoIcon fontSize="small" />;
|
||||
case 'HIGH':
|
||||
case 'VERY_HIGH':
|
||||
return <WarningIcon fontSize="small" />;
|
||||
case 'CRITICAL':
|
||||
return <ErrorIcon fontSize="small" />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRiskCategoryColor = (category: string | null): string => {
|
||||
switch (category) {
|
||||
case 'RELIABLE': return '#22c55e';
|
||||
case 'MOSTLY_RELIABLE': return '#84cc16';
|
||||
case 'MIXED': return '#eab308';
|
||||
case 'QUESTIONABLE': return '#f97316';
|
||||
case 'UNRELIABLE': return '#ef4444';
|
||||
case 'DISINFORMATION': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number | null) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ro-RO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* AnalysisHistory — types
|
||||
*/
|
||||
|
||||
export interface AnalysisSession {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
input_type: string;
|
||||
input_preview: string | null;
|
||||
input_url: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
total_duration_ms: number | null;
|
||||
source_app: string;
|
||||
risk_score: number | null;
|
||||
risk_category: string | null;
|
||||
risk_level: string | null;
|
||||
confidence: number | null;
|
||||
confidence_level: string | null;
|
||||
techniques_score: string | null;
|
||||
techniques_count: number | null;
|
||||
ai_probability: string | null;
|
||||
ai_verdict: string | null;
|
||||
total_claims: number | null;
|
||||
verified_true: number | null;
|
||||
verified_false: number | null;
|
||||
claims_score: string | null;
|
||||
domain: string | null;
|
||||
domain_verdict: string | null;
|
||||
domain_trust_score: number | null;
|
||||
source_trust_score: number | null;
|
||||
source_verdict: string | null;
|
||||
source_publication: string | null;
|
||||
source_author: string | null;
|
||||
source_platform: string | null;
|
||||
llm_usage: any | null;
|
||||
}
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { AnalysisHistory } from './AnalysisHistory';
|
||||
export { AnalysisDetailModal } from './AnalysisDetailModal';
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* VerdictConfig — fetch wrappers
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComponentWeight,
|
||||
Multiplier,
|
||||
VerdictCategory,
|
||||
RiskMapping,
|
||||
SeverityAssessment,
|
||||
VerdictConfigData,
|
||||
} from './types';
|
||||
|
||||
const FRAMEWORK_API = '/framework/api';
|
||||
const AGENT_API = '/agent-v3/api/v3/pipeline';
|
||||
|
||||
export async function fetchWeights() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVerdicts() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVerdictConfig(): Promise<{ success: boolean; data: VerdictConfigData | null }> {
|
||||
const res = await fetch(`${AGENT_API}/verdict-config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateComponentWeight(id: number, data: Partial<ComponentWeight>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/components/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createMultiplier(data: Partial<Multiplier>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateMultiplier(id: number, data: Partial<Multiplier>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteMultiplier(id: number) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateVerdictCategory(id: number, data: Partial<VerdictCategory>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/categories/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRiskMapping(id: number, data: Partial<RiskMapping>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/risk/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSeverity(id: string, data: Partial<SeverityAssessment>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/severity/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchRuntimeConfig(): Promise<{ success: boolean; data: VerdictConfigData | null; error?: string }> {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRuntimeConfig(data: VerdictConfigData) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function syncRedis() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/sync-redis`, { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Slider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||
} from '@mui/material';
|
||||
import type {
|
||||
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
|
||||
DeleteDialogState,
|
||||
} from '../types';
|
||||
|
||||
type DialogState<T> = { open: boolean; data: T | null };
|
||||
|
||||
interface WeightDialogProps {
|
||||
state: DialogState<ComponentWeight>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<ComponentWeight>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const WeightDialog: React.FC<WeightDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Component Weight</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Component" value={state.data.component_name} disabled fullWidth />
|
||||
<Box>
|
||||
<Typography gutterBottom>Weight: {state.data.component_weight}%</Typography>
|
||||
<Slider
|
||||
value={state.data.component_weight}
|
||||
onChange={(_, v) => setState({ ...state, data: { ...state.data!, component_weight: v as number } })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
marks={[{ value: 0, label: '0%' }, { value: 25, label: '25%' }, { value: 50, label: '50%' }, { value: 75, label: '75%' }, { value: 100, label: '100%' }]}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={state.data.description}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface MultiplierDialogProps {
|
||||
state: DialogState<Partial<Multiplier>>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<Partial<Multiplier>>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const MultiplierDialog: React.FC<MultiplierDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{state.data?.multiplier_id ? 'Edit Multiplier' : 'Add Topic Multiplier'}</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={state.data.multiplier_name || ''}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, multiplier_name: e.target.value } })}
|
||||
fullWidth
|
||||
required
|
||||
placeholder="e.g. elections, health, geopolitics"
|
||||
/>
|
||||
<Box>
|
||||
<Typography gutterBottom>Multiplier: {(state.data.multiplier || 1).toFixed(2)}x</Typography>
|
||||
<Slider
|
||||
value={state.data.multiplier || 1}
|
||||
onChange={(_, v) => setState({ ...state, data: { ...state.data!, multiplier: v as number } })}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.05}
|
||||
marks={[{ value: 0.5, label: '0.5x' }, { value: 1, label: '1x' }, { value: 1.5, label: '1.5x' }, { value: 2, label: '2x' }]}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={state.data.description || ''}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!state.data?.multiplier_name}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface CategoryDialogProps {
|
||||
state: DialogState<VerdictCategory>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<VerdictCategory>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const CategoryDialog: React.FC<CategoryDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Verdict Category</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Code" value={state.data.verdict_category_code} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_code: e.target.value } })} fullWidth />
|
||||
<TextField label="Description" value={state.data.description} onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })} fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Color" value={state.data.verdict_category_color} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.verdict_category_color, borderRadius: 1, mr: 1 }} /> }} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface RiskDialogProps {
|
||||
state: DialogState<RiskMapping>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<RiskMapping>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const RiskDialog: React.FC<RiskDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Risk Mapping</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Risk Level Name" value={state.data.risk_mapping} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_mapping: e.target.value } })} fullWidth />
|
||||
<TextField label="Level Value" type="number" value={state.data.risk_level} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_level: Number(e.target.value) } })} fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Color" value={state.data.risk_color} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.risk_color, borderRadius: 1, mr: 1 }} /> }} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface SeverityDialogProps {
|
||||
state: DialogState<SeverityAssessment>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<SeverityAssessment>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const SeverityDialog: React.FC<SeverityDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Severity Level</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Severity ID" value={state.data.severity_id} disabled fullWidth helperText="Primary key — cannot be changed" />
|
||||
<TextField
|
||||
label="Severity Category"
|
||||
value={state.data.severity_category}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, severity_category: e.target.value } })}
|
||||
fullWidth
|
||||
helperText="LOW / MEDIUM / HIGH / CRITICAL"
|
||||
/>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField
|
||||
label="Start Range"
|
||||
type="number"
|
||||
value={state.data.start_range}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Range"
|
||||
type="number"
|
||||
value={state.data.end_range}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Recommended Action"
|
||||
value={state.data.recomended_action}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, recomended_action: e.target.value } })}
|
||||
fullWidth
|
||||
helperText="MONITOR / REVIEW / ESCALATE / URGENT"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save & Sync</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface DeleteDialogProps {
|
||||
state: DeleteDialogState | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteDialog: React.FC<DeleteDialogProps> = ({ state, onCancel, onConfirm }) => (
|
||||
<Dialog open={!!state} onClose={onCancel}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Are you sure you want to delete <strong>{state?.name}</strong>?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||
FormControl, InputLabel, Select, MenuItem,
|
||||
} from '@mui/material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
|
||||
interface Props {
|
||||
state: { open: boolean; data: VerdictConfigData | null };
|
||||
setState: React.Dispatch<React.SetStateAction<{ open: boolean; data: VerdictConfigData | null }>>;
|
||||
updateField: (path: string[], value: unknown) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const RuntimeConfigDialog: React.FC<Props> = ({ state, setState, updateField, onSave }) => (
|
||||
<Dialog
|
||||
open={state.open}
|
||||
onClose={() => setState({ open: false, data: null })}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Edit Verdict Runtime Config</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{state.data && (
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Synergy Bonus</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select
|
||||
label="Enabled"
|
||||
value={state.data.synergy.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['synergy', 'enabled'], e.target.value === 'true')}
|
||||
>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold (component score)" type="number" size="small"
|
||||
value={state.data.synergy.threshold}
|
||||
onChange={(e) => updateField(['synergy', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per component" type="number" size="small"
|
||||
value={state.data.synergy.bonus_per_component}
|
||||
onChange={(e) => updateField(['synergy', 'bonus_per_component'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.synergy.max_bonus}
|
||||
onChange={(e) => updateField(['synergy', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: False Claims</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.false_claims.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per claim" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.bonus_per_claim}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'bonus_per_claim'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.max_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Severe Techniques</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.severe_techniques.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.severe_techniques.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus" type="number" size="small"
|
||||
value={state.data.overrides.severe_techniques.bonus}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Undisclosed AI</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.undisclosed_ai.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Bonus" type="number" size="small"
|
||||
value={state.data.overrides.undisclosed_ai.bonus}
|
||||
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Untrusted Domain</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.untrusted_domain.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Untrusted bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.untrusted_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'untrusted_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Suspicious bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.suspicious_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'suspicious_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Blacklisted bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.blacklisted_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'blacklisted_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Domain Red Flags</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.domain_red_flags.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per flag" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.bonus_per_flag}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'bonus_per_flag'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.max_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Bonuses</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<TextField label="Base per component" type="number" size="small"
|
||||
value={state.data.confidence.base_per_component}
|
||||
onChange={(e) => updateField(['confidence', 'base_per_component'], Number(e.target.value))} />
|
||||
<TextField label="Domain strong signal" type="number" size="small"
|
||||
value={state.data.confidence.domain_strong_signal_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'domain_strong_signal_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Domain weak signal" type="number" size="small"
|
||||
value={state.data.confidence.domain_weak_signal_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'domain_weak_signal_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Techniques bonus max" type="number" size="small"
|
||||
value={state.data.confidence.techniques_bonus_max}
|
||||
onChange={(e) => updateField(['confidence', 'techniques_bonus_max'], Number(e.target.value))} />
|
||||
<TextField label="AI HIGH conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_high_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_high_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="AI MEDIUM conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_medium_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_medium_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="AI LOW conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_low_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_low_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Claims verified bonus max" type="number" size="small"
|
||||
value={state.data.confidence.claims_verified_bonus_max}
|
||||
onChange={(e) => updateField(['confidence', 'claims_verified_bonus_max'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Levels (min thresholds)</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(3, 1fr)" gap={2}>
|
||||
<TextField label="HIGH min" type="number" size="small"
|
||||
value={state.data.confidence_levels.HIGH.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'HIGH', 'min'], Number(e.target.value))} />
|
||||
<TextField label="MEDIUM min" type="number" size="small"
|
||||
value={state.data.confidence_levels.MEDIUM.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'MEDIUM', 'min'], Number(e.target.value))} />
|
||||
<TextField label="LOW min" type="number" size="small"
|
||||
value={state.data.confidence_levels.LOW.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'LOW', 'min'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save & Sync</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* VerdictConfig — shared visual helpers
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Tooltip, Chip } from '@mui/material';
|
||||
|
||||
export const SEVERITY_COLORS: Record<string, string> = {
|
||||
LOW: '#4caf50',
|
||||
MEDIUM: '#ff9800',
|
||||
HIGH: '#f44336',
|
||||
CRITICAL: '#b71c1c',
|
||||
};
|
||||
|
||||
export const PROFILE_COLORS: Record<string, string> = {
|
||||
text_no_url: '#1976d2',
|
||||
text_with_url: '#0288d1',
|
||||
image: '#7b1fa2',
|
||||
audio: '#e65100',
|
||||
video: '#c62828',
|
||||
url: '#2e7d32',
|
||||
};
|
||||
|
||||
export const RangeBar: React.FC<{
|
||||
items: { start: number; end: number; color: string; label: string }[];
|
||||
}> = ({ items }) => (
|
||||
<Box sx={{ display: 'flex', height: 28, borderRadius: 1, overflow: 'hidden', border: '1px solid #444', width: '100%' }}>
|
||||
{items.map((item, i) => {
|
||||
const width = item.end - item.start + 1;
|
||||
return (
|
||||
<Tooltip key={i} title={`${item.label}: ${item.start}-${item.end}`}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${width}%`,
|
||||
bgcolor: item.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, fontSize: '0.65rem', textShadow: '0 1px 2px rgba(0,0,0,0.6)', whiteSpace: 'nowrap' }}>
|
||||
{width > 8 ? item.label : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ConfigValue: React.FC<{ label: string; value: string | number | boolean }> = ({ label, value }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Chip label={value ? 'ON' : 'OFF'} size="small" color={value ? 'success' : 'default'} />
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* Verdict Configuration Component
|
||||
*
|
||||
* Manages Final Verdict settings across 8 tabs:
|
||||
* 0. Component Weights (editable)
|
||||
* 1. Verdict Categories (editable)
|
||||
* 2. Risk Mappings (editable)
|
||||
* 3. Severity & Actions (editable, syncs to Redis)
|
||||
* 4. Overrides & Synergy (editable, from Redis verdict_config)
|
||||
* 5. Confidence (editable, from Redis verdict_config)
|
||||
* 6. Topic Multipliers (editable, only type=1 active)
|
||||
* 7. Input Profiles (editable, per input type)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Balance as WeightsIcon,
|
||||
Category as CategoryIcon,
|
||||
Warning as RiskIcon,
|
||||
TrendingUp as MultiplierIcon,
|
||||
Shield as ShieldIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Dashboard as ProfilesIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import type {
|
||||
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
|
||||
VerdictConfigData, DeleteDialogState,
|
||||
} from './types';
|
||||
import {
|
||||
fetchWeights, fetchVerdicts, fetchVerdictConfig, fetchRuntimeConfig,
|
||||
updateComponentWeight, createMultiplier, updateMultiplier, deleteMultiplier,
|
||||
updateVerdictCategory, updateRiskMapping, updateSeverity, updateRuntimeConfig,
|
||||
syncRedis,
|
||||
} from './api';
|
||||
import { WeightsTab } from './tabs/WeightsTab';
|
||||
import { CategoriesTab } from './tabs/CategoriesTab';
|
||||
import { RiskTab } from './tabs/RiskTab';
|
||||
import { SeverityTab } from './tabs/SeverityTab';
|
||||
import { OverridesTab } from './tabs/OverridesTab';
|
||||
import { ConfidenceTab } from './tabs/ConfidenceTab';
|
||||
import { MultipliersTab } from './tabs/MultipliersTab';
|
||||
import {
|
||||
WeightDialog, MultiplierDialog, CategoryDialog, RiskDialog, SeverityDialog, DeleteDialog,
|
||||
} from './dialogs/EntityDialogs';
|
||||
import { RuntimeConfigDialog } from './dialogs/RuntimeConfigDialog';
|
||||
import { InputProfilesPanel } from './panels/InputProfilesPanel';
|
||||
|
||||
export const VerdictConfig: React.FC = () => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [componentWeights, setComponentWeights] = useState<ComponentWeight[]>([]);
|
||||
const [multipliers, setMultipliers] = useState<Multiplier[]>([]);
|
||||
const [verdictCategories, setVerdictCategories] = useState<VerdictCategory[]>([]);
|
||||
const [riskMappings, setRiskMappings] = useState<RiskMapping[]>([]);
|
||||
const [severityAssessments, setSeverityAssessments] = useState<SeverityAssessment[]>([]);
|
||||
const [verdictConfig, setVerdictConfig] = useState<VerdictConfigData | null>(null);
|
||||
|
||||
const [weightDialog, setWeightDialog] = useState<{ open: boolean; data: ComponentWeight | null }>({ open: false, data: null });
|
||||
const [multiplierDialog, setMultiplierDialog] = useState<{ open: boolean; data: Partial<Multiplier> | null }>({ open: false, data: null });
|
||||
const [categoryDialog, setCategoryDialog] = useState<{ open: boolean; data: VerdictCategory | null }>({ open: false, data: null });
|
||||
const [riskDialog, setRiskDialog] = useState<{ open: boolean; data: RiskMapping | null }>({ open: false, data: null });
|
||||
const [severityDialog, setSeverityDialog] = useState<{ open: boolean; data: SeverityAssessment | null }>({ open: false, data: null });
|
||||
const [runtimeConfigDialog, setRuntimeConfigDialog] = useState<{ open: boolean; data: VerdictConfigData | null }>({ open: false, data: null });
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [weightsRes, verdictsRes, runtimeRes, redisRes] = await Promise.all([
|
||||
fetchWeights(),
|
||||
fetchVerdicts(),
|
||||
fetchRuntimeConfig(),
|
||||
fetchVerdictConfig(),
|
||||
]);
|
||||
|
||||
if (weightsRes.success) {
|
||||
setComponentWeights(weightsRes.data.componentWeights || []);
|
||||
setMultipliers(weightsRes.data.multipliers || []);
|
||||
}
|
||||
|
||||
if (verdictsRes.success) {
|
||||
setVerdictCategories(verdictsRes.data.verdictCategories || []);
|
||||
setRiskMappings(verdictsRes.data.riskMappings || []);
|
||||
const sev = (verdictsRes.data.severityAssessments || []).map((s: SeverityAssessment) => ({
|
||||
...s,
|
||||
severity_id: typeof s.severity_id === 'string' ? s.severity_id.trim() : s.severity_id,
|
||||
severity_category: typeof s.severity_category === 'string' ? s.severity_category.trim() : s.severity_category,
|
||||
recomended_action: typeof s.recomended_action === 'string' ? s.recomended_action.trim() : s.recomended_action,
|
||||
}));
|
||||
setSeverityAssessments(sev);
|
||||
}
|
||||
|
||||
// Prefer runtime-config from PG (source of truth). Fall back to Redis read if PG row missing.
|
||||
if (runtimeRes.success && runtimeRes.data) {
|
||||
setVerdictConfig(runtimeRes.data);
|
||||
} else if (redisRes.success && redisRes.data) {
|
||||
setVerdictConfig(redisRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const showSuccess = (msg: string) => { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); };
|
||||
const showError = (msg: string) => { setError(msg); setTimeout(() => setError(null), 5000); };
|
||||
|
||||
const handleSaveWeight = async () => {
|
||||
if (!weightDialog.data) return;
|
||||
try {
|
||||
const res = await updateComponentWeight(weightDialog.data.component_weight_id, weightDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Weight updated successfully');
|
||||
setWeightDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMultiplier = async () => {
|
||||
if (!multiplierDialog.data) return;
|
||||
try {
|
||||
const isEdit = 'multiplier_id' in multiplierDialog.data && multiplierDialog.data.multiplier_id;
|
||||
const res = isEdit
|
||||
? await updateMultiplier(multiplierDialog.data.multiplier_id as number, multiplierDialog.data)
|
||||
: await createMultiplier(multiplierDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess(isEdit ? 'Multiplier updated' : 'Multiplier created');
|
||||
setMultiplierDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMultiplier = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'multiplier') return;
|
||||
try {
|
||||
const res = await deleteMultiplier(deleteDialog.id);
|
||||
if (res.success) {
|
||||
showSuccess('Multiplier deleted');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCategory = async () => {
|
||||
if (!categoryDialog.data) return;
|
||||
try {
|
||||
const res = await updateVerdictCategory(categoryDialog.data.verdict_category_id, categoryDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Category updated');
|
||||
setCategoryDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRisk = async () => {
|
||||
if (!riskDialog.data) return;
|
||||
try {
|
||||
const res = await updateRiskMapping(riskDialog.data.risk_mapping_id, riskDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Risk mapping updated');
|
||||
setRiskDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRedis = async () => {
|
||||
try {
|
||||
const res = await syncRedis();
|
||||
if (res.success) {
|
||||
showSuccess('Synced to Redis successfully');
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Sync failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSeverity = async () => {
|
||||
if (!severityDialog.data) return;
|
||||
const { severity_id, severity_category, start_range, end_range, recomended_action } = severityDialog.data;
|
||||
try {
|
||||
const res = await updateSeverity(severity_id, { severity_category, start_range, end_range, recomended_action });
|
||||
if (res.success) {
|
||||
await syncRedis();
|
||||
showSuccess('Severity updated and synced to Redis');
|
||||
setSeverityDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRuntimeConfig = async () => {
|
||||
if (!runtimeConfigDialog.data) return;
|
||||
try {
|
||||
const res = await updateRuntimeConfig(runtimeConfigDialog.data);
|
||||
if (res.success) {
|
||||
await syncRedis();
|
||||
showSuccess('Verdict config updated and synced to Redis');
|
||||
setRuntimeConfigDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const openRuntimeEditor = () => {
|
||||
if (!verdictConfig) return;
|
||||
setRuntimeConfigDialog({ open: true, data: JSON.parse(JSON.stringify(verdictConfig)) });
|
||||
};
|
||||
|
||||
const updateRuntimeField = (path: string[], value: unknown) => {
|
||||
setRuntimeConfigDialog(prev => {
|
||||
if (!prev.data) return prev;
|
||||
const next = JSON.parse(JSON.stringify(prev.data)) as VerdictConfigData;
|
||||
let cursor: Record<string, unknown> = next as unknown as Record<string, unknown>;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
cursor = cursor[path[i]] as Record<string, unknown>;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
return { open: true, data: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const topicMultipliers = multipliers.filter(m => m.multiplier_type === 1);
|
||||
const inactiveMultipliers = multipliers.filter(m => m.multiplier_type !== 1);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5">Final Verdict Configuration</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={fetchData}>Refresh</Button>
|
||||
<Button variant="contained" color="secondary" onClick={handleSyncRedis}>Sync to Redis</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, v) => setTabValue(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab icon={<WeightsIcon />} label={`Weights (${componentWeights.length})`} iconPosition="start" />
|
||||
<Tab icon={<CategoryIcon />} label={`Categories (${verdictCategories.length})`} iconPosition="start" />
|
||||
<Tab icon={<RiskIcon />} label={`Risk Levels (${riskMappings.length})`} iconPosition="start" />
|
||||
<Tab icon={<RiskIcon />} label={`Severity (${severityAssessments.length})`} iconPosition="start" />
|
||||
<Tab icon={<ShieldIcon />} label="Overrides & Synergy" iconPosition="start" />
|
||||
<Tab icon={<PsychologyIcon />} label="Confidence" iconPosition="start" />
|
||||
<Tab icon={<MultiplierIcon />} label={`Multipliers (${topicMultipliers.length})`} iconPosition="start" />
|
||||
<Tab icon={<ProfilesIcon />} label="Input Profiles" iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<WeightsTab
|
||||
componentWeights={componentWeights}
|
||||
onEdit={(w) => setWeightDialog({ open: true, data: w })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 1 && (
|
||||
<CategoriesTab
|
||||
verdictCategories={verdictCategories}
|
||||
onEdit={(c) => setCategoryDialog({ open: true, data: c })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 2 && (
|
||||
<RiskTab
|
||||
riskMappings={riskMappings}
|
||||
onEdit={(r) => setRiskDialog({ open: true, data: r })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 3 && (
|
||||
<SeverityTab
|
||||
severityAssessments={severityAssessments}
|
||||
onEdit={(s) => setSeverityDialog({ open: true, data: s })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 4 && (
|
||||
<OverridesTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
|
||||
)}
|
||||
{tabValue === 5 && (
|
||||
<ConfidenceTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
|
||||
)}
|
||||
{tabValue === 6 && (
|
||||
<MultipliersTab
|
||||
topicMultipliers={topicMultipliers}
|
||||
inactiveMultipliers={inactiveMultipliers}
|
||||
onAdd={() => setMultiplierDialog({ open: true, data: { multiplier_type: 1, multiplier_name: '', multiplier: 1, description: '' } })}
|
||||
onEdit={(m) => setMultiplierDialog({ open: true, data: m })}
|
||||
onDelete={(m) => setDeleteDialog({ open: true, type: 'multiplier', id: m.multiplier_id, name: m.multiplier_name })}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{tabValue === 7 && (
|
||||
<InputProfilesPanel onSuccess={showSuccess} onError={showError} />
|
||||
)}
|
||||
|
||||
<WeightDialog state={weightDialog} setState={setWeightDialog} onSave={handleSaveWeight} />
|
||||
<MultiplierDialog state={multiplierDialog} setState={setMultiplierDialog} onSave={handleSaveMultiplier} />
|
||||
<CategoryDialog state={categoryDialog} setState={setCategoryDialog} onSave={handleSaveCategory} />
|
||||
<RiskDialog state={riskDialog} setState={setRiskDialog} onSave={handleSaveRisk} />
|
||||
<SeverityDialog state={severityDialog} setState={setSeverityDialog} onSave={handleSaveSeverity} />
|
||||
<RuntimeConfigDialog
|
||||
state={runtimeConfigDialog}
|
||||
setState={setRuntimeConfigDialog}
|
||||
updateField={updateRuntimeField}
|
||||
onSave={handleSaveRuntimeConfig}
|
||||
/>
|
||||
<DeleteDialog
|
||||
state={deleteDialog}
|
||||
onCancel={() => setDeleteDialog(null)}
|
||||
onConfirm={handleDeleteMultiplier}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerdictConfig;
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Alert, CircularProgress, Card, CardContent, Chip, Slider,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField,
|
||||
} from '@mui/material';
|
||||
import { Dashboard as ProfilesIcon, Save as SaveIcon } from '@mui/icons-material';
|
||||
import { PROFILE_COLORS } from '../helpers';
|
||||
|
||||
interface InputProfilesProps {
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const InputProfilesPanel: React.FC<InputProfilesProps> = ({ onSuccess, onError }) => {
|
||||
const [profiles, setProfiles] = useState<any[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState<string>('text_no_url');
|
||||
const [editedProfile, setEditedProfile] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/framework/api/input-profiles');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProfiles(data.data);
|
||||
const current = data.data.find((p: any) => p.profile_code === selectedProfile) || data.data[0];
|
||||
setEditedProfile(JSON.parse(JSON.stringify(current)));
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onError, selectedProfile]);
|
||||
|
||||
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
|
||||
|
||||
const handleSelectProfile = (code: string) => {
|
||||
setSelectedProfile(code);
|
||||
const p = profiles.find((pr: any) => pr.profile_code === code);
|
||||
if (p) setEditedProfile(JSON.parse(JSON.stringify(p)));
|
||||
};
|
||||
|
||||
const handleWeightChange = (field: string, value: number) => {
|
||||
setEditedProfile((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleOverrideToggle = (overrideCode: string) => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const overrides = [...(prev.overrides || [])];
|
||||
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
|
||||
if (idx >= 0) overrides[idx] = { ...overrides[idx], enabled: !overrides[idx].enabled };
|
||||
return { ...prev, overrides };
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideValueChange = (overrideCode: string, field: string, value: number) => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const overrides = [...(prev.overrides || [])];
|
||||
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
|
||||
if (idx >= 0) overrides[idx] = { ...overrides[idx], [field]: value };
|
||||
return { ...prev, overrides };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editedProfile) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const total = editedProfile.weight_techniques + editedProfile.weight_claims +
|
||||
editedProfile.weight_ai_tampered + editedProfile.weight_source;
|
||||
if (total !== 100) {
|
||||
onError(`Weights must sum to 100% (currently ${total}%)`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weight_techniques: editedProfile.weight_techniques,
|
||||
weight_claims: editedProfile.weight_claims,
|
||||
weight_ai_tampered: editedProfile.weight_ai_tampered,
|
||||
weight_source: editedProfile.weight_source,
|
||||
override_cap: editedProfile.override_cap,
|
||||
min_components: editedProfile.min_components,
|
||||
ai_disclosure_multipliers: editedProfile.ai_disclosure_multipliers,
|
||||
}),
|
||||
});
|
||||
const profileData = await profileRes.json();
|
||||
if (!profileData.success) { onError(profileData.error); setSaving(false); return; }
|
||||
|
||||
if (editedProfile.overrides?.length > 0) {
|
||||
const overridesRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}/overrides`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: editedProfile.overrides }),
|
||||
});
|
||||
const overridesData = await overridesRes.json();
|
||||
if (!overridesData.success) { onError(overridesData.error); setSaving(false); return; }
|
||||
}
|
||||
|
||||
onSuccess(`Profile "${editedProfile.profile_name}" saved! Sync to Redis to apply.`);
|
||||
fetchProfiles();
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
|
||||
if (!editedProfile) return <Alert severity="info">No profiles found</Alert>;
|
||||
|
||||
const weightTotal = editedProfile.weight_techniques + editedProfile.weight_claims +
|
||||
editedProfile.weight_ai_tampered + editedProfile.weight_source;
|
||||
const origProfile = profiles.find((p: any) => p.profile_code === selectedProfile);
|
||||
const isModified = origProfile && JSON.stringify(origProfile) !== JSON.stringify(editedProfile);
|
||||
|
||||
return (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
<ProfilesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Input Type Profiles — Component weights per input type
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified || saving || weightTotal !== 100}
|
||||
>
|
||||
Save Profile
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes</Alert>}
|
||||
{weightTotal !== 100 && <Alert severity="error" sx={{ mb: 2 }}>Weights must sum to 100% (currently {weightTotal}%)</Alert>}
|
||||
|
||||
<Box display="flex" gap={1} mb={3} flexWrap="wrap">
|
||||
{profiles.map((p: any) => (
|
||||
<Chip
|
||||
key={p.profile_code}
|
||||
label={p.profile_name}
|
||||
onClick={() => handleSelectProfile(p.profile_code)}
|
||||
variant={selectedProfile === p.profile_code ? 'filled' : 'outlined'}
|
||||
color={selectedProfile === p.profile_code ? 'primary' : 'default'}
|
||||
sx={{
|
||||
borderColor: PROFILE_COLORS[p.profile_code],
|
||||
...(selectedProfile === p.profile_code && { bgcolor: PROFILE_COLORS[p.profile_code] }),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>Component Weights (total must = 100%)</Typography>
|
||||
{[
|
||||
{ key: 'weight_techniques', label: 'Techniques', color: '#9c27b0' },
|
||||
{ key: 'weight_claims', label: 'Claims', color: '#009688' },
|
||||
{ key: 'weight_ai_tampered', label: 'AI Tampered', color: '#ff5722' },
|
||||
{ key: 'weight_source', label: 'Source', color: '#2e7d32' },
|
||||
].map(({ key, label, color }) => (
|
||||
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Typography sx={{ width: 120, color }}>{label}</Typography>
|
||||
<Slider
|
||||
value={editedProfile[key]}
|
||||
onChange={(_, v) => handleWeightChange(key, v as number)}
|
||||
min={0} max={100} step={5}
|
||||
sx={{ flex: 1, color }}
|
||||
/>
|
||||
<Typography sx={{ width: 50, textAlign: 'right', fontWeight: 600 }}>{editedProfile[key]}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Chip
|
||||
label={`Total: ${weightTotal}%`}
|
||||
color={weightTotal === 100 ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>Override Rules</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="body2" color="text.secondary">Cap:</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={editedProfile.override_cap}
|
||||
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, override_cap: Number(e.target.value) }))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Override</TableCell>
|
||||
<TableCell width={80}>Active</TableCell>
|
||||
<TableCell width={90}>Threshold</TableCell>
|
||||
<TableCell width={90}>Per Unit</TableCell>
|
||||
<TableCell width={90}>Fixed</TableCell>
|
||||
<TableCell width={90}>Max</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(editedProfile.overrides || []).map((ov: any) => (
|
||||
<TableRow key={ov.override_code} sx={{ opacity: ov.enabled ? 1 : 0.5 }}>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{ov.override_code.replace(/_/g, ' ')}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={ov.enabled ? 'ON' : 'OFF'}
|
||||
size="small"
|
||||
color={ov.enabled ? 'success' : 'default'}
|
||||
onClick={() => handleOverrideToggle(ov.override_code)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.threshold != null && (
|
||||
<TextField size="small" type="number" value={ov.threshold}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'threshold', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.bonus_per_unit != null && (
|
||||
<TextField size="small" type="number" value={ov.bonus_per_unit}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_per_unit', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.bonus_fixed != null && (
|
||||
<TextField size="small" type="number" value={ov.bonus_fixed}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_fixed', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.max_bonus != null && (
|
||||
<TextField size="small" type="number" value={ov.max_bonus}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'max_bonus', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>AI Disclosure Multipliers</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
How much AI detection risk is reduced when AI usage is disclosed. Lower = less risk.
|
||||
</Typography>
|
||||
{editedProfile.ai_disclosure_multipliers && Object.entries(editedProfile.ai_disclosure_multipliers).map(([key, val]: [string, any]) => (
|
||||
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Typography sx={{ width: 100, textTransform: 'capitalize' }}>{key}</Typography>
|
||||
<Slider
|
||||
value={val}
|
||||
onChange={(_, v) => setEditedProfile((prev: any) => ({
|
||||
...prev,
|
||||
ai_disclosure_multipliers: { ...prev.ai_disclosure_multipliers, [key]: v as number }
|
||||
}))}
|
||||
min={0} max={1} step={0.05}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography sx={{ width: 60, textAlign: 'right', fontWeight: 600 }}>{(val * 100).toFixed(0)}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>INCONCLUSIVE Rules</Typography>
|
||||
<Box display="flex" gap={3} flexWrap="wrap">
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Minimum components for verdict</Typography>
|
||||
<TextField
|
||||
size="small" type="number"
|
||||
value={editedProfile.min_components}
|
||||
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, min_components: Number(e.target.value) }))}
|
||||
sx={{ width: 80, mt: 1 }}
|
||||
inputProps={{ min: 1, max: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Primary components</Typography>
|
||||
<Box display="flex" gap={0.5} mt={1}>
|
||||
{['techniques', 'claims', 'ai_tampered', 'source'].map(comp => {
|
||||
const isPrimary = (editedProfile.primary_components || []).includes(comp);
|
||||
return (
|
||||
<Chip
|
||||
key={comp}
|
||||
label={comp.replace('_', ' ')}
|
||||
size="small"
|
||||
variant={isPrimary ? 'filled' : 'outlined'}
|
||||
color={isPrimary ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const current = prev.primary_components || [];
|
||||
const updated = isPrimary
|
||||
? current.filter((c: string) => c !== comp)
|
||||
: [...current, comp];
|
||||
return { ...prev, primary_components: updated };
|
||||
});
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictCategory } from '../types';
|
||||
import { RangeBar } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
verdictCategories: VerdictCategory[];
|
||||
onEdit: (cat: VerdictCategory) => void;
|
||||
}
|
||||
|
||||
export const CategoriesTab: React.FC<Props> = ({ verdictCategories, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Verdict categories map risk scores to human-readable labels. Used by <code>mapToVerdictCategory()</code>.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={verdictCategories.map(c => ({
|
||||
start: c.start_range,
|
||||
end: c.end_range,
|
||||
color: c.verdict_category_color,
|
||||
label: c.verdict_category_code,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Color</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{verdictCategories.map((c) => (
|
||||
<TableRow key={c.verdict_category_id} hover>
|
||||
<TableCell><strong>{c.description}</strong></TableCell>
|
||||
<TableCell><Chip label={c.verdict_category_code} size="small" /></TableCell>
|
||||
<TableCell>{c.start_range} - {c.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 24, height: 24, bgcolor: c.verdict_category_color, borderRadius: 1, border: '1px solid #555' }} />
|
||||
<Typography variant="caption">{c.verdict_category_color}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(c)}><EditIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Alert, Card, CardContent, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
|
||||
interface Props {
|
||||
verdictConfig: VerdictConfigData | null;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const ConfidenceTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Confidence scoring parameters. Used by <code>calculateConfidence()</code>.
|
||||
Higher confidence = more data sources produced strong signals.
|
||||
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!verdictConfig}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit Confidence
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!verdictConfig ? (
|
||||
<Alert severity="warning">
|
||||
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Base Scoring
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Each of the 4 components (domain, techniques, ai, claims) adds {verdictConfig.confidence.base_per_component} when available.
|
||||
Max base = {verdictConfig.confidence.base_per_component * 4}.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Component Bonuses
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
Additional confidence points based on component signal strength.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Condition</TableCell>
|
||||
<TableCell align="right">Bonus</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Domain</TableCell>
|
||||
<TableCell>TRUSTED / UNTRUSTED verdict</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.domain_strong_signal_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Domain</TableCell>
|
||||
<TableCell>Other verdict (weak signal)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.domain_weak_signal_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Techniques</TableCell>
|
||||
<TableCell>Avg technique confidence (max bonus)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.techniques_bonus_max}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>HIGH confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_high_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>MEDIUM confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_medium_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>LOW confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_low_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Claims</TableCell>
|
||||
<TableCell>Verified ratio (max bonus)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.claims_verified_bonus_max}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Confidence Levels
|
||||
</Typography>
|
||||
<Box display="flex" gap={2} mt={1}>
|
||||
<Chip
|
||||
label={`HIGH >= ${verdictConfig.confidence_levels.HIGH.min}`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`MEDIUM >= ${verdictConfig.confidence_levels.MEDIUM.min}`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`LOW >= ${verdictConfig.confidence_levels.LOW.min}`}
|
||||
color="default"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Chip, IconButton,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { Multiplier } from '../types';
|
||||
|
||||
interface Props {
|
||||
topicMultipliers: Multiplier[];
|
||||
inactiveMultipliers: Multiplier[];
|
||||
onAdd: () => void;
|
||||
onEdit: (m: Multiplier) => void;
|
||||
onDelete: (m: Multiplier) => void;
|
||||
}
|
||||
|
||||
export const MultipliersTab: React.FC<Props> = ({
|
||||
topicMultipliers,
|
||||
inactiveMultipliers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Topic multipliers adjust the final risk score when <code>options.topic</code> is set.
|
||||
Only <strong>Topic</strong> multipliers are applied in code.
|
||||
</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={onAdd}>
|
||||
Add Topic Multiplier
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" fontWeight={700} mb={1}>
|
||||
Active Topic Multipliers ({topicMultipliers.length})
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Multiplier</TableCell>
|
||||
<TableCell>Effect</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topicMultipliers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body2" color="text.secondary">No topic multipliers configured</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : topicMultipliers.map((m) => (
|
||||
<TableRow key={m.multiplier_id} hover>
|
||||
<TableCell><strong>{m.multiplier_name}</strong></TableCell>
|
||||
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={m.multiplier > 1 ? `+${((m.multiplier - 1) * 100).toFixed(0)}%` : `${((m.multiplier - 1) * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
color={m.multiplier > 1 ? 'error' : m.multiplier < 1 ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{m.description}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(m)}><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{inactiveMultipliers.length > 0 && (
|
||||
<Box mt={3}>
|
||||
<Typography variant="subtitle2" fontWeight={700} mb={1} color="text.secondary">
|
||||
Inactive Multipliers ({inactiveMultipliers.length})
|
||||
<Chip label="Not implemented in code" size="small" color="default" sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Multiplier</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{inactiveMultipliers.map((m) => (
|
||||
<TableRow key={m.multiplier_id} sx={{ opacity: 0.5 }}>
|
||||
<TableCell>
|
||||
<Chip label={m.multiplier_type === 2 ? 'Temporal' : 'Reach'} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>{m.multiplier_name}</TableCell>
|
||||
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
|
||||
<TableCell>
|
||||
<Chip label="Not implemented" size="small" color="default" variant="outlined" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Button, Alert, Card, CardContent } from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
import { ConfigValue } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
verdictConfig: VerdictConfigData | null;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const OverridesTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Override rules that adjust the final risk score. Used by <code>applyOverrides()</code> and <code>applySynergyBonus()</code>.
|
||||
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!verdictConfig}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit Overrides & Synergy
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!verdictConfig ? (
|
||||
<Alert severity="warning">
|
||||
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Synergy Bonus
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
When multiple components show high risk (above threshold), adds a bonus to the final score.
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.synergy.enabled} />
|
||||
<ConfigValue label="Threshold (component score)" value={verdictConfig.synergy.threshold} />
|
||||
<ConfigValue label="Bonus per component" value={`+${verdictConfig.synergy.bonus_per_component}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.synergy.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Typography variant="subtitle1" fontWeight={700}>Override Rules</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(auto-fill, minmax(320px, 1fr))" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
False Claims
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.false_claims.enabled} />
|
||||
<ConfigValue label="Threshold (min false claims)" value={verdictConfig.overrides.false_claims.threshold} />
|
||||
<ConfigValue label="Bonus per false claim" value={`+${verdictConfig.overrides.false_claims.bonus_per_claim}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.false_claims.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Severe Techniques
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.severe_techniques.enabled} />
|
||||
<ConfigValue label="Threshold (min severe count)" value={verdictConfig.overrides.severe_techniques.threshold} />
|
||||
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.severe_techniques.bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Undisclosed AI
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.undisclosed_ai.enabled} />
|
||||
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.undisclosed_ai.bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Untrusted Domain
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.untrusted_domain.enabled} />
|
||||
<ConfigValue label="Untrusted bonus" value={`+${verdictConfig.overrides.untrusted_domain.untrusted_bonus}%`} />
|
||||
<ConfigValue label="Suspicious bonus" value={`+${verdictConfig.overrides.untrusted_domain.suspicious_bonus}%`} />
|
||||
<ConfigValue label="Blacklisted bonus" value={`+${verdictConfig.overrides.untrusted_domain.blacklisted_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Domain Red Flags
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.domain_red_flags.enabled} />
|
||||
<ConfigValue label="Threshold (min flags)" value={verdictConfig.overrides.domain_red_flags.threshold} />
|
||||
<ConfigValue label="Bonus per flag" value={`+${verdictConfig.overrides.domain_red_flags.bonus_per_flag}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.domain_red_flags.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { RiskMapping } from '../types';
|
||||
import { RangeBar } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
riskMappings: RiskMapping[];
|
||||
onEdit: (risk: RiskMapping) => void;
|
||||
}
|
||||
|
||||
export const RiskTab: React.FC<Props> = ({ riskMappings, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Risk levels provide an alternative categorization focused on risk severity. Used by <code>mapToRiskLevel()</code>.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={riskMappings.map(r => ({
|
||||
start: r.start_range,
|
||||
end: r.end_range,
|
||||
color: r.risk_color,
|
||||
label: r.risk_mapping,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Risk Level</TableCell>
|
||||
<TableCell>Level Value</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Color</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{riskMappings.map((r) => (
|
||||
<TableRow key={r.risk_mapping_id} hover>
|
||||
<TableCell><strong>{r.risk_mapping}</strong></TableCell>
|
||||
<TableCell><Chip label={r.risk_level} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{r.start_range} - {r.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 24, height: 24, bgcolor: r.risk_color, borderRadius: 1, border: '1px solid #555' }} />
|
||||
<Typography variant="caption">{r.risk_color}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(r)}><EditIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { SeverityAssessment } from '../types';
|
||||
import { RangeBar, SEVERITY_COLORS } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
severityAssessments: SeverityAssessment[];
|
||||
onEdit: (sev: SeverityAssessment) => void;
|
||||
}
|
||||
|
||||
export const SeverityTab: React.FC<Props> = ({ severityAssessments, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Severity levels determine recommended actions. Used by <code>mapToSeverity()</code>.
|
||||
Stored in <code>bos_parammgmt.severity_assessment</code>; saved rows auto-sync to Redis.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={severityAssessments.map(s => ({
|
||||
start: s.start_range,
|
||||
end: s.end_range,
|
||||
color: SEVERITY_COLORS[s.severity_category] || '#666',
|
||||
label: s.severity_category,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Recommended Action</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{severityAssessments.map((s) => (
|
||||
<TableRow key={s.severity_id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={s.severity_category}
|
||||
size="small"
|
||||
sx={{ bgcolor: SEVERITY_COLORS[s.severity_category] || '#666', color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{s.start_range} - {s.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={s.recomended_action} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => onEdit({ ...s })}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { ComponentWeight } from '../types';
|
||||
|
||||
interface Props {
|
||||
componentWeights: ComponentWeight[];
|
||||
onEdit: (weight: ComponentWeight) => void;
|
||||
}
|
||||
|
||||
export const WeightsTab: React.FC<Props> = ({ componentWeights, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Component weights determine how much each analysis component contributes to the final verdict score.
|
||||
Weights should sum to 100%.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Weight (%)</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{componentWeights.map((w) => (
|
||||
<TableRow key={w.component_weight_id} hover>
|
||||
<TableCell><strong>{w.component_name}</strong></TableCell>
|
||||
<TableCell>
|
||||
<Chip label={`${w.component_weight}%`} size="small" color="primary" />
|
||||
</TableCell>
|
||||
<TableCell>{w.description}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(w)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total: {componentWeights.reduce((sum, w) => sum + w.component_weight, 0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* VerdictConfig — shared types
|
||||
*/
|
||||
|
||||
export interface ComponentWeight {
|
||||
component_weight_id: number;
|
||||
component_name: string;
|
||||
component_weight: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Multiplier {
|
||||
multiplier_id: number;
|
||||
multiplier_type: number;
|
||||
multiplier_name: string;
|
||||
multiplier: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface VerdictCategory {
|
||||
verdict_category_id: number;
|
||||
verdict_category_code: string;
|
||||
description: string;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
verdict_category_color: string;
|
||||
}
|
||||
|
||||
export interface RiskMapping {
|
||||
risk_mapping_id: number;
|
||||
risk_mapping: string;
|
||||
risk_level: number;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
risk_color: string;
|
||||
}
|
||||
|
||||
export interface SeverityAssessment {
|
||||
severity_id: string;
|
||||
severity_category: string;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
recomended_action: string;
|
||||
}
|
||||
|
||||
export interface VerdictConfigData {
|
||||
synergy: {
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
bonus_per_component: number;
|
||||
max_bonus: number;
|
||||
};
|
||||
overrides: {
|
||||
false_claims: { enabled: boolean; threshold: number; bonus_per_claim: number; max_bonus: number };
|
||||
severe_techniques: { enabled: boolean; threshold: number; bonus: number };
|
||||
undisclosed_ai: { enabled: boolean; bonus: number };
|
||||
untrusted_domain: { enabled: boolean; untrusted_bonus: number; suspicious_bonus: number; blacklisted_bonus: number };
|
||||
domain_red_flags: { enabled: boolean; threshold: number; bonus_per_flag: number; max_bonus: number };
|
||||
};
|
||||
confidence: {
|
||||
base_per_component: number;
|
||||
domain_strong_signal_bonus: number;
|
||||
domain_weak_signal_bonus: number;
|
||||
techniques_bonus_max: number;
|
||||
ai_high_confidence_bonus: number;
|
||||
ai_medium_confidence_bonus: number;
|
||||
ai_low_confidence_bonus: number;
|
||||
claims_verified_bonus_max: number;
|
||||
};
|
||||
confidence_levels: {
|
||||
HIGH: { min: number };
|
||||
MEDIUM: { min: number };
|
||||
LOW: { min: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteDialogState {
|
||||
open: boolean;
|
||||
type: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* API helpers for the LLM Components Config page.
|
||||
*
|
||||
* Stage assignments + models live in PG (didiFramework /api/providers/*);
|
||||
* vision config is still fetched from agent-v3 /config because vision is
|
||||
* an agent-side concern and isn't mirrored to PG yet.
|
||||
*
|
||||
* fetchStageAssignments groups the flat assignment rows by stage_code AND
|
||||
* tier (free/premium), so each stage exposes both chains for the UI's tier
|
||||
* toggle. Falls back to free when premium is empty (legacy environments).
|
||||
*/
|
||||
import type { ModelConfig, StageAssignment, TierCode } from './types';
|
||||
|
||||
export const FRAMEWORK_API = '/framework/api/providers';
|
||||
|
||||
export async function fetchConfig(apiBase: string) {
|
||||
// Vision config still lives on agent-v3 (Redis-backed). Used only for AI Tampered.
|
||||
const res = await fetch(`${apiBase}/config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchModels(_apiBase: string) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/models`);
|
||||
const data = await res.json();
|
||||
if (!data.success) return data;
|
||||
|
||||
// Map PG schema to UI shape.
|
||||
const models = (data.data || []).map((m: any) => ({
|
||||
model_key: m.model_code,
|
||||
provider: m.provider_code || m.provider_name,
|
||||
model_name: m.model_name,
|
||||
context_window: m.context_window || 0,
|
||||
cost_input_1m: m.input_cost_per_1m || 0,
|
||||
cost_output_1m: m.output_cost_per_1m || 0,
|
||||
speed_tier: m.context_window >= 128000 ? 'fast' : m.context_window >= 32000 ? 'medium' : 'fast',
|
||||
quality_tier: m.input_cost_per_1m > 3 ? 'premium' : m.input_cost_per_1m > 0.5 ? 'high' : 'standard',
|
||||
_model_id: m.model_id,
|
||||
_provider_id: m.provider_id,
|
||||
}));
|
||||
|
||||
return { success: true, data: { models } };
|
||||
}
|
||||
|
||||
export async function fetchStageAssignments(_apiBase: string) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/assignments`);
|
||||
const data = await res.json();
|
||||
if (!data.success) return data;
|
||||
|
||||
const grouped: Record<string, StageAssignment> = {};
|
||||
for (const a of data.data || []) {
|
||||
const key = a.stage_code;
|
||||
const tier: TierCode = a.tier === 'premium' ? 'premium' : 'free';
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = {
|
||||
stage: key,
|
||||
description: a.stage_name || key,
|
||||
models: [],
|
||||
modelsByTier: { free: [], premium: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const role = a.fallback_order === 1 ? 'primary'
|
||||
: `fallback_${a.fallback_order - 1}` as ModelConfig['role'];
|
||||
|
||||
const entry: ModelConfig = {
|
||||
order: a.fallback_order,
|
||||
role,
|
||||
model_key: a.model_code,
|
||||
name: a.model_name,
|
||||
temperature: parseFloat(a.temperature),
|
||||
max_tokens: a.max_tokens,
|
||||
timeout_ms: a.timeout_ms,
|
||||
_stage_id: a.stage_id,
|
||||
_provider_id: a.provider_id,
|
||||
_model_id: a.model_id,
|
||||
_tier: tier,
|
||||
};
|
||||
|
||||
grouped[key].modelsByTier![tier].push(entry);
|
||||
}
|
||||
|
||||
for (const stage of Object.values(grouped)) {
|
||||
if (stage.modelsByTier) {
|
||||
stage.modelsByTier.free.sort((a, b) => a.order - b.order);
|
||||
stage.modelsByTier.premium.sort((a, b) => a.order - b.order);
|
||||
stage.models = stage.modelsByTier.free;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: grouped };
|
||||
}
|
||||
|
||||
export async function updateStageAssignments(_apiBase: string, data: Record<string, StageAssignment>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
const modelsRes = await fetch(`${FRAMEWORK_API}/models`);
|
||||
const modelsData = await modelsRes.json();
|
||||
const findModel = (modelKey: string) =>
|
||||
(modelsData.data || []).find((m: any) => m.model_code === modelKey);
|
||||
|
||||
for (const [_stageKey, stage] of Object.entries(data)) {
|
||||
const tierChains: [TierCode, ModelConfig[]][] = stage.modelsByTier
|
||||
? (Object.entries(stage.modelsByTier) as [TierCode, ModelConfig[]][])
|
||||
: [['free', stage.models]];
|
||||
|
||||
for (const [_tier, chain] of tierChains) {
|
||||
for (const model of chain) {
|
||||
const stageId = model._stage_id;
|
||||
if (!stageId) continue;
|
||||
|
||||
const body: any = {};
|
||||
if (model.temperature !== undefined) body.temperature = model.temperature;
|
||||
if (model.max_tokens !== undefined) body.max_tokens = model.max_tokens;
|
||||
if (model.timeout_ms !== undefined) body.timeout_ms = model.timeout_ms;
|
||||
|
||||
if (model.model_key) {
|
||||
const found = findModel(model.model_key);
|
||||
if (found) {
|
||||
body.model_id = found.model_id;
|
||||
body.provider_id = found.provider_id;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${FRAMEWORK_API}/assignments/${stageId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.success) {
|
||||
errors.push(result.error || `Failed to update stage ${stageId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true, message: 'Assignments updated in PostgreSQL' };
|
||||
}
|
||||
|
||||
export async function testModel(apiBase: string, modelKey: string) {
|
||||
const res = await fetch(`${apiBase}/test-model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_key: modelKey }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function syncToRedis(): Promise<{ success: boolean; error?: string; message?: string }> {
|
||||
const res = await fetch('/framework/api/sync-redis', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Static configuration table for the 6 analysis components.
|
||||
*
|
||||
* Each entry defines display metadata (name/icon/color), the agent-v3 API
|
||||
* base used for /test-model and /analyze, and the 2-stage pipeline shape
|
||||
* (screening + deep, or extraction + verification, etc.).
|
||||
*
|
||||
* `verdict` and `moderation` have empty `stages` because they delegate to
|
||||
* dedicated components rather than the generic stage-assignment editor.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Speed as SpeedIcon, Psychology as PsychologyIcon, Science as ScienceIcon,
|
||||
SmartToy as SmartToyIcon, FactCheck as FactCheckIcon,
|
||||
Gavel as VerdictIcon, TravelExplore as SourceIcon, Shield as ModerationIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ComponentType, ComponentConfig } from './types';
|
||||
|
||||
export const COMPONENT_CONFIGS: Record<ComponentType, ComponentConfig> = {
|
||||
'techniques': {
|
||||
name: 'Manipulation Techniques',
|
||||
icon: <ScienceIcon />,
|
||||
description: '2-Stage Pipeline for detecting manipulation techniques',
|
||||
color: '#9c27b0',
|
||||
apiBase: '/agent-v3/api/v3/techniques',
|
||||
stages: [
|
||||
{
|
||||
key: 'techniques_screening',
|
||||
title: 'Stage 1: SCREENING',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Quick dimension detection (~500 tokens, ~2-5s)',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'techniques_deep',
|
||||
title: 'Stage 2: DEEP ANALYSIS',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Per-dimension technique detection (~1-2k tokens)',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'ai-tampered': {
|
||||
name: 'AI Tampered Detection',
|
||||
icon: <SmartToyIcon />,
|
||||
description: '2-Stage Pipeline for detecting AI-generated content',
|
||||
color: '#ff5722',
|
||||
apiBase: '/agent-v3/api/v3/ai-tampered',
|
||||
stages: [
|
||||
{
|
||||
key: 'ai_tampered_screening',
|
||||
title: 'Stage 1: SCREENING',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Quick AI detection (~1k tokens)',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'ai_tampered_deep',
|
||||
title: 'Stage 2: DEEP ANALYSIS',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Per-category indicator detection',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
hasVision: true,
|
||||
},
|
||||
'claims': {
|
||||
name: 'Claims Verification',
|
||||
icon: <FactCheckIcon />,
|
||||
description: '2-Stage Pipeline for claim extraction and verification',
|
||||
color: '#009688',
|
||||
apiBase: '/agent-v3/api/v3/claims',
|
||||
stages: [
|
||||
{
|
||||
key: 'claims_extraction',
|
||||
title: 'Stage 1: EXTRACTION',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Extract claims from text',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'claims_verification',
|
||||
title: 'Stage 2: VERIFICATION',
|
||||
icon: <FactCheckIcon />,
|
||||
description: 'Verify claims against sources',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'source-assessment': {
|
||||
name: 'Source Assessment',
|
||||
icon: <SourceIcon />,
|
||||
description: '2-Stage Pipeline for source credibility assessment (publication, author, platform, domain)',
|
||||
color: '#2e7d32',
|
||||
apiBase: '/agent-v3/api/v3/source-assessment',
|
||||
stages: [
|
||||
{
|
||||
key: 'source_assessment_extraction',
|
||||
title: 'Stage 1: EXTRACTION',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Extract publication, author, platform from text/transcript',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'source_assessment_evaluation',
|
||||
title: 'Stage 2: EVALUATION',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Classify source using web evidence and framework categories',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'verdict': {
|
||||
name: 'Final Verdict',
|
||||
icon: <VerdictIcon />,
|
||||
description: 'Configure verdict weights, categories, risk mappings and multipliers',
|
||||
color: '#7b1fa2',
|
||||
apiBase: '/framework/api',
|
||||
stages: [],
|
||||
},
|
||||
'moderation': {
|
||||
name: 'Moderation',
|
||||
icon: <ModerationIcon />,
|
||||
description: 'HIL triage rules, brain client settings, sensitive topics, roles',
|
||||
color: '#0288d1',
|
||||
apiBase: '/framework/api',
|
||||
stages: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Pure helpers for the Parameters panel — flatten/set nested config objects
|
||||
* and a curated label/tooltip table for the most common scoring keys.
|
||||
*
|
||||
* EXCLUDE_KEYS are scaffolding fields that don't belong in the UI grid
|
||||
* (legacy or computed at runtime); the panel filters them out before render.
|
||||
*/
|
||||
|
||||
export const PARAM_INFO: Record<string, { label: string; tooltip: string }> = {
|
||||
count_scaler_divisor: { label: 'Count Scaler Divisor', tooltip: 'How many techniques needed for full score. 1 technique = 1/N of max. Default 3 means 1 tech=33%, 2=67%, 3+=100%.' },
|
||||
severe_threshold: { label: 'Severe Technique Threshold', tooltip: 'Techniques with severity >= this value are considered "severe" and trigger the severe_techniques override bonus.' },
|
||||
'bonuses.intensity_bonus.cap': { label: 'Intensity Bonus Cap', tooltip: 'Maximum bonus from technique intensity (0-1 scale). Prevents high-intensity single techniques from dominating.' },
|
||||
'bonuses.intensity_bonus.coefficient': { label: 'Intensity Bonus Coefficient', tooltip: 'Multiplier for intensity calculation. Higher = more impact from technique intensity.' },
|
||||
'bonuses.intensity_bonus.type': { label: 'Intensity Bonus Type', tooltip: '"sqrt" = diminishing returns (recommended), "linear" = proportional intensity impact.' },
|
||||
'bonuses.count_bonus.threshold_3': { label: 'Count Bonus (>= 3 techniques)', tooltip: 'Bonus added to manipulation score when 3+ techniques detected (0-1 scale).' },
|
||||
'bonuses.count_bonus.threshold_5': { label: 'Count Bonus (>= 5 techniques)', tooltip: 'Bonus added when 5+ techniques detected. Replaces threshold_3 bonus.' },
|
||||
'bonuses.dimension_bonus.per_dimension': { label: 'Dimension Bonus Per Dimension', tooltip: 'Bonus per unique manipulation dimension detected (D1-D8). Multiple dimensions = broader manipulation.' },
|
||||
'bonuses.dimension_bonus.max_bonus': { label: 'Dimension Bonus Max', tooltip: 'Maximum total bonus from dimensions, regardless of how many are detected.' },
|
||||
'blend_weights.screening': { label: 'Blend: Screening Weight', tooltip: 'How much the screening stage contributes to final AI probability (0-1). Screening + Deep should sum to 1.0.' },
|
||||
'blend_weights.deep': { label: 'Blend: Deep Analysis Weight', tooltip: 'How much the deep analysis stage contributes to final AI probability (0-1).' },
|
||||
undisclosed_threshold: { label: 'Undisclosed AI Threshold', tooltip: 'AI probability (%) above which undisclosed AI triggers the override bonus. Content with disclosed AI is not affected.' },
|
||||
'thresholds.LIKELY_AI': { label: 'Verdict: LIKELY AI (%)', tooltip: 'AI probability >= this value produces LIKELY_AI verdict label.' },
|
||||
'thresholds.POSSIBLY_AI': { label: 'Verdict: POSSIBLY AI (%)', tooltip: 'AI probability >= this value produces POSSIBLY_AI verdict label.' },
|
||||
'thresholds.MIXED': { label: 'Verdict: MIXED (%)', tooltip: 'AI probability >= this value produces MIXED verdict label.' },
|
||||
'disclosure_impact.explicit': { label: 'Disclosure Impact: Explicit', tooltip: 'Risk multiplier when AI is explicitly declared (e.g. "Written by ChatGPT"). Lower = less risk. 0.3 means 30% of original risk.' },
|
||||
'disclosure_impact.partial': { label: 'Disclosure Impact: Partial', tooltip: 'Risk multiplier for partial AI disclosure (e.g. "with AI assistance").' },
|
||||
'disclosure_impact.implied': { label: 'Disclosure Impact: Implied', tooltip: 'Risk multiplier for implied AI disclosure (e.g. mentioning ChatGPT in context).' },
|
||||
'disclosure_impact.none': { label: 'Disclosure Impact: None', tooltip: 'Risk multiplier when AI is NOT disclosed. 1.0 = full risk (no reduction).' },
|
||||
'status_weights.VT': { label: 'Status: Verified True', tooltip: 'Credibility weight for claims verified as true (1.0 = full credibility contribution).' },
|
||||
'status_weights.LT': { label: 'Status: Likely True', tooltip: 'Credibility weight for claims likely true based on sources.' },
|
||||
'status_weights.UV': { label: 'Status: Unverified', tooltip: 'Credibility weight for claims that could not be verified. Lower = more suspicious. 0.5 = neutral.' },
|
||||
'status_weights.OP': { label: 'Status: Opinion as Fact', tooltip: 'Credibility weight for opinions presented as facts. Low value penalizes this practice.' },
|
||||
'status_weights.LF': { label: 'Status: Likely False', tooltip: 'Credibility weight for claims likely false based on contradicting sources.' },
|
||||
'status_weights.VF': { label: 'Status: Verified False', tooltip: 'Credibility weight for verified false claims. 0 = zero credibility contribution.' },
|
||||
all_unverified_credibility: { label: 'All Unverified Default', tooltip: 'Default credibility score (0-1) when ALL claims are unverified with zero web sources. 0.75 = lean credible, 0.5 = neutral, 0.25 = suspicious.' },
|
||||
all_unverified_behavior: { label: 'All Unverified Behavior', tooltip: '"fixed" = use the default value above. "calculate_per_type" = calculate based on claim types and their individual unverified weights.' },
|
||||
'axis_weights.publication': { label: 'Axis: Publication', tooltip: 'Weight of publication/outlet credibility in trust score (0-1). All axes should sum to 1.0.' },
|
||||
'axis_weights.domain': { label: 'Axis: Domain', tooltip: 'Weight of domain analysis (age, SSL, blacklist) in trust score. 0 when no URL available.' },
|
||||
'axis_weights.author': { label: 'Axis: Author', tooltip: 'Weight of author credibility in trust score.' },
|
||||
'axis_weights.platform': { label: 'Axis: Platform', tooltip: 'Weight of platform type (news site, social media, blog) in trust score.' },
|
||||
'verdict_thresholds.TRUSTED': { label: 'Threshold: TRUSTED (>=)', tooltip: 'Trust score >= this value = TRUSTED verdict. Source is considered reliable.' },
|
||||
'verdict_thresholds.NEUTRAL': { label: 'Threshold: NEUTRAL (>=)', tooltip: 'Trust score >= this value = NEUTRAL verdict. Source cannot be confirmed or denied.' },
|
||||
'verdict_thresholds.SUSPICIOUS': { label: 'Threshold: SUSPICIOUS (>=)', tooltip: 'Trust score >= this value = SUSPICIOUS verdict. Below this = UNTRUSTED.' },
|
||||
domain_unavailable_default: { label: 'Domain Unavailable Default', tooltip: 'Default domain axis score (0-100) when no URL is provided. 50 = neutral assumption.' },
|
||||
};
|
||||
|
||||
// Keys to exclude from display (internal / non-scoring).
|
||||
export const EXCLUDE_KEYS = ['base_calculation', 'manipulation_levels', 'warning_flags_triggers',
|
||||
'category_weights', 'risk_calculation', 'confidence_levels', 'source_reliability_weights',
|
||||
'claim_type_weights', 'status_thresholds', 'quick_scores'];
|
||||
|
||||
/** Flatten a nested object into dot-notation key-value pairs. */
|
||||
export function flattenConfig(obj: any, prefix = ''): { key: string; value: any }[] {
|
||||
const result: { key: string; value: any }[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
result.push(...flattenConfig(v, fullKey));
|
||||
} else {
|
||||
result.push({ key: fullKey, value: v });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a value in a nested object using dot-notation key. */
|
||||
export function setNestedValue(obj: any, path: string, value: any): any {
|
||||
const clone = JSON.parse(JSON.stringify(obj));
|
||||
const parts = path.split('.');
|
||||
let current = clone;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) current[parts[i]] = {};
|
||||
current = current[parts[i]];
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* LLM Components Configuration - Unified UI
|
||||
*
|
||||
* Manages stage assignments and models for:
|
||||
* - Techniques V3 (Manipulation Detection)
|
||||
* - AI Tampered (AI Content Detection)
|
||||
* - Claims (Claim Verification)
|
||||
* - Source Assessment (Source credibility)
|
||||
* - Verdict (final scoring) — delegated to <VerdictConfig />
|
||||
* - Moderation — delegated to <ModerationSettings />
|
||||
*
|
||||
* Original 1608-line index.tsx split into:
|
||||
* types.ts — domain types
|
||||
* component-configs.tsx — COMPONENT_CONFIGS table (icons + stages + apiBase)
|
||||
* api.ts — fetch / update / test / sync helpers
|
||||
* helpers.ts — flattenConfig + setNestedValue + PARAM_INFO + EXCLUDE_KEYS
|
||||
* panels/StageAssignmentsPanel — per-stage model chains with tier toggle
|
||||
* panels/VisionModelsPanel — vision cascade for AI Tampered
|
||||
* panels/AvailableModelsPanel — flat models grid + connectivity test
|
||||
* panels/TestAnalysisPanel — quick /analyze runner
|
||||
* panels/PromptsPanel — system+user prompt editor
|
||||
* panels/ParametersPanel — scoring config editor
|
||||
* index.tsx (this) — page shell + state + render dispatcher
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress, Divider,
|
||||
ToggleButton, ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon, Refresh as RefreshIcon,
|
||||
Psychology as PsychologyIcon, SwapVert as SwapVertIcon,
|
||||
Visibility as VisionIcon, Settings as SettingsIcon,
|
||||
Sync as SyncIcon, Description as PromptsTabIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { VerdictConfig } from './VerdictConfig';
|
||||
import ModerationSettings from '../ModerationSettings';
|
||||
import {
|
||||
type ComponentType, type TierCode,
|
||||
type AvailableModel, type StageAssignment, type ModelConfig, type TestResult,
|
||||
} from './types';
|
||||
import { COMPONENT_CONFIGS } from './component-configs';
|
||||
import { fetchConfig, fetchModels, fetchStageAssignments, updateStageAssignments, testModel, syncToRedis } from './api';
|
||||
import { StageAssignmentsPanel } from './panels/StageAssignmentsPanel';
|
||||
import { VisionModelsPanel } from './panels/VisionModelsPanel';
|
||||
import { AvailableModelsPanel } from './panels/AvailableModelsPanel';
|
||||
import { TestAnalysisPanel } from './panels/TestAnalysisPanel';
|
||||
import { PromptsPanel } from './panels/PromptsPanel';
|
||||
import { ParametersPanel } from './panels/ParametersPanel';
|
||||
|
||||
export const LLMComponentsConfig: React.FC = () => {
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentType>('techniques');
|
||||
const [selectedTier, setSelectedTier] = useState<TierCode>('free');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||||
const [stageAssignments, setStageAssignments] = useState<Record<string, StageAssignment>>({});
|
||||
const [visionModels, setVisionModels] = useState<ModelConfig[]>([]);
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
|
||||
const [testText, setTestText] = useState('');
|
||||
const [analysisResult, setAnalysisResult] = useState<any>(null);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
|
||||
const config = COMPONENT_CONFIGS[selectedComponent];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTestResults({});
|
||||
|
||||
try {
|
||||
const [modelsRes, assignmentsRes, configRes] = await Promise.all([
|
||||
fetchModels(config.apiBase),
|
||||
fetchStageAssignments(config.apiBase),
|
||||
fetchConfig(config.apiBase),
|
||||
]);
|
||||
|
||||
if (modelsRes.success) setAvailableModels(modelsRes.data.models || []);
|
||||
if (assignmentsRes.success) setStageAssignments(assignmentsRes.data || {});
|
||||
if (config.hasVision && configRes.success && configRes.data.vision_models) {
|
||||
setVisionModels(configRes.data.vision_models.models || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.apiBase, config.hasVision]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData, selectedComponent]);
|
||||
|
||||
const handleModelChange = (stageKey: string, modelIndex: number, newModelKey: string) => {
|
||||
setStageAssignments((prev) => {
|
||||
const updated = { ...prev };
|
||||
const stage = { ...updated[stageKey] };
|
||||
const tierMap = { ...(stage.modelsByTier || { free: [], premium: [] }) } as Record<TierCode, ModelConfig[]>;
|
||||
const currentTierModels = [...(tierMap[selectedTier] || [])];
|
||||
if (!currentTierModels[modelIndex]) return prev;
|
||||
currentTierModels[modelIndex] = { ...currentTierModels[modelIndex], model_key: newModelKey };
|
||||
tierMap[selectedTier] = currentTierModels;
|
||||
stage.modelsByTier = tierMap;
|
||||
stage.models = currentTierModels;
|
||||
updated[stageKey] = stage;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await updateStageAssignments(config.apiBase, stageAssignments);
|
||||
if (res.success) {
|
||||
setSuccess('Configuration saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} else {
|
||||
setError(res.error || 'Save failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestModel = async (modelKey: string) => {
|
||||
setTestResults((prev) => ({ ...prev, [modelKey]: { model_key: modelKey, status: 'testing' } }));
|
||||
try {
|
||||
const res = await testModel(config.apiBase, modelKey);
|
||||
setTestResults((prev) => ({ ...prev, [modelKey]: res.data }));
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelKey]: { model_key: modelKey, status: 'error', error: (err as Error).message },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!testText.trim()) return;
|
||||
setAnalyzing(true);
|
||||
setAnalysisResult(null);
|
||||
try {
|
||||
const res = await fetch(`${config.apiBase}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText }),
|
||||
});
|
||||
setAnalysisResult(await res.json());
|
||||
} catch (err) {
|
||||
setAnalysisResult({ success: false, error: (err as Error).message });
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRedis = async () => {
|
||||
setSyncing(true);
|
||||
setSyncMessage(null);
|
||||
try {
|
||||
const result = await syncToRedis();
|
||||
if (result.success) {
|
||||
setSyncMessage('Redis synced successfully!');
|
||||
setSuccess('Redis synced from PostgreSQL');
|
||||
setTimeout(() => { setSyncMessage(null); setSuccess(null); }, 4000);
|
||||
} else {
|
||||
setError(result.error || 'Sync failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSpeedColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'ultra_fast': return 'success';
|
||||
case 'fast': return 'info';
|
||||
case 'medium': return 'warning';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'premium': return 'secondary';
|
||||
case 'high': return 'primary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && availableModels.length === 0) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const analysisComponents: ComponentType[] = ['techniques', 'ai-tampered', 'claims', 'source-assessment', 'verdict', 'moderation'];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box mb={3} display="flex" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<ToggleButtonGroup
|
||||
value={selectedComponent}
|
||||
exclusive
|
||||
onChange={(_, val) => val && setSelectedComponent(val)}
|
||||
size="large"
|
||||
>
|
||||
{analysisComponents.map((key) => {
|
||||
const cfg = COMPONENT_CONFIGS[key];
|
||||
return (
|
||||
<ToggleButton
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{
|
||||
px: 3,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: `${cfg.color}22`,
|
||||
borderColor: cfg.color,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{cfg.icon}
|
||||
<Typography>{cfg.name}</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
);
|
||||
})}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Verdict + Moderation delegated to dedicated components */}
|
||||
{selectedComponent === 'verdict' ? (
|
||||
<>
|
||||
<VerdictConfig />
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#7b1fa2' }}>
|
||||
<PromptsTabIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Verdict Prompts
|
||||
</Typography>
|
||||
<PromptsPanel componentCode="pipeline" />
|
||||
</>
|
||||
) : selectedComponent === 'moderation' ? (
|
||||
<ModerationSettings />
|
||||
) : (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: config.color }}>
|
||||
{config.icon}
|
||||
<Box component="span" ml={1}>{config.name}</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={loadData} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
|
||||
onClick={handleSyncRedis}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? 'Syncing...' : 'Sync to Redis'}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={loading} sx={{ bgcolor: config.color }}>
|
||||
Save Configuration
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
{syncMessage && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setSyncMessage(null)}>{syncMessage}</Alert>}
|
||||
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
|
||||
<Tab label="Stage Assignments" icon={<SwapVertIcon />} iconPosition="start" />
|
||||
{config.hasVision && <Tab label="Vision Models" icon={<VisionIcon />} iconPosition="start" />}
|
||||
<Tab label="Available Models" icon={<PsychologyIcon />} iconPosition="start" />
|
||||
<Tab label="Parameters" icon={<SettingsIcon />} iconPosition="start" />
|
||||
<Tab label="Prompts" icon={<PromptsTabIcon />} iconPosition="start" />
|
||||
<Tab label="Test Analysis" icon={<PlayIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<>
|
||||
<Paper sx={{
|
||||
mb: 2, p: 2, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', gap: 2, flexWrap: 'wrap',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Subscription tier</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selectedTier === 'free'
|
||||
? 'Plan 1-3 (Freemium / Starter / Basic) — local + cheap fallbacks'
|
||||
: 'Plan 4-6 (Pro / Business / Enterprise) — premium cloud models'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={selectedTier}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={(_, v) => v && setSelectedTier(v as TierCode)}
|
||||
>
|
||||
<ToggleButton value="free" sx={{ px: 3 }}>FREE</ToggleButton>
|
||||
<ToggleButton value="premium" sx={{ px: 3 }}>PREMIUM</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
<StageAssignmentsPanel
|
||||
stages={config.stages}
|
||||
stageAssignments={stageAssignments}
|
||||
availableModels={availableModels}
|
||||
testResults={testResults}
|
||||
selectedTier={selectedTier}
|
||||
onModelChange={handleModelChange}
|
||||
onTestModel={handleTestModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.hasVision && tabValue === 1 && (
|
||||
<VisionModelsPanel visionModels={visionModels} />
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 2 : 1) && (
|
||||
<AvailableModelsPanel
|
||||
models={availableModels}
|
||||
testResults={testResults}
|
||||
onTestModel={handleTestModel}
|
||||
getSpeedColor={getSpeedColor}
|
||||
getQualityColor={getQualityColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 3 : 2) && (
|
||||
<ParametersPanel
|
||||
componentType={selectedComponent}
|
||||
apiBase={config.apiBase}
|
||||
onSuccess={(msg) => { setSuccess(msg); setTimeout(() => setSuccess(null), 4000); }}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 4 : 3) && (
|
||||
<PromptsPanel componentCode={selectedComponent} />
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 5 : 4) && (
|
||||
<TestAnalysisPanel
|
||||
componentType={selectedComponent}
|
||||
testText={testText}
|
||||
setTestText={setTestText}
|
||||
analyzing={analyzing}
|
||||
analysisResult={analysisResult}
|
||||
onAnalyze={handleAnalyze}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Available models panel — flat list of every model defined in PG, with
|
||||
* test buttons + the latest test result inline.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, Typography, Button, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon, Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AvailableModel, TestResult } from '../types';
|
||||
|
||||
interface Props {
|
||||
models: AvailableModel[];
|
||||
testResults: Record<string, TestResult>;
|
||||
onTestModel: (modelKey: string) => void;
|
||||
getSpeedColor: (tier: string) => any;
|
||||
getQualityColor: (tier: string) => any;
|
||||
}
|
||||
|
||||
export const AvailableModelsPanel: React.FC<Props> = ({
|
||||
models, testResults, onTestModel, getSpeedColor, getQualityColor,
|
||||
}) => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Context</TableCell>
|
||||
<TableCell>Speed</TableCell>
|
||||
<TableCell>Quality</TableCell>
|
||||
<TableCell>Cost ($/1M)</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Test</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{models.map((model) => {
|
||||
const testResult = testResults[model.model_key];
|
||||
return (
|
||||
<TableRow key={model.model_key}>
|
||||
<TableCell>
|
||||
<Typography fontWeight="bold">{model.model_name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{model.model_key}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={model.provider} size="small" /></TableCell>
|
||||
<TableCell>{(model.context_window / 1000).toFixed(0)}K</TableCell>
|
||||
<TableCell><Chip label={model.speed_tier} size="small" color={getSpeedColor(model.speed_tier)} /></TableCell>
|
||||
<TableCell><Chip label={model.quality_tier} size="small" color={getQualityColor(model.quality_tier)} /></TableCell>
|
||||
<TableCell>
|
||||
{model.cost_input_1m === 0 ? (
|
||||
<Chip label="FREE" size="small" color="success" />
|
||||
) : (
|
||||
`$${model.cost_input_1m} / $${model.cost_output_1m}`
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{testResult?.status === 'connected' && (
|
||||
<Chip icon={<CheckIcon />} label={`${testResult.response_time_ms}ms`} size="small" color="success" />
|
||||
)}
|
||||
{testResult?.status === 'error' && (
|
||||
<Tooltip title={testResult.error}><Chip icon={<CloseIcon />} label="Error" size="small" color="error" /></Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'testing' && <CircularProgress size={20} />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" startIcon={<PlayIcon />} onClick={() => onTestModel(model.model_key)} disabled={testResult?.status === 'testing'}>
|
||||
Test
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Parameters panel — editable scoring config per analysis component.
|
||||
*
|
||||
* Reads from `/api/input-profiles/scoring-config/:component` (PG-backed via
|
||||
* didiFramework, not Redis), flattens nested objects into dot-keys, filters
|
||||
* out scaffolding (EXCLUDE_KEYS), and renders a row per scalar value.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Alert, Button, Chip, Tooltip, CircularProgress,
|
||||
TableContainer, Table, TableHead, TableBody, TableRow, TableCell, TextField,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Save as SaveIcon, Settings as SettingsIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ComponentType } from '../types';
|
||||
import { PARAM_INFO, EXCLUDE_KEYS, flattenConfig, setNestedValue } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
componentType: ComponentType;
|
||||
apiBase: string;
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const ParametersPanel: React.FC<Props> = ({ componentType, apiBase, onSuccess, onError }) => {
|
||||
void apiBase;
|
||||
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [editedConfig, setEditedConfig] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
setConfig(data.data);
|
||||
setEditedConfig(JSON.parse(JSON.stringify(data.data)));
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [componentType, onError]);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editedConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editedConfig),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.success) {
|
||||
onError(result.error || 'Failed to save');
|
||||
return;
|
||||
}
|
||||
setConfig(JSON.parse(JSON.stringify(editedConfig)));
|
||||
onSuccess('Parameters saved! Sync to Redis to apply.');
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
|
||||
if (!editedConfig) return <Alert severity="info">No scoring config found for {componentType}</Alert>;
|
||||
|
||||
const flatParams = flattenConfig(editedConfig)
|
||||
.filter(p => !EXCLUDE_KEYS.some(ex => p.key === ex || p.key.startsWith(ex + '.')));
|
||||
|
||||
const isModified = JSON.stringify(config) !== JSON.stringify(editedConfig);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
<SettingsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Scoring Parameters
|
||||
</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
{isModified && (
|
||||
<Button variant="text" onClick={() => setEditedConfig(JSON.parse(JSON.stringify(config)))}>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified || saving}
|
||||
>
|
||||
Save Parameters
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes. Save and then Sync to Redis to apply.</Alert>}
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Parameter</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 200 }}>Value</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 120 }}>Type</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{flatParams.map(({ key, value }) => {
|
||||
const info = PARAM_INFO[key];
|
||||
const label = info?.label || key;
|
||||
const tooltip = info?.tooltip || '';
|
||||
const valueType = typeof value;
|
||||
|
||||
return (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip} arrow placement="right">
|
||||
<InfoIcon sx={{ fontSize: 16, color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontFamily="monospace">{key}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{valueType === 'boolean' ? (
|
||||
<Chip
|
||||
label={value ? 'ON' : 'OFF'}
|
||||
size="small"
|
||||
color={value ? 'success' : 'default'}
|
||||
onClick={() => setEditedConfig((prev: any) => setNestedValue(prev, key, !value))}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
) : valueType === 'string' ? (
|
||||
<TextField
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => setEditedConfig((prev: any) => setNestedValue(prev, key, e.target.value))}
|
||||
sx={{ width: 180 }}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!isNaN(v)) setEditedConfig((prev: any) => setNestedValue(prev, key, v));
|
||||
}}
|
||||
sx={{ width: 120 }}
|
||||
inputProps={{ step: value < 1 ? 0.01 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={valueType} size="small" variant="outlined"
|
||||
color={valueType === 'number' ? 'primary' : valueType === 'boolean' ? 'success' : 'default'} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Prompts panel — edit system_prompt + user_template per (component, stage).
|
||||
*
|
||||
* Fetches the active component's prompts plus the SHARED prompts:
|
||||
* - vision → shown for techniques/ai-tampered/claims (image OCR + AI detect)
|
||||
* - pipeline → shown for verdict (final-verdict explanation prompt)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
TextField, Button, Chip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon, Save as SaveIcon,
|
||||
Description as PromptsTabIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { PromptComponentCode } from '../types';
|
||||
|
||||
interface PromptData {
|
||||
prompt_id: number;
|
||||
component_code: string;
|
||||
stage_code: string;
|
||||
system_prompt: string;
|
||||
user_template: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
componentCode: PromptComponentCode;
|
||||
}
|
||||
|
||||
export const PromptsPanel: React.FC<Props> = ({ componentCode }) => {
|
||||
const [prompts, setPrompts] = useState<PromptData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [editedPrompts, setEditedPrompts] = useState<Record<number, Partial<PromptData>>>({});
|
||||
|
||||
const fetchPrompts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const components = [componentCode];
|
||||
if (['techniques', 'ai-tampered', 'claims'].includes(componentCode)) {
|
||||
components.push('vision');
|
||||
}
|
||||
if (componentCode === 'verdict' || componentCode === 'pipeline') {
|
||||
components.push('pipeline');
|
||||
}
|
||||
|
||||
const allPrompts: PromptData[] = [];
|
||||
for (const comp of components) {
|
||||
const res = await fetch(`/framework/api/providers/prompts?component=${comp}`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
allPrompts.push(...data.data);
|
||||
}
|
||||
}
|
||||
|
||||
setPrompts(allPrompts);
|
||||
setEditedPrompts({});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [componentCode]);
|
||||
|
||||
useEffect(() => { fetchPrompts(); }, [fetchPrompts]);
|
||||
|
||||
const handleFieldChange = (promptId: number, field: 'system_prompt' | 'user_template', value: string) => {
|
||||
setEditedPrompts(prev => ({
|
||||
...prev,
|
||||
[promptId]: { ...prev[promptId], [field]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const isEdited = (promptId: number) => editedPrompts[promptId] !== undefined;
|
||||
|
||||
const handleSave = async (prompt: PromptData) => {
|
||||
const edits = editedPrompts[prompt.prompt_id];
|
||||
if (!edits) return;
|
||||
|
||||
setSaving(prompt.prompt_id);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/providers/prompts/${prompt.prompt_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSuccess(`Prompt "${prompt.stage_code}" saved! Remember to Sync to Redis.`);
|
||||
setEditedPrompts(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[prompt.prompt_id];
|
||||
return next;
|
||||
});
|
||||
fetchPrompts();
|
||||
setTimeout(() => setSuccess(null), 5000);
|
||||
} else {
|
||||
setError(data.error || 'Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{prompts.length === 0 ? (
|
||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
No prompts configured for {componentCode}. Prompts can be added via API.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
prompts.map((prompt) => {
|
||||
const edited = editedPrompts[prompt.prompt_id];
|
||||
const currentSystem = edited?.system_prompt ?? prompt.system_prompt;
|
||||
const currentUser = edited?.user_template ?? prompt.user_template;
|
||||
|
||||
return (
|
||||
<Accordion key={prompt.prompt_id} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={2} width="100%">
|
||||
<PromptsTabIcon color="action" />
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6">
|
||||
{prompt.stage_code}
|
||||
{prompt.component_code !== componentCode && (
|
||||
<Chip label={prompt.component_code} size="small" sx={{ ml: 1 }} color="info" variant="outlined" />
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{prompt.description}</Typography>
|
||||
</Box>
|
||||
{isEdited(prompt.prompt_id) && (
|
||||
<Chip label="Modified" size="small" color="warning" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>System Prompt</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
value={currentSystem}
|
||||
onChange={(e) => handleFieldChange(prompt.prompt_id, 'system_prompt', e.target.value)}
|
||||
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>User Template</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={8}
|
||||
value={currentUser}
|
||||
onChange={(e) => handleFieldChange(prompt.prompt_id, 'user_template', e.target.value)}
|
||||
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" gap={1}>
|
||||
{isEdited(prompt.prompt_id) && (
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setEditedPrompts(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[prompt.prompt_id];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving === prompt.prompt_id ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={() => handleSave(prompt)}
|
||||
disabled={!isEdited(prompt.prompt_id) || saving === prompt.prompt_id}
|
||||
>
|
||||
Save Prompt
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Stage assignments table — one accordion per pipeline stage, with the
|
||||
* tier-specific (free/premium) model chain editable inside.
|
||||
*
|
||||
* If the requested tier is empty (e.g. premium not configured yet), falls
|
||||
* back to the free chain and shows a warning Alert pointing to the API to
|
||||
* create the missing tier rows.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Accordion, AccordionSummary, AccordionDetails, Typography, Chip, Alert,
|
||||
TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
FormControl, Select, MenuItem, IconButton, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon, PlayArrow as PlayIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { StageConfig, StageAssignment, AvailableModel, TestResult, TierCode } from '../types';
|
||||
|
||||
interface Props {
|
||||
stages: StageConfig[];
|
||||
stageAssignments: Record<string, StageAssignment>;
|
||||
availableModels: AvailableModel[];
|
||||
testResults: Record<string, TestResult>;
|
||||
selectedTier: TierCode;
|
||||
onModelChange: (stageKey: string, modelIndex: number, newModelKey: string) => void;
|
||||
onTestModel: (modelKey: string) => void;
|
||||
}
|
||||
|
||||
export const StageAssignmentsPanel: React.FC<Props> = ({
|
||||
stages, stageAssignments, availableModels, testResults, selectedTier, onModelChange, onTestModel,
|
||||
}) => (
|
||||
<Box>
|
||||
{stages.map(({ key, title, icon, description, color }) => {
|
||||
const stage = stageAssignments[key];
|
||||
if (!stage) return null;
|
||||
|
||||
const tierModels =
|
||||
stage.modelsByTier?.[selectedTier] && stage.modelsByTier[selectedTier].length > 0
|
||||
? stage.modelsByTier[selectedTier]
|
||||
: (stage.modelsByTier?.free && stage.modelsByTier.free.length > 0
|
||||
? stage.modelsByTier.free
|
||||
: stage.models);
|
||||
const tierBadge = selectedTier === 'premium' ? 'PREMIUM' : 'FREE';
|
||||
const tierBadgeColor: 'success' | 'warning' = selectedTier === 'premium' ? 'warning' : 'success';
|
||||
const missingPremium = selectedTier === 'premium'
|
||||
&& (!stage.modelsByTier?.premium || stage.modelsByTier.premium.length === 0);
|
||||
|
||||
return (
|
||||
<Accordion key={`${key}-${selectedTier}`} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={2} width="100%">
|
||||
<Box sx={{ color }}>{icon}</Box>
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Chip label={tierBadge} size="small" color={tierBadgeColor} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">{description}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{missingPremium && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
No <strong>premium</strong> assignments configured for this stage — showing <strong>free</strong> chain
|
||||
as fallback. Create premium rows via POST /api/providers/assignments with <code>tier: "premium"</code>.
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={80}>Order</TableCell>
|
||||
<TableCell width={120}>Role</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell width={100}>Temp</TableCell>
|
||||
<TableCell width={120}>Max Tokens</TableCell>
|
||||
<TableCell width={120}>Timeout</TableCell>
|
||||
<TableCell width={100}>Status</TableCell>
|
||||
<TableCell width={80}>Test</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tierModels.map((model, index) => {
|
||||
const modelKey = model.model_key || '';
|
||||
const testResult = testResults[modelKey];
|
||||
|
||||
return (
|
||||
<TableRow key={model.order}>
|
||||
<TableCell>
|
||||
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={model.role.replace('_', ' ').toUpperCase()}
|
||||
size="small"
|
||||
variant={model.role === 'primary' ? 'filled' : 'outlined'}
|
||||
color={model.role === 'primary' ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelKey}
|
||||
onChange={(e) => onModelChange(key, index, e.target.value)}
|
||||
>
|
||||
{availableModels.map((m) => (
|
||||
<MenuItem key={m.model_key} value={m.model_key}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography>{m.model_name}</Typography>
|
||||
<Chip label={m.provider} size="small" variant="outlined" />
|
||||
{m.cost_input_1m === 0 && <Chip label="FREE" size="small" color="success" />}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>{model.temperature != null ? model.temperature : '-'}</TableCell>
|
||||
<TableCell>{model.max_tokens != null ? model.max_tokens : '-'}</TableCell>
|
||||
<TableCell>{model.timeout_ms != null ? `${model.timeout_ms / 1000}s` : '-'}</TableCell>
|
||||
<TableCell>
|
||||
{testResult?.status === 'connected' && (
|
||||
<Tooltip title={`${testResult.response_time_ms}ms`}>
|
||||
<Chip icon={<CheckIcon />} label="OK" size="small" color="success" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'error' && (
|
||||
<Tooltip title={testResult.error}>
|
||||
<Chip icon={<CloseIcon />} label="Error" size="small" color="error" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'testing' && <CircularProgress size={20} />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onTestModel(modelKey)} disabled={testResult?.status === 'testing'}>
|
||||
<PlayIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Test Analysis panel — quick-run UI for the active component's /analyze
|
||||
* endpoint. Per-component sample texts let QA reproduce known-good results
|
||||
* without copy-pasting from a doc.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Alert, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow as PlayIcon } from '@mui/icons-material';
|
||||
import type { ComponentType } from '../types';
|
||||
|
||||
interface Props {
|
||||
componentType: ComponentType;
|
||||
testText: string;
|
||||
setTestText: (text: string) => void;
|
||||
analyzing: boolean;
|
||||
analysisResult: any;
|
||||
onAnalyze: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_TEXTS: Record<ComponentType, string> = {
|
||||
'techniques': `URGENT: This is a THREAT to our survival! The government is hiding the truth.
|
||||
We must ACT NOW before it's too late! They don't want you to know about this conspiracy.
|
||||
Share this before they delete it! This is being censored everywhere.`,
|
||||
'ai-tampered': `It is important to note that there are several key considerations to keep in mind.
|
||||
Furthermore, one must acknowledge the multifaceted nature of this issue.
|
||||
In conclusion, while there are valid perspectives on both sides, a balanced approach is recommended.`,
|
||||
'claims': `The Eiffel Tower is 500 meters tall. Romania joined the EU in 2007.
|
||||
The vaccine has a 95% efficacy rate according to clinical trials.
|
||||
The GDP of Romania grew by 5% in 2024.`,
|
||||
'source-assessment': `According to a report by Reuters, the new climate agreement was signed by 50 countries.
|
||||
The article was written by journalist John Smith and published on fortune.com.
|
||||
The data comes from the United Nations Environment Programme.`,
|
||||
'verdict': '',
|
||||
'moderation': '',
|
||||
};
|
||||
|
||||
export const TestAnalysisPanel: React.FC<Props> = ({
|
||||
componentType, testText, setTestText, analyzing, analysisResult, onAnalyze,
|
||||
}) => (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Input Text</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="Enter text to analyze..."
|
||||
/>
|
||||
<Box mt={2} display="flex" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={analyzing ? <CircularProgress size={20} /> : <PlayIcon />}
|
||||
onClick={onAnalyze}
|
||||
disabled={analyzing || !testText.trim()}
|
||||
>
|
||||
{analyzing ? 'Analyzing...' : 'Run Analysis'}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => setTestText(SAMPLE_TEXTS[componentType])}>
|
||||
Load Sample
|
||||
</Button>
|
||||
<Button onClick={() => setTestText('')}>Clear</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
|
||||
<Paper sx={{ p: 2, minHeight: 400 }}>
|
||||
<Typography variant="h6" gutterBottom>Results</Typography>
|
||||
{!analysisResult && !analyzing && (
|
||||
<Typography color="text.secondary">Run an analysis to see results.</Typography>
|
||||
)}
|
||||
{analyzing && (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{analysisResult && !analyzing && (
|
||||
<Box>
|
||||
{analysisResult.success ? (
|
||||
<Box component="pre" sx={{ overflow: 'auto', maxHeight: 400, bgcolor: 'action.hover', p: 2, borderRadius: 1 }}>
|
||||
{JSON.stringify(analysisResult.data, null, 2)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="error">{analysisResult.error}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Vision Models panel — displayed only for AI Tampered (the one component
|
||||
* that runs frame-level analysis through the vision cascade).
|
||||
*
|
||||
* Read-only listing; vision config edits go through the agent-v3 admin API
|
||||
* (not yet PG-backed), so the UI doesn't expose direct row editing here.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, Typography,
|
||||
} from '@mui/material';
|
||||
import { Visibility as VisionIcon } from '@mui/icons-material';
|
||||
import type { ModelConfig } from '../types';
|
||||
|
||||
interface Props {
|
||||
visionModels: ModelConfig[];
|
||||
}
|
||||
|
||||
export const VisionModelsPanel: React.FC<Props> = ({ visionModels }) => (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<VisionIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Vision Models for Image AI Detection
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
These models are used to analyze images for AI-generated content indicators.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Order</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Timeout</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visionModels.map((model) => (
|
||||
<TableRow key={model.order}>
|
||||
<TableCell>
|
||||
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={(model.role || 'primary').replace('_', ' ').toUpperCase()}
|
||||
size="small"
|
||||
color={model.role === 'primary' ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography fontFamily="monospace">{model.model}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{model.name}</TableCell>
|
||||
<TableCell>{model.timeout ? `${model.timeout / 1000}s` : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Domain types for the LLM Components Configuration page.
|
||||
*
|
||||
* The shape mirrors PG-side schemas (assignments / models / configs),
|
||||
* with helper-only fields prefixed `_` (e.g. `_stage_id`) that the UI
|
||||
* uses to call PUT endpoints by id without flowing through serializers.
|
||||
*/
|
||||
import type React from 'react';
|
||||
|
||||
export type ComponentType = 'techniques' | 'ai-tampered' | 'claims' | 'source-assessment' | 'verdict' | 'moderation';
|
||||
export type PromptComponentCode = ComponentType | 'vision' | 'pipeline';
|
||||
export type TierCode = 'free' | 'premium';
|
||||
|
||||
export interface ModelConfig {
|
||||
order: number;
|
||||
role: 'primary' | 'fallback_1' | 'fallback_2' | 'fallback_3';
|
||||
model_key?: string;
|
||||
model?: string; // for vision models
|
||||
name?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
timeout_ms?: number;
|
||||
timeout?: number; // legacy alias used by vision models
|
||||
_stage_id?: number;
|
||||
_provider_id?: number;
|
||||
_model_id?: number;
|
||||
_tier?: TierCode;
|
||||
}
|
||||
|
||||
export interface StageAssignment {
|
||||
stage: string;
|
||||
description: string;
|
||||
models: ModelConfig[];
|
||||
modelsByTier?: Record<TierCode, ModelConfig[]>;
|
||||
}
|
||||
|
||||
export interface AvailableModel {
|
||||
model_key: string;
|
||||
provider: string;
|
||||
model_name: string;
|
||||
context_window: number;
|
||||
cost_input_1m: number;
|
||||
cost_output_1m: number;
|
||||
speed_tier: string;
|
||||
quality_tier: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
model_key: string;
|
||||
status: 'connected' | 'error' | 'testing';
|
||||
response_time_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
apiBase: string;
|
||||
stages: StageConfig[];
|
||||
hasVision?: boolean;
|
||||
}
|
||||
|
||||
export interface StageConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, Button, Chip, Alert, CircularProgress, Divider, Card, CardContent, Grid,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, ToggleButton, ToggleButtonGroup, TextField,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as BackIcon, CheckCircle as ApproveIcon, EditNote as EditIcon, Block as RejectIcon,
|
||||
PersonAdd as ClaimIcon, Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
getQueueEntry, claimQueueEntry, resolveQueueEntry,
|
||||
priorityLabel, priorityColor, statusColor, ageMinutes,
|
||||
type QueueEntry, type ResolutionAction,
|
||||
} from './api';
|
||||
|
||||
export const ModerationDetail: React.FC = () => {
|
||||
const { queueId } = useParams<{ queueId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [entry, setEntry] = useState<QueueEntry | null>(null);
|
||||
const [session, setSession] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [resolveAction, setResolveAction] = useState<ResolutionAction>('approved');
|
||||
const [resolveNotes, setResolveNotes] = useState('');
|
||||
const [resolveCorrections, setResolveCorrections] = useState('{\n \n}');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!queueId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getQueueEntry(queueId);
|
||||
if (res.success && res.data) {
|
||||
setEntry(res.data.queue);
|
||||
setSession(res.data.session);
|
||||
} else {
|
||||
setError(res.error ?? 'Failed to load');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queueId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleClaim = async () => {
|
||||
if (!queueId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await claimQueueEntry(queueId);
|
||||
if (res.success) {
|
||||
setSuccess('Claimed. You can now resolve.');
|
||||
load();
|
||||
} else {
|
||||
setError(res.error ?? 'Claim failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openResolve = (action: ResolutionAction) => {
|
||||
setResolveAction(action);
|
||||
setResolveNotes('');
|
||||
setResolveCorrections('{\n \n}');
|
||||
setResolveOpen(true);
|
||||
};
|
||||
|
||||
const handleResolveSubmit = async () => {
|
||||
if (!queueId) return;
|
||||
let corrections: Record<string, unknown> | null = null;
|
||||
if (resolveAction === 'corrected') {
|
||||
try {
|
||||
corrections = JSON.parse(resolveCorrections);
|
||||
} catch (e) {
|
||||
setError(`Invalid JSON in corrections: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await resolveQueueEntry(queueId, {
|
||||
action: resolveAction,
|
||||
corrections,
|
||||
notes: resolveNotes || null,
|
||||
user_id: 'admin',
|
||||
});
|
||||
if (res.success) {
|
||||
setSuccess(`Resolved as '${resolveAction}'`);
|
||||
setResolveOpen(false);
|
||||
load();
|
||||
} else {
|
||||
setError(res.error ?? 'Resolve failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert severity="error" action={<Button onClick={() => navigate('/moderation')}>Back</Button>}>
|
||||
{error ?? 'Not found'}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isActionable = entry.status === 'pending' || entry.status === 'in_review';
|
||||
const inReview = entry.status === 'in_review';
|
||||
|
||||
// Pull common fields from session safely
|
||||
const s = (session ?? {}) as Record<string, unknown>;
|
||||
const inputText = (s.input_text as string | null) ?? null;
|
||||
const inputUrl = (s.input_url as string | null) ?? null;
|
||||
const inputType = (s.input_type as string | null) ?? null;
|
||||
const userEmail = (s.user_email as string | null) ?? null;
|
||||
const riskScore = (s.risk_score as number | null) ?? null;
|
||||
const riskCategory = (s.risk_category as string | null) ?? null;
|
||||
const confidence = (s.confidence as number | null) ?? null;
|
||||
const severity = (s.severity as string | null) ?? null;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Typography variant="h5">Queue #{entry.queue_id}</Typography>
|
||||
<Chip label={entry.status} color={statusColor(entry.status)} size="small" />
|
||||
<Chip label={priorityLabel(entry.priority)} color={priorityColor(entry.priority)} size="small" />
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Paper sx={{ p: 2, mb: 2, bgcolor: isActionable ? 'background.paper' : 'action.hover' }}>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
{entry.status === 'pending' && (
|
||||
<Button
|
||||
variant="contained" color="primary" startIcon={<ClaimIcon />}
|
||||
onClick={handleClaim} disabled={submitting}
|
||||
>
|
||||
Claim for Review
|
||||
</Button>
|
||||
)}
|
||||
{inReview && (
|
||||
<>
|
||||
<Button variant="contained" color="success" startIcon={<ApproveIcon />} onClick={() => openResolve('approved')} disabled={submitting}>
|
||||
Approve as is
|
||||
</Button>
|
||||
<Button variant="contained" color="info" startIcon={<EditIcon />} onClick={() => openResolve('corrected')} disabled={submitting}>
|
||||
Resolve with Corrections
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" startIcon={<RejectIcon />} onClick={() => openResolve('rejected')} disabled={submitting}>
|
||||
Reject (low quality input)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isActionable && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status is <strong>{entry.status}</strong>.
|
||||
{entry.resolved_by && ` Resolved by ${entry.resolved_by} as '${entry.resolution_action}'.`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* Left: Session input */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Input</Typography>
|
||||
<Box display="flex" gap={1} mb={1} flexWrap="wrap">
|
||||
<Chip label={`type: ${inputType ?? '?'}`} size="small" variant="outlined" />
|
||||
{userEmail && <Chip label={userEmail} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
{inputText && (
|
||||
<Box sx={{ maxHeight: 400, overflow: 'auto', bgcolor: 'background.default', p: 2, borderRadius: 1, mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{inputText}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{inputUrl && (
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" color="text.secondary">URL</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>{inputUrl}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Right: AI verdict summary */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>AI Verdict</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap" mb={2}>
|
||||
{riskCategory && <Chip label={riskCategory} color="primary" />}
|
||||
{severity && <Chip label={`severity: ${severity}`} variant="outlined" />}
|
||||
</Box>
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Risk Score</Typography>
|
||||
<Typography variant="h4">{riskScore ?? '—'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Confidence</Typography>
|
||||
<Typography variant="h4">{confidence ?? '—'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" color="text.secondary">Components</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap" mt={0.5}>
|
||||
{(s.components_run as string[] | null)?.map((c) => (
|
||||
<Chip key={c} label={c} size="small" color="success" variant="outlined" />
|
||||
))}
|
||||
{(s.components_skipped as string[] | null)?.map((c) => (
|
||||
<Chip key={`s-${c}`} label={c} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Queue meta */}
|
||||
<Grid size={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Queue Metadata</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(4, 1fr)" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Reason</Typography>
|
||||
<Typography variant="body2">{entry.enqueue_reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Created</Typography>
|
||||
<Typography variant="body2">{ageMinutes(entry.created_at)}m ago</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Assigned</Typography>
|
||||
<Typography variant="body2">{entry.assigned_to ?? '—'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Resolved</Typography>
|
||||
<Typography variant="body2">{entry.resolved_by ?? '—'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{entry.enqueue_meta && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="caption" color="text.secondary">Triage meta</Typography>
|
||||
<pre style={{ background: 'var(--mui-palette-background-default,#111)', color: 'var(--mui-palette-text-primary,#e0e0e0)', padding: 8, borderRadius: 4, fontSize: 12, overflow: 'auto' }}>
|
||||
{JSON.stringify(entry.enqueue_meta, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Resolve Dialog */}
|
||||
<Dialog open={resolveOpen} onClose={() => setResolveOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Resolve Queue Entry #{entry.queue_id}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<ToggleButtonGroup
|
||||
value={resolveAction}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setResolveAction(v)}
|
||||
>
|
||||
<ToggleButton value="approved">Approved (no change)</ToggleButton>
|
||||
<ToggleButton value="corrected">Corrected</ToggleButton>
|
||||
<ToggleButton value="rejected">Rejected</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{resolveAction === 'corrected' && (
|
||||
<>
|
||||
<Alert severity="info">
|
||||
Provide corrections as a JSON diff. Example:<br/>
|
||||
<code style={{ fontSize: 11 }}>
|
||||
{`{ "verdict": { "risk_score": { "from": 67, "to": 45 } }, "techniques": { "removed": ["false_dilemma"] } }`}
|
||||
</code>
|
||||
</Alert>
|
||||
<TextField
|
||||
label="Corrections (JSON)"
|
||||
value={resolveCorrections}
|
||||
onChange={(e) => setResolveCorrections(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 13 } }}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Notes (optional)"
|
||||
value={resolveNotes}
|
||||
onChange={(e) => setResolveNotes(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResolveOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleResolveSubmit} disabled={submitting}>
|
||||
{submitting ? 'Saving…' : 'Confirm Resolve'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationDetail;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, IconButton, ToggleButtonGroup, ToggleButton, CircularProgress, Alert, Button, Pagination,
|
||||
} from '@mui/material';
|
||||
import { Refresh as RefreshIcon, OpenInNew as OpenIcon, Shield as ShieldIcon } from '@mui/icons-material';
|
||||
import { listQueue, priorityLabel, priorityColor, statusColor, ageMinutes, type QueueEntry, type QueueStatus } from './api';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const ModerationQueue: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<QueueEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<QueueStatus | 'all'>('pending');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: { status?: string; priority?: string; limit: number; offset: number } = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
};
|
||||
if (statusFilter !== 'all') params.status = statusFilter;
|
||||
if (priorityFilter) params.priority = priorityFilter;
|
||||
const res = await listQueue(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data);
|
||||
setTotal(res.total ?? res.data.length);
|
||||
} else {
|
||||
setError(res.error ?? 'Failed to load queue');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, priorityFilter, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 30s when on pending tab
|
||||
useEffect(() => {
|
||||
if (statusFilter !== 'pending') return;
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [statusFilter, load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
<ShieldIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#0288d1' }} />
|
||||
Moderation Queue
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sessions flagged by triage or user reports for human review.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button variant="outlined" onClick={() => navigate('/moderation/stats')}>
|
||||
View Stats
|
||||
</Button>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box display="flex" gap={2} flexWrap="wrap" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Status</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={statusFilter}
|
||||
exclusive
|
||||
onChange={(_, v) => v && (setPage(1), setStatusFilter(v))}
|
||||
>
|
||||
<ToggleButton value="pending">Pending</ToggleButton>
|
||||
<ToggleButton value="in_review">In Review</ToggleButton>
|
||||
<ToggleButton value="resolved">Resolved</ToggleButton>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Priority</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={priorityFilter}
|
||||
exclusive
|
||||
onChange={(_, v) => (setPage(1), setPriorityFilter(v ?? ''))}
|
||||
>
|
||||
<ToggleButton value="">Any</ToggleButton>
|
||||
<ToggleButton value="1">1</ToggleButton>
|
||||
<ToggleButton value="2">2</ToggleButton>
|
||||
<ToggleButton value="3">3</ToggleButton>
|
||||
<ToggleButton value="4">4</ToggleButton>
|
||||
<ToggleButton value="5">5</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ ml: 'auto' }} color="text.secondary">
|
||||
{total} total
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
<Paper>
|
||||
{loading && items.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>
|
||||
) : items.length === 0 ? (
|
||||
<Box p={4} textAlign="center">
|
||||
<Typography color="text.secondary">No items match these filters.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Queue ID</TableCell>
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Reason</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Assigned</TableCell>
|
||||
<TableCell>Age</TableCell>
|
||||
<TableCell>Session</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((it) => (
|
||||
<TableRow key={it.queue_id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/moderation/${it.queue_id}`)}>
|
||||
<TableCell>#{it.queue_id}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={priorityLabel(it.priority)} color={priorityColor(it.priority)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={it.enqueue_reason} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={it.status} color={statusColor(it.status)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{it.assigned_to ?? '—'}</TableCell>
|
||||
<TableCell>{ageMinutes(it.created_at)}m</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{it.session_id.slice(0, 8)}…
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/moderation/${it.queue_id}`); }}>
|
||||
<OpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<Pagination count={totalPages} page={page} onChange={(_, p) => setPage(p)} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationQueue;
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Paper, Card, CardContent, Grid, CircularProgress, Alert, Button, Chip } from '@mui/material';
|
||||
import { Refresh as RefreshIcon, ArrowBack as BackIcon } from '@mui/icons-material';
|
||||
import { getStats, type QueueStats } from './api';
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
pending: '#ff9800',
|
||||
in_review: '#0288d1',
|
||||
resolved_24h: '#2e7d32',
|
||||
};
|
||||
|
||||
export const ModerationStats: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<QueueStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getStats();
|
||||
if (res.success && res.data) setStats(res.data);
|
||||
else setError(res.error ?? 'Failed to load stats');
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>Queue</Button>
|
||||
<Typography variant="h4">Moderation Stats</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{stats && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.pending}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Pending</Typography>
|
||||
<Typography variant="h2">{stats.pending}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Awaiting moderator claim</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.in_review}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">In Review</Typography>
|
||||
<Typography variant="h2">{stats.in_review}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Currently being moderated</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.resolved_24h}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Resolved (24h)</Typography>
|
||||
<Typography variant="h2">{stats.resolved_24h}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Completed in last 24h</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Open by Priority</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
{Object.keys(stats.by_priority).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No open entries.</Typography>
|
||||
) : (
|
||||
Object.entries(stats.by_priority).map(([k, v]) => (
|
||||
<Chip key={k} label={`${k}: ${v}`} color={k === 'p1' ? 'error' : k === 'p2' ? 'warning' : 'default'} />
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Avg time in queue</Typography>
|
||||
<Typography variant="h4">
|
||||
{stats.avg_time_in_queue_ms != null
|
||||
? `${(stats.avg_time_in_queue_ms / 1000 / 60).toFixed(1)}m`
|
||||
: '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
From enqueue to claim. Lower is better; spike means moderator capacity issue.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationStats;
|
||||
135
backend/admin-dashboard/src/components/Moderation/api.ts
Normal file
135
backend/admin-dashboard/src/components/Moderation/api.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* API client for Moderation pages — talks to agent-v3 via /agent-v3/api/v3/moderation/*.
|
||||
* (Nginx proxies the path; in dev/staging it goes direct.)
|
||||
*/
|
||||
|
||||
const AGENT_API = '/agent-v3/api/v3/moderation';
|
||||
|
||||
export type EnqueueReason = 'flagged' | 'low_confidence' | 'sensitive_topic' | 'mixed' | 'none';
|
||||
export type QueueStatus = 'pending' | 'in_review' | 'resolved' | 'declined' | 'auto_closed';
|
||||
export type ResolutionAction = 'approved' | 'corrected' | 'rejected';
|
||||
|
||||
export interface QueueEntry {
|
||||
queue_id: number | string;
|
||||
session_id: string;
|
||||
priority: number;
|
||||
enqueue_reason: EnqueueReason;
|
||||
enqueue_meta: Record<string, unknown> | null;
|
||||
status: QueueStatus;
|
||||
assigned_to: string | null;
|
||||
assigned_at: string | null;
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
resolution_action: ResolutionAction | null;
|
||||
time_in_queue_ms: number | null;
|
||||
time_in_review_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface QueueDetailResponse {
|
||||
queue: QueueEntry;
|
||||
session: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
pending: number;
|
||||
in_review: number;
|
||||
resolved_24h: number;
|
||||
by_priority: Record<string, number>;
|
||||
avg_time_in_queue_ms: number | null;
|
||||
}
|
||||
|
||||
export interface ResolveBody {
|
||||
action: ResolutionAction;
|
||||
corrections?: Record<string, unknown> | null;
|
||||
notes?: string | null;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
|
||||
try { return (await res.json()) as ApiResponse<T>; }
|
||||
catch { return { success: false, error: `${res.status} ${res.statusText}` }; }
|
||||
}
|
||||
|
||||
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const headers: HeadersInit = {
|
||||
...(init.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
export interface ListQueueParams {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigned_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function listQueue(params: ListQueueParams = {}): Promise<ApiResponse<QueueEntry[]>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.priority) q.set('priority', params.priority);
|
||||
if (params.assigned_to) q.set('assigned_to', params.assigned_to);
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.offset) q.set('offset', String(params.offset));
|
||||
const res = await authedFetch(`${AGENT_API}/queue?${q}`);
|
||||
return asJson<QueueEntry[]>(res);
|
||||
}
|
||||
|
||||
export async function getQueueEntry(queueId: number | string): Promise<ApiResponse<QueueDetailResponse>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}`);
|
||||
return asJson<QueueDetailResponse>(res);
|
||||
}
|
||||
|
||||
export async function claimQueueEntry(queueId: number | string, userId?: string): Promise<ApiResponse<QueueEntry>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userId ? { user_id: userId } : {}),
|
||||
});
|
||||
return asJson<QueueEntry>(res);
|
||||
}
|
||||
|
||||
export async function resolveQueueEntry(queueId: number | string, body: ResolveBody): Promise<ApiResponse<QueueEntry>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/resolve`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return asJson<QueueEntry>(res);
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<ApiResponse<QueueStats>> {
|
||||
const res = await authedFetch(`${AGENT_API}/stats`);
|
||||
return asJson<QueueStats>(res);
|
||||
}
|
||||
|
||||
// Helpers for UI
|
||||
export function priorityLabel(p: number): string {
|
||||
return ({ 1: 'Critical (flagged)', 2: 'High', 3: 'Low confidence', 4: 'Sensitive topic', 5: 'Random' } as Record<number, string>)[p] ?? `P${p}`;
|
||||
}
|
||||
|
||||
export function priorityColor(p: number): 'error' | 'warning' | 'info' | 'success' | 'default' {
|
||||
return ({ 1: 'error', 2: 'warning', 3: 'info', 4: 'info', 5: 'default' } as const)[p as 1 | 2 | 3 | 4 | 5] ?? 'default';
|
||||
}
|
||||
|
||||
export function statusColor(s: QueueStatus): 'warning' | 'info' | 'success' | 'error' | 'default' {
|
||||
return ({ pending: 'warning', in_review: 'info', resolved: 'success', declined: 'default', auto_closed: 'default' } as const)[s] ?? 'default';
|
||||
}
|
||||
|
||||
export function ageMinutes(createdAt: string): number {
|
||||
return Math.round((Date.now() - new Date(createdAt).getTime()) / 60000);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, FormGroup, Checkbox, Alert } from '@mui/material';
|
||||
import { Save as SaveIcon, Info as InfoIcon } from '@mui/icons-material';
|
||||
import type { ModerationConfig } from './api';
|
||||
import { updateModerationConfig } from './api';
|
||||
|
||||
interface Props {
|
||||
config: ModerationConfig;
|
||||
onSaved: (next: ModerationConfig) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const BrainClientCard: React.FC<Props> = ({ config, onSaved, onError }) => {
|
||||
const [draft, setDraft] = useState<ModerationConfig>(config);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { setDraft(config); }, [config]);
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateModerationConfig({
|
||||
brain_enabled: draft.brain_enabled,
|
||||
brain_url: draft.brain_url,
|
||||
brain_lookup_timeout_ms: draft.brain_lookup_timeout_ms,
|
||||
brain_write_timeout_ms: draft.brain_write_timeout_ms,
|
||||
brain_confidence_min_silver: Number(draft.brain_confidence_min_silver),
|
||||
brain_semantic_threshold: Number(draft.brain_semantic_threshold),
|
||||
brain_per_component: draft.brain_per_component,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
onSaved(res.data);
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Brain Client</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
How didi-backend talks to didi-brain. CLIENT settings only.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.brain_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, brain_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={draft.brain_enabled ? 'Enabled' : 'Disabled'}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 2 }}>
|
||||
These are <strong>client</strong> settings (how didi calls brain). Brain server config
|
||||
(TTL, embeddings, eviction) lives in the AI platform dashboard.
|
||||
</Alert>
|
||||
|
||||
{!draft.brain_enabled && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Brain is OFF. Executors run LLM normally without checking cache.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="grid" gridTemplateColumns="2fr 1fr 1fr" gap={2}>
|
||||
<TextField
|
||||
size="small" fullWidth label="Brain URL"
|
||||
value={draft.brain_url}
|
||||
onChange={(e) => setDraft({ ...draft, brain_url: e.target.value })}
|
||||
helperText="HTTP(S) endpoint of didi-brain (e.g. http://10.11.10.13:8090)"
|
||||
/>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Lookup timeout (ms)"
|
||||
value={draft.brain_lookup_timeout_ms}
|
||||
onChange={(e) => setDraft({ ...draft, brain_lookup_timeout_ms: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Write timeout (ms)"
|
||||
value={draft.brain_write_timeout_ms}
|
||||
onChange={(e) => setDraft({ ...draft, brain_write_timeout_ms: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={3}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Confidence threshold for silver write</Typography>
|
||||
<Slider
|
||||
value={Number(draft.brain_confidence_min_silver)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, brain_confidence_min_silver: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
LLM results with confidence < {draft.brain_confidence_min_silver} get bronze (not served), ≥ get silver
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Semantic match threshold (cosine distance)</Typography>
|
||||
<Slider
|
||||
value={Number(draft.brain_semantic_threshold)}
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.005}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, brain_semantic_threshold: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Lower = more strict match. Default 0.08 ≈ similarity 0.92
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography variant="body2" gutterBottom>Per-component cache opt-in</Typography>
|
||||
<FormGroup row>
|
||||
{(['techniques', 'ai_tampered', 'claims'] as const).map((comp) => (
|
||||
<FormControlLabel
|
||||
key={comp}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!draft.brain_per_component?.[comp]}
|
||||
onChange={(e) => setDraft({
|
||||
...draft,
|
||||
brain_per_component: { ...draft.brain_per_component, [comp]: e.target.checked },
|
||||
})}
|
||||
/>
|
||||
}
|
||||
label={comp.replace('_', ' ')}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Brain Client'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, Table, TableBody, TableCell, TableHead, TableRow, Chip } from '@mui/material';
|
||||
import type { ModerationRole } from './api';
|
||||
import { updateModerationRole } from './api';
|
||||
|
||||
interface Props {
|
||||
roles: ModerationRole[];
|
||||
onChanged: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const TOGGLE_FIELDS: Array<keyof ModerationRole> = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active'];
|
||||
|
||||
export const RolesCard: React.FC<Props> = ({ roles, onChanged, onError }) => {
|
||||
const toggle = async (code: string, field: keyof ModerationRole, value: boolean) => {
|
||||
try {
|
||||
const res = await updateModerationRole(code, { [field]: value } as Partial<ModerationRole>);
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Update failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={700}>Roles & Permissions</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Maps Keycloak realm roles to HIL actions. Toggle changes auto-sync to Redis.
|
||||
</Typography>
|
||||
|
||||
<Table size="small" sx={{ mt: 2 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell align="center">can_resolve</TableCell>
|
||||
<TableCell align="center">can_escalate</TableCell>
|
||||
<TableCell align="center">can_force_gold_brain</TableCell>
|
||||
<TableCell align="center">active</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.role_code} hover>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Chip label={role.role_code} size="small" color="primary" variant="outlined" />
|
||||
<Typography variant="caption" display="block" color="text.secondary" mt={0.5}>
|
||||
{role.role_label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
{TOGGLE_FIELDS.map((field) => (
|
||||
<TableCell key={field} align="center">
|
||||
<Switch
|
||||
checked={!!role[field]}
|
||||
onChange={(e) => toggle(role.role_code, field, e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Chip, TextField, Button, IconButton, Switch, FormControlLabel,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, Stack, Alert, Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Restore as RestoreIcon,
|
||||
Tune as TuneIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { SensitiveTopic, Volatility } from './api';
|
||||
import { createSensitiveTopic, deleteSensitiveTopic, updateSensitiveTopic } from './api';
|
||||
|
||||
interface Props {
|
||||
topics: SensitiveTopic[];
|
||||
onChanged: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const VOLATILITY_LABEL: Record<Volatility, string> = {
|
||||
volatile: 'Volatile',
|
||||
evolving: 'Evolving',
|
||||
stable: 'Stable',
|
||||
};
|
||||
|
||||
const VOLATILITY_COLOR: Record<Volatility, 'error' | 'warning' | 'success'> = {
|
||||
volatile: 'error',
|
||||
evolving: 'warning',
|
||||
stable: 'success',
|
||||
};
|
||||
|
||||
// Sensible per-volatility defaults to seed the form when creating new topics
|
||||
// or switching the volatility in the edit dialog.
|
||||
const VOLATILITY_DEFAULTS: Record<
|
||||
Volatility,
|
||||
{ cache_ttl_hours: number; recency_window_days: number; half_life_days: number }
|
||||
> = {
|
||||
volatile: { cache_ttl_hours: 24, recency_window_days: 7, half_life_days: 3 },
|
||||
evolving: { cache_ttl_hours: 168, recency_window_days: 14, half_life_days: 14 },
|
||||
stable: { cache_ttl_hours: 720, recency_window_days: 180, half_life_days: 180 },
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Edit volatility dialog
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EditDialogProps {
|
||||
topic: SensitiveTopic | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const EditVolatilityDialog: React.FC<EditDialogProps> = ({ topic, onClose, onSaved, onError }) => {
|
||||
const open = topic !== null;
|
||||
const initialVol: Volatility = (topic?.volatility ?? 'evolving') as Volatility;
|
||||
const [volatility, setVolatility] = useState<Volatility>(initialVol);
|
||||
const [ttl, setTtl] = useState<number>(topic?.cache_ttl_hours ?? 720);
|
||||
const [window_, setWindow] = useState<number>(topic?.recency_window_days ?? 30);
|
||||
const [halfLife, setHalfLife] = useState<number>(
|
||||
Number(topic?.half_life_days ?? 30)
|
||||
);
|
||||
const [atomicPath, setAtomicPath] = useState<string>(topic?.atomic_path_prefix ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// When the dialog re-opens for a different topic, reset state.
|
||||
React.useEffect(() => {
|
||||
if (topic) {
|
||||
setVolatility((topic.volatility ?? 'evolving') as Volatility);
|
||||
setTtl(topic.cache_ttl_hours ?? 720);
|
||||
setWindow(topic.recency_window_days ?? 30);
|
||||
setHalfLife(Number(topic.half_life_days ?? 30));
|
||||
setAtomicPath(topic.atomic_path_prefix ?? '');
|
||||
}
|
||||
}, [topic]);
|
||||
|
||||
const applyVolatilityDefaults = (v: Volatility) => {
|
||||
setVolatility(v);
|
||||
const d = VOLATILITY_DEFAULTS[v];
|
||||
setTtl(d.cache_ttl_hours);
|
||||
setWindow(d.recency_window_days);
|
||||
setHalfLife(d.half_life_days);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!topic) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmedPath = atomicPath.trim();
|
||||
const res = await updateSensitiveTopic(topic.topic_id, {
|
||||
volatility,
|
||||
cache_ttl_hours: ttl,
|
||||
recency_window_days: window_,
|
||||
half_life_days: halfLife,
|
||||
// Empty string explicitly tells the backend to clear the value (NULL).
|
||||
atomic_path_prefix: trimmedPath === '' ? '' : trimmedPath,
|
||||
});
|
||||
if (res.success) {
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Volatility config — {topic?.topic_code ?? ''}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
These values are read by didi-brain to set cache TTL, recency window, and
|
||||
age-decay half-life for content tagged with this topic. Pick a tier to seed
|
||||
sensible defaults, then fine-tune if needed.
|
||||
</Alert>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Volatility tier"
|
||||
value={volatility}
|
||||
onChange={(e) => applyVolatilityDefaults(e.target.value as Volatility)}
|
||||
>
|
||||
<MenuItem value="volatile">Volatile (war, breaking news, daily politics)</MenuItem>
|
||||
<MenuItem value="evolving">Evolving (economy, health, ongoing trials)</MenuItem>
|
||||
<MenuItem value="stable">Stable (settled science, history)</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Cache TTL (hours)"
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(Number(e.target.value))}
|
||||
inputProps={{ min: 1, max: 26280 }}
|
||||
helperText="Hard cap: brain truncates verdicts touching this topic to this many hours"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Recency window (days)"
|
||||
value={window_}
|
||||
onChange={(e) => setWindow(Number(e.target.value))}
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
helperText="For volatile topics, evidence older than this is dropped from /v1/gather"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Half-life (days)"
|
||||
value={halfLife}
|
||||
onChange={(e) => setHalfLife(Number(e.target.value))}
|
||||
inputProps={{ min: 0.1, step: 0.5 }}
|
||||
helperText="Recency boost decays with this half-life when ranking evidence"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Atomic taxonomy path prefix (optional)"
|
||||
value={atomicPath}
|
||||
onChange={(e) => setAtomicPath(e.target.value)}
|
||||
placeholder="e.g. Topics/Health/ or Topics/Politics/Elections"
|
||||
helperText={
|
||||
'Maps this policy topic to a path in the brain knowledge graph. ' +
|
||||
'Leave empty if no mapping. Used by classifier to also tag atoms ' +
|
||||
'for retrieval. No leading slash. Trailing slash is allowed.'
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Main card
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SensitiveTopicsCard: React.FC<Props> = ({ topics, onChanged, onError }) => {
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newVolatility, setNewVolatility] = useState<Volatility>('evolving');
|
||||
const [newAtomicPath, setNewAtomicPath] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<SensitiveTopic | null>(null);
|
||||
|
||||
const filtered = topics.filter((t) => showInactive || t.is_active);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newCode.trim() || !newLabel.trim()) {
|
||||
onError('Both topic_code and label are required');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const defaults = VOLATILITY_DEFAULTS[newVolatility];
|
||||
const trimmedPath = newAtomicPath.trim();
|
||||
const res = await createSensitiveTopic({
|
||||
topic_code: newCode.trim(),
|
||||
topic_label: newLabel.trim(),
|
||||
volatility: newVolatility,
|
||||
...defaults,
|
||||
...(trimmedPath ? { atomic_path_prefix: trimmedPath } : {}),
|
||||
});
|
||||
if (res.success) {
|
||||
setNewCode('');
|
||||
setNewLabel('');
|
||||
setNewVolatility('evolving');
|
||||
setNewAtomicPath('');
|
||||
onChanged();
|
||||
} else {
|
||||
onError(res.error ?? 'Create failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: number) => {
|
||||
try {
|
||||
const res = await deleteSensitiveTopic(id);
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Delete failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (id: number) => {
|
||||
try {
|
||||
const res = await updateSensitiveTopic(id, { is_active: true });
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Reactivate failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Sensitive Topics</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Topics that trigger HIL review when detected, with per-topic brain cache volatility config.
|
||||
Auto-syncs to Redis on change.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />}
|
||||
label="Show inactive"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1} my={2}>
|
||||
{filtered.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">No topics.</Typography>
|
||||
)}
|
||||
{filtered.map((t) => {
|
||||
const volatility = (t.volatility ?? 'evolving') as Volatility;
|
||||
return (
|
||||
<Box
|
||||
key={t.topic_id}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
p={1}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: t.is_active ? 'background.paper' : 'action.hover',
|
||||
opacity: t.is_active ? 1 : 0.65,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={VOLATILITY_LABEL[volatility]}
|
||||
color={VOLATILITY_COLOR[volatility]}
|
||||
sx={{ minWidth: 80 }}
|
||||
/>
|
||||
<Box flex={1} minWidth={0}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t.topic_code}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
{t.topic_label}
|
||||
</Typography>
|
||||
{t.atomic_path_prefix && (
|
||||
<Tooltip title="Maps to atomic-server taxonomy path (used by brain classifier)">
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'primary.main',
|
||||
fontSize: 11,
|
||||
mt: 0.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
↳ {t.atomic_path_prefix}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Cache TTL (hours)">
|
||||
<Chip size="small" label={`TTL ${t.cache_ttl_hours ?? '—'}h`} variant="outlined" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Recency window (days)">
|
||||
<Chip size="small" label={`${t.recency_window_days ?? '—'}d window`} variant="outlined" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit volatility config">
|
||||
<IconButton size="small" onClick={() => setEditing(t)}>
|
||||
<TuneIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{t.is_active ? (
|
||||
<Tooltip title="Deactivate">
|
||||
<IconButton size="small" onClick={() => handleDeactivate(t.topic_id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Reactivate">
|
||||
<IconButton size="small" onClick={() => handleReactivate(t.topic_id)}>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" gap={1} alignItems="flex-start" mt={2} flexWrap="wrap">
|
||||
<TextField
|
||||
size="small" label="topic_code"
|
||||
placeholder="e.g. fraud_scams"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value.toLowerCase())}
|
||||
helperText="lowercase, [a-z0-9_]"
|
||||
sx={{ width: 180 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small" label="topic_label"
|
||||
placeholder="Human readable name"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Volatility"
|
||||
value={newVolatility}
|
||||
onChange={(e) => setNewVolatility(e.target.value as Volatility)}
|
||||
sx={{ width: 140 }}
|
||||
>
|
||||
<MenuItem value="volatile">Volatile</MenuItem>
|
||||
<MenuItem value="evolving">Evolving</MenuItem>
|
||||
<MenuItem value="stable">Stable</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Atomic path (optional)"
|
||||
placeholder="Topics/Health/"
|
||||
value={newAtomicPath}
|
||||
onChange={(e) => setNewAtomicPath(e.target.value)}
|
||||
helperText="Brain taxonomy bridge"
|
||||
sx={{ minWidth: 200, flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAdd}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<EditVolatilityDialog
|
||||
topic={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, Alert } from '@mui/material';
|
||||
import { Save as SaveIcon } from '@mui/icons-material';
|
||||
import type { ModerationConfig } from './api';
|
||||
import { updateModerationConfig } from './api';
|
||||
|
||||
interface Props {
|
||||
config: ModerationConfig;
|
||||
onSaved: (next: ModerationConfig) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const TriageCard: React.FC<Props> = ({ config, onSaved, onError }) => {
|
||||
const [draft, setDraft] = useState<ModerationConfig>(config);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
React.useEffect(() => { setDraft(config); }, [config]);
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateModerationConfig({
|
||||
triage_enabled: draft.triage_enabled,
|
||||
confidence_low: Number(draft.confidence_low),
|
||||
risk_grey_min: Number(draft.risk_grey_min),
|
||||
risk_grey_max: Number(draft.risk_grey_max),
|
||||
queue_relax_at: draft.queue_relax_at,
|
||||
queue_strict_at: draft.queue_strict_at,
|
||||
auto_tune_enabled: draft.auto_tune_enabled,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
onSaved(res.data);
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Triage</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Decide which sessions enter the moderation queue. Edits sync to Redis on save.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.triage_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, triage_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={draft.triage_enabled ? 'Enabled' : 'Disabled'}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!draft.triage_enabled && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Triage is OFF. No sessions will enter the moderation queue.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={2}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Confidence threshold (low)</Typography>
|
||||
<Slider
|
||||
value={Number(draft.confidence_low)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, confidence_low: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sessions with confidence < {draft.confidence_low} go to queue
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Risk grey zone</Typography>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<TextField
|
||||
size="small" type="number" label="min"
|
||||
value={draft.risk_grey_min}
|
||||
onChange={(e) => setDraft({ ...draft, risk_grey_min: e.target.value })}
|
||||
/>
|
||||
<Typography>—</Typography>
|
||||
<TextField
|
||||
size="small" type="number" label="max"
|
||||
value={draft.risk_grey_max}
|
||||
onChange={(e) => setDraft({ ...draft, risk_grey_max: e.target.value })}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sensitive topics with risk in this band go to queue
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Auto-relax at pending"
|
||||
helperText="If queue ≥ this many pending, drop topic filter"
|
||||
value={draft.queue_relax_at}
|
||||
onChange={(e) => setDraft({ ...draft, queue_relax_at: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Auto-strict at pending"
|
||||
helperText="If queue ≤ this many pending, broaden triage"
|
||||
value={draft.queue_strict_at}
|
||||
onChange={(e) => setDraft({ ...draft, queue_strict_at: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.auto_tune_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, auto_tune_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Auto-tune thresholds (cron)"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Triage'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
171
backend/admin-dashboard/src/components/ModerationSettings/api.ts
Normal file
171
backend/admin-dashboard/src/components/ModerationSettings/api.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* API client for Moderation Settings — talks to didiFramework on /framework/api/*.
|
||||
* All endpoints from migration 011 + Phase 1.2 routes.
|
||||
*/
|
||||
|
||||
const FRAMEWORK_API = '/framework/api';
|
||||
|
||||
export interface ModerationConfig {
|
||||
config_id: number;
|
||||
triage_enabled: boolean;
|
||||
confidence_low: string | number;
|
||||
risk_grey_min: string | number;
|
||||
risk_grey_max: string | number;
|
||||
queue_relax_at: number;
|
||||
queue_strict_at: number;
|
||||
auto_tune_enabled: boolean;
|
||||
brain_enabled: boolean;
|
||||
brain_url: string;
|
||||
brain_lookup_timeout_ms: number;
|
||||
brain_write_timeout_ms: number;
|
||||
brain_confidence_min_silver: string | number;
|
||||
brain_semantic_threshold: string | number;
|
||||
brain_per_component: { techniques: boolean; ai_tampered: boolean; claims: boolean };
|
||||
updated_by: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type Volatility = 'volatile' | 'evolving' | 'stable';
|
||||
|
||||
export interface SensitiveTopic {
|
||||
topic_id: number;
|
||||
topic_code: string;
|
||||
topic_label: string;
|
||||
is_active: boolean;
|
||||
// Phase D1 — drives brain cache TTL + recency boost.
|
||||
// Optional in TS so legacy callers / older API responses still type-check;
|
||||
// backend always returns them after migration 012.
|
||||
volatility?: Volatility;
|
||||
cache_ttl_hours?: number;
|
||||
recency_window_days?: number;
|
||||
half_life_days?: number | string; // numeric column comes back as string
|
||||
// Migration 014 — bridge to atomic-server taxonomy. Optional, NULL when no
|
||||
// mapping is defined. Format: "Topics/Health/" or "Topics/Politics/Elections".
|
||||
atomic_path_prefix?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SensitiveTopicVolatilityPatch {
|
||||
volatility?: Volatility;
|
||||
cache_ttl_hours?: number;
|
||||
recency_window_days?: number;
|
||||
half_life_days?: number;
|
||||
// Empty string clears the value (server maps '' → NULL).
|
||||
atomic_path_prefix?: string | null;
|
||||
}
|
||||
|
||||
export interface ModerationRole {
|
||||
role_code: string;
|
||||
role_label: string;
|
||||
can_resolve: boolean;
|
||||
can_escalate: boolean;
|
||||
can_force_gold_brain: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
return { success: false, error: `${res.status} ${res.statusText}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const headers: HeadersInit = {
|
||||
...(init.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
// ─── moderation-config ───
|
||||
export async function getModerationConfig(): Promise<ApiResponse<ModerationConfig>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`);
|
||||
return asJson<ModerationConfig>(res);
|
||||
}
|
||||
|
||||
export async function updateModerationConfig(patch: Partial<ModerationConfig>): Promise<ApiResponse<ModerationConfig>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<ModerationConfig>(res);
|
||||
}
|
||||
|
||||
// ─── sensitive-topics ───
|
||||
export async function listSensitiveTopics(active: 'true' | 'false' | 'all' = 'all'): Promise<ApiResponse<SensitiveTopic[]>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics?active=${active}`);
|
||||
return asJson<SensitiveTopic[]>(res);
|
||||
}
|
||||
|
||||
export async function createSensitiveTopic(
|
||||
body: {
|
||||
topic_code: string;
|
||||
topic_label: string;
|
||||
is_active?: boolean;
|
||||
} & SensitiveTopicVolatilityPatch
|
||||
): Promise<ApiResponse<SensitiveTopic>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return asJson<SensitiveTopic>(res);
|
||||
}
|
||||
|
||||
export async function updateSensitiveTopic(
|
||||
id: number,
|
||||
patch: {
|
||||
topic_label?: string;
|
||||
is_active?: boolean;
|
||||
} & SensitiveTopicVolatilityPatch
|
||||
): Promise<ApiResponse<SensitiveTopic>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<SensitiveTopic>(res);
|
||||
}
|
||||
|
||||
export async function deleteSensitiveTopic(id: number): Promise<ApiResponse<void>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, { method: 'DELETE' });
|
||||
return asJson<void>(res);
|
||||
}
|
||||
|
||||
// ─── moderation-roles ───
|
||||
export async function listModerationRoles(): Promise<ApiResponse<ModerationRole[]>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles`);
|
||||
return asJson<ModerationRole[]>(res);
|
||||
}
|
||||
|
||||
export async function updateModerationRole(code: string, patch: Partial<ModerationRole>): Promise<ApiResponse<ModerationRole>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles/${encodeURIComponent(code)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<ModerationRole>(res);
|
||||
}
|
||||
|
||||
// ─── sync-redis ───
|
||||
export async function syncRedis(): Promise<ApiResponse<{ keys_written: number }>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sync-redis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return asJson<{ keys_written: number }>(res);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Alert, CircularProgress, Snackbar, Button } from '@mui/material';
|
||||
import { Refresh as RefreshIcon, CloudSync as SyncIcon } from '@mui/icons-material';
|
||||
import {
|
||||
getModerationConfig,
|
||||
listSensitiveTopics,
|
||||
listModerationRoles,
|
||||
syncRedis,
|
||||
type ModerationConfig,
|
||||
type SensitiveTopic,
|
||||
type ModerationRole,
|
||||
} from './api';
|
||||
import { TriageCard } from './TriageCard';
|
||||
import { BrainClientCard } from './BrainClientCard';
|
||||
import { SensitiveTopicsCard } from './SensitiveTopicsCard';
|
||||
import { RolesCard } from './RolesCard';
|
||||
|
||||
export const ModerationSettings: React.FC = () => {
|
||||
const [config, setConfig] = useState<ModerationConfig | null>(null);
|
||||
const [topics, setTopics] = useState<SensitiveTopic[]>([]);
|
||||
const [roles, setRoles] = useState<ModerationRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cfgRes, topRes, rolRes] = await Promise.all([
|
||||
getModerationConfig(),
|
||||
listSensitiveTopics('all'),
|
||||
listModerationRoles(),
|
||||
]);
|
||||
if (cfgRes.success && cfgRes.data) setConfig(cfgRes.data);
|
||||
else setError(cfgRes.error ?? 'Failed to load config');
|
||||
if (topRes.success && topRes.data) setTopics(topRes.data);
|
||||
if (rolRes.success && rolRes.data) setRoles(rolRes.data);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); }, [loadAll]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await syncRedis();
|
||||
if (res.success) {
|
||||
setSuccess(`Synced to Redis (${res.data?.keys_written ?? '?'} keys)`);
|
||||
} else {
|
||||
setError(res.error ?? 'Sync failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigSaved = async (next: ModerationConfig) => {
|
||||
setConfig(next);
|
||||
setSuccess('Config saved. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
const handleTopicsChanged = async () => {
|
||||
const res = await listSensitiveTopics('all');
|
||||
if (res.success && res.data) setTopics(res.data);
|
||||
setSuccess('Topics updated. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
const handleRolesChanged = async () => {
|
||||
const res = await listModerationRoles();
|
||||
if (res.success && res.data) setRoles(res.data);
|
||||
setSuccess('Roles updated. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !config) {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Alert severity="error" action={<Button onClick={loadAll}>Retry</Button>}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: '#0288d1' }}>
|
||||
Moderation Settings
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Triage rules + brain client + sensitive topics + roles. All settings persist in PG and sync to Redis.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={loadAll} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? 'Syncing…' : 'Sync to Redis'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<TriageCard config={config} onSaved={handleConfigSaved} onError={setError} />
|
||||
<BrainClientCard config={config} onSaved={handleConfigSaved} onError={setError} />
|
||||
<SensitiveTopicsCard topics={topics} onChanged={handleTopicsChanged} onError={setError} />
|
||||
<RolesCard roles={roles} onChanged={handleRolesChanged} onError={setError} />
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={!!success}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccess(null)}
|
||||
message={success}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationSettings;
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Dry-run dialog — resolves & shows the execution plan for a pipeline without
|
||||
* dispatching (Run Console-lite: "ce ar rula", no credits consumed). Also
|
||||
* offers a real async run + cancel so the operator can exercise INT-3 live.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography,
|
||||
Select, MenuItem, TextField, Chip, CircularProgress, Alert, Paper, Divider,
|
||||
Stack, Table, TableBody, TableCell, TableRow,
|
||||
} from '@mui/material';
|
||||
import { pipelinesApi, DryRunResult, PipelineProfile } from './pipelinesApi';
|
||||
|
||||
const MEDIA_TYPES = ['text', 'url', 'image', 'audio', 'video'];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
profile: PipelineProfile | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Map a pipeline profile_code to the media_type its dry-run should use.
|
||||
function defaultMediaType(code: string): string {
|
||||
if (code.startsWith('text')) return 'text';
|
||||
if (['image', 'audio', 'video', 'url'].includes(code)) return code;
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export const DryRunDialog: React.FC<Props> = ({ open, profile, onClose }) => {
|
||||
const [mediaType, setMediaType] = useState('text');
|
||||
const [text, setText] = useState('Guvernul ascunde adevărul despre acest subiect, conform unor surse anonime neverificate.');
|
||||
const [planType, setPlanType] = useState(1);
|
||||
const [result, setResult] = useState<DryRunResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (profile) { setMediaType(defaultMediaType(profile.profile_code)); setResult(null); setError(null); }
|
||||
}, [profile]);
|
||||
|
||||
const runDryRun = async () => {
|
||||
setLoading(true); setError(null); setResult(null);
|
||||
try {
|
||||
const r = await pipelinesApi.dryRun({
|
||||
media_type: mediaType,
|
||||
text: mediaType === 'text' || mediaType === 'url' ? text : undefined,
|
||||
url: mediaType === 'url' ? 'https://example.com/article' : undefined,
|
||||
media_url: ['image', 'audio', 'video'].includes(mediaType) ? 'https://example.com/media' : undefined,
|
||||
user_id: 'dashboard-dryrun',
|
||||
plan_type: planType,
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
Dry-run — {profile?.profile_name}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Rezolvă planul de execuție fără dispatch (zero credite consumate)
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Select size="small" value={mediaType} onChange={e => setMediaType(e.target.value)}>
|
||||
{MEDIA_TYPES.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
<Select size="small" value={planType} onChange={e => setPlanType(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6].map(p => <MenuItem key={p} value={p}>plan {p}</MenuItem>)}
|
||||
</Select>
|
||||
<Button variant="contained" onClick={runDryRun} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : 'Resolve plan'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(mediaType === 'text' || mediaType === 'url') && (
|
||||
<TextField
|
||||
fullWidth multiline minRows={2} size="small" sx={{ mb: 2 }}
|
||||
label="Sample text" value={text} onChange={e => setText(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="overline" color="text.secondary">Flow</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{result.flow}</Typography>
|
||||
</Paper>
|
||||
|
||||
{result.verdict_profile && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Verdict profile: {result.verdict_profile.profile_code}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5 }}>
|
||||
{Object.entries(result.verdict_profile.weights).map(([k, v]) => (
|
||||
<Chip key={k} size="small" label={`${k}: ${v}`} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Typography variant="overline" color="text.secondary">Nodes ({result.nodes.length})</Typography>
|
||||
{result.nodes.map(n => (
|
||||
<Paper key={n.component} variant="outlined" sx={{ p: 1.5, mb: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Chip size="small" color="primary" label={n.component} />
|
||||
{n.queue && <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{n.queue}</Typography>}
|
||||
{n.timeout_ms && <Chip size="small" variant="outlined" label={`${n.timeout_ms / 1000}s`} />}
|
||||
</Stack>
|
||||
{n.depends_on && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
depends_on: {n.depends_on.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
{n.produces && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{n.produces.map(p => <Chip key={p} size="small" variant="outlined" label={p} sx={{ mr: 0.5, mb: 0.5 }} />)}
|
||||
</Box>
|
||||
)}
|
||||
{n.stages && Object.entries(n.stages).map(([stage, tiers]) => (
|
||||
<Box key={stage} sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{stage}</Typography>
|
||||
{Object.entries(tiers).map(([tier, models]) => (
|
||||
<Table key={tier} size="small" sx={{ mb: 0.5 }}>
|
||||
<TableBody>
|
||||
{models.map(m => (
|
||||
<TableRow key={`${tier}-${m.order}`}>
|
||||
<TableCell sx={{ py: 0.2, border: 0, width: 60 }}>
|
||||
<Typography variant="caption" color="text.secondary">{tier}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.2, border: 0, width: 80 }}>
|
||||
<Typography variant="caption">{m.role}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.2, border: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{m.model_key}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{result.skipped.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="overline" color="text.secondary">Skipped</Typography>
|
||||
{result.skipped.map(s => (
|
||||
<Typography key={s.component} variant="caption" display="block" color="text.secondary">
|
||||
{s.component} — {s.reason}
|
||||
</Typography>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>{result.note}</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Pipelines page — CRUD + lifecycle over the REAL pipeline definitions
|
||||
* (input_type_profile) plus dry-run (Run Console-lite). Modul 1 caiet:
|
||||
* „Workflow Builder: CRUD/versionare pipeline; Run Console".
|
||||
*
|
||||
* Backed by /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run.
|
||||
* NOT the dead pipelineApi (services/api/pipeline.ts).
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableHead, TableRow, TableCell, TableBody,
|
||||
Chip, IconButton, Button, Stack, CircularProgress, Alert, Tooltip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField, Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as DryRunIcon,
|
||||
ContentCopy as CloneIcon,
|
||||
ToggleOn as ActivateIcon,
|
||||
ToggleOff as DeactivateIcon,
|
||||
History as VersionsIcon,
|
||||
Restore as RestoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Download as ExportIcon,
|
||||
Upload as ImportIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { pipelinesApi, PipelineProfile, PipelineVersion } from './pipelinesApi';
|
||||
import { DryRunDialog } from './DryRunDialog';
|
||||
|
||||
export const PipelinesPage: React.FC = () => {
|
||||
const [profiles, setProfiles] = useState<PipelineProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const [dryRunProfile, setDryRunProfile] = useState<PipelineProfile | null>(null);
|
||||
const [cloneSource, setCloneSource] = useState<PipelineProfile | null>(null);
|
||||
const [cloneCode, setCloneCode] = useState('');
|
||||
const [cloneName, setCloneName] = useState('');
|
||||
const [versionsFor, setVersionsFor] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<PipelineVersion[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
setProfiles(await pipelinesApi.list());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const toggleActive = async (p: PipelineProfile) => {
|
||||
setBusy(p.profile_code);
|
||||
try {
|
||||
if (p.is_active) await pipelinesApi.deactivate(p.profile_code);
|
||||
else await pipelinesApi.activate(p.profile_code);
|
||||
await load();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const doClone = async () => {
|
||||
if (!cloneSource || !cloneCode) return;
|
||||
setBusy(cloneSource.profile_code);
|
||||
try {
|
||||
await pipelinesApi.clone(cloneSource.profile_code, cloneCode, cloneName || undefined);
|
||||
setCloneSource(null); setCloneCode(''); setCloneName('');
|
||||
await load();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const showVersions = async (code: string) => {
|
||||
if (versionsFor === code) { setVersionsFor(null); return; }
|
||||
try {
|
||||
setVersions(await pipelinesApi.versions(code));
|
||||
setVersionsFor(code);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
const restore = async (code: string, versionId: number) => {
|
||||
setBusy(code);
|
||||
try {
|
||||
await pipelinesApi.restore(code, versionId);
|
||||
await load();
|
||||
setVersions(await pipelinesApi.versions(code));
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const exportProfile = async (code: string) => {
|
||||
try {
|
||||
const full = await pipelinesApi.get(code); // includes overrides
|
||||
const blob = new Blob([JSON.stringify(full, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `pipeline-${code}.json`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
const importProfile = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result));
|
||||
// Prefix new_code to avoid clashing with the source it was exported from.
|
||||
const base = parsed.profile_code || 'imported';
|
||||
await pipelinesApi.import({ ...parsed, new_code: `${base}_import` });
|
||||
await load();
|
||||
} catch (e) { setError(`Import failed: ${(e as Error).message}`); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>Pipelines</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Definiții de pipeline (componente, ponderi, praguri per tip de input) — clonare, versionare, activare, dry-run
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
component="label"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ImportIcon />}
|
||||
>
|
||||
Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
hidden
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) importProfile(f); e.target.value = ''; }}
|
||||
/>
|
||||
</Button>
|
||||
<IconButton onClick={load}><RefreshIcon /></IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<Paper variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Pipeline</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Weights (tech / claims / ai / source)</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{profiles.map(p => (
|
||||
<React.Fragment key={p.profile_code}>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.profile_name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }} color="text.secondary">{p.profile_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" color={p.is_active ? 'success' : 'default'} label={p.is_active ? 'active' : 'inactive'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{p.weight_techniques} / {p.weight_claims} / {p.weight_ai_tampered} / {p.weight_source}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Dry-run (resolve plan)">
|
||||
<IconButton size="small" onClick={() => setDryRunProfile(p)}><DryRunIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Clone">
|
||||
<IconButton size="small" onClick={() => { setCloneSource(p); setCloneCode(`${p.profile_code}_copy`); }}><CloneIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={p.is_active ? 'Deactivate' : 'Activate'}>
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => toggleActive(p)}>
|
||||
{p.is_active ? <DeactivateIcon /> : <ActivateIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Export JSON">
|
||||
<IconButton size="small" onClick={() => exportProfile(p.profile_code)}><ExportIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Version history">
|
||||
<IconButton size="small" onClick={() => showVersions(p.profile_code)}><VersionsIcon /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ p: 0, border: 0 }}>
|
||||
<Collapse in={versionsFor === p.profile_code} unmountOnExit>
|
||||
<Box sx={{ p: 2, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="overline" color="text.secondary">Version history</Typography>
|
||||
{versions.length === 0 && <Typography variant="body2" color="text.secondary">No versions yet</Typography>}
|
||||
{versions.map(v => (
|
||||
<Stack key={v.version_id} direction="row" spacing={2} alignItems="center" sx={{ py: 0.5 }}>
|
||||
<Chip size="small" label={`v${v.version_no}`} />
|
||||
<Typography variant="body2">{v.change_note}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(v.changed_at).toLocaleString()} {v.changed_by ? `· ${v.changed_by}` : ''}
|
||||
</Typography>
|
||||
<Tooltip title="Restore this version">
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => restore(p.profile_code, v.version_id)}>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<DryRunDialog open={!!dryRunProfile} profile={dryRunProfile} onClose={() => setDryRunProfile(null)} />
|
||||
|
||||
<Dialog open={!!cloneSource} onClose={() => setCloneSource(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Clone pipeline</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
From <b>{cloneSource?.profile_code}</b>. The clone starts inactive.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth size="small" label="New code" sx={{ mb: 2 }}
|
||||
value={cloneCode} onChange={e => setCloneCode(e.target.value)}
|
||||
helperText="lowercase, [a-z0-9_-]"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth size="small" label="New name (optional)"
|
||||
value={cloneName} onChange={e => setCloneName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCloneSource(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={doClone} disabled={!cloneCode || busy === cloneSource?.profile_code}>Clone</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipelinesPage;
|
||||
115
backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts
Normal file
115
backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Pipelines API client — talks to the REAL endpoints:
|
||||
* - didiFramework /framework/api/pipelines/* (input_type_profile = pipeline definition)
|
||||
* - agent-v3 /agent-v3/api/v3/pipeline/dry-run and /:id/cancel
|
||||
*
|
||||
* NOT the dead pipelineApi (services/api/pipeline.ts → /api/v1/pipelines,
|
||||
* which has no backend). Everything here is backed by live routes.
|
||||
*/
|
||||
const FW = '/framework/api/pipelines';
|
||||
const AGENT = '/agent-v3/api/v3/pipeline';
|
||||
|
||||
function authHeader(): Record<string, string> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function req<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader(), ...(init?.headers || {}) },
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body?.success === false) {
|
||||
throw new Error(body?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface PipelineProfile {
|
||||
profile_code: string;
|
||||
profile_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
weight_techniques: number;
|
||||
weight_claims: number;
|
||||
weight_ai_tampered: number;
|
||||
weight_source: number;
|
||||
min_components: number;
|
||||
override_cap: number;
|
||||
overrides?: unknown[];
|
||||
}
|
||||
|
||||
export interface PipelineVersion {
|
||||
version_id: number;
|
||||
version_no: number;
|
||||
changed_at: string;
|
||||
changed_by: string | null;
|
||||
change_note: string;
|
||||
}
|
||||
|
||||
export interface DryRunResult {
|
||||
media_type: string;
|
||||
plan_type: number;
|
||||
flow: string;
|
||||
results_queue: string;
|
||||
nodes: Array<{
|
||||
component: string;
|
||||
queue?: string;
|
||||
priority?: number;
|
||||
timeout_ms?: number;
|
||||
depends_on?: string[];
|
||||
produces?: string[];
|
||||
stages?: Record<string, Record<string, Array<{ order: number; role: string; model_key: string; provider: string; timeout_ms?: number }>>>;
|
||||
prompt_config_keys?: string[];
|
||||
}>;
|
||||
skipped: Array<{ component: string; reason: string }>;
|
||||
verdict_profile: {
|
||||
profile_code: string;
|
||||
profile_name: string;
|
||||
weights: Record<string, number>;
|
||||
min_components: number;
|
||||
override_cap: number;
|
||||
} | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const pipelinesApi = {
|
||||
list: () => req<{ data: PipelineProfile[]; count: number }>(FW).then(r => r.data),
|
||||
|
||||
get: (code: string) => req<{ data: PipelineProfile }>(`${FW}/${code}`).then(r => r.data),
|
||||
|
||||
clone: (code: string, new_code: string, new_name?: string) =>
|
||||
req<{ data: PipelineProfile }>(`${FW}/${code}/clone`, {
|
||||
method: 'POST', body: JSON.stringify({ new_code, new_name }),
|
||||
}).then(r => r.data),
|
||||
|
||||
activate: (code: string) => req(`${FW}/${code}/activate`, { method: 'POST' }),
|
||||
deactivate: (code: string) => req(`${FW}/${code}/deactivate`, { method: 'POST' }),
|
||||
|
||||
update: (code: string, patch: Partial<PipelineProfile> & { change_note?: string }) =>
|
||||
req<{ data: PipelineProfile; version_saved: number }>(`${FW}/${code}`, {
|
||||
method: 'PUT', body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
versions: (code: string) =>
|
||||
req<{ data: PipelineVersion[] }>(`${FW}/${code}/versions`).then(r => r.data),
|
||||
|
||||
restore: (code: string, versionId: number) =>
|
||||
req(`${FW}/${code}/versions/${versionId}/restore`, { method: 'POST' }),
|
||||
|
||||
dryRun: (body: { media_type: string; text?: string; url?: string; media_url?: string; user_id?: string; plan_type?: number; options?: unknown }) =>
|
||||
req<{ data: DryRunResult }>(`${AGENT}/dry-run`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}).then(r => r.data),
|
||||
|
||||
cancel: (sessionId: string, user_id?: string) =>
|
||||
req(`${AGENT}/${sessionId}/cancel`, {
|
||||
method: 'POST', body: JSON.stringify({ user_id }),
|
||||
}),
|
||||
|
||||
import: (payload: unknown) =>
|
||||
req<{ data: PipelineProfile }>(`${FW}/import`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
}).then(r => r.data),
|
||||
};
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* LLM Providers Management - Full CRUD
|
||||
*
|
||||
* Manages LLM providers, models, component assignments, and API keys.
|
||||
* Integrated with PostgreSQL backend via /framework/api/providers/* endpoints.
|
||||
*
|
||||
* Original 1159-line file split into:
|
||||
* ProvidersManagement.types.ts — domain types + TabPanel + API_BASE
|
||||
* api.ts — 13 fetch wrappers
|
||||
* tabs/{Providers,Models,Assignments,ApiKeys}Tab.tsx — 4 entity tables
|
||||
* dialogs/{Provider,Model,Assignment,ApiKey,DeleteConfirm}Dialog.tsx — 5 dialogs
|
||||
* ProvidersManagement.tsx (this) — state + handlers + render orchestrator
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Container, Paper, Typography, Tabs, Tab, Button, CircularProgress, Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Key as KeyIcon, Memory as ModelIcon,
|
||||
Assignment as AssignmentIcon, CloudQueue as ProviderIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
|
||||
TabPanel,
|
||||
} from './ProvidersManagement.types';
|
||||
import {
|
||||
fetchAll,
|
||||
createProvider, updateProvider, deleteProvider,
|
||||
createModel, updateModel, deleteModel,
|
||||
createAssignment, updateAssignment, deleteAssignment,
|
||||
createApiKey, updateApiKey, deleteApiKey,
|
||||
testProvider,
|
||||
} from './api';
|
||||
import { ProvidersTab } from './tabs/ProvidersTab';
|
||||
import { ModelsTab } from './tabs/ModelsTab';
|
||||
import { AssignmentsTab } from './tabs/AssignmentsTab';
|
||||
import { ApiKeysTab } from './tabs/ApiKeysTab';
|
||||
import { ProviderDialog } from './dialogs/ProviderDialog';
|
||||
import { ModelDialog } from './dialogs/ModelDialog';
|
||||
import { AssignmentDialog } from './dialogs/AssignmentDialog';
|
||||
import { ApiKeyDialog } from './dialogs/ApiKeyDialog';
|
||||
import { DeleteConfirmDialog } from './dialogs/DeleteConfirmDialog';
|
||||
|
||||
export const ProvidersManagement: React.FC = () => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [providers, setProviders] = useState<LlmProvider[]>([]);
|
||||
const [models, setModels] = useState<LlmModel[]>([]);
|
||||
const [assignments, setAssignments] = useState<ComponentAssignment[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
|
||||
// Dialog states
|
||||
const [providerDialog, setProviderDialog] = useState<{ open: boolean; data: Partial<LlmProvider> | null }>({ open: false, data: null });
|
||||
const [modelDialog, setModelDialog] = useState<{ open: boolean; data: Partial<LlmModel> | null }>({ open: false, data: null });
|
||||
const [assignmentDialog, setAssignmentDialog] = useState<{ open: boolean; data: Partial<ComponentAssignment> | null }>({ open: false, data: null });
|
||||
const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; data: Partial<ApiKey & { api_key_value?: string }> | null }>({ open: false, data: null });
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; type: string; id: number; name: string } | null>(null);
|
||||
|
||||
// Test connection state
|
||||
const [testingProvider, setTestingProvider] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ providerId: number; success: boolean; latencyMs?: number; error?: string } | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchAll();
|
||||
if (data.success) {
|
||||
setProviders(data.data.providers || []);
|
||||
setModels(data.data.models || []);
|
||||
setAssignments(data.data.assignments || []);
|
||||
setApiKeys(data.data.keys || []);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch data');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const showSuccess = (message: string) => {
|
||||
setSuccess(message);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
};
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 5000);
|
||||
};
|
||||
|
||||
// ─── Provider CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveProvider = async () => {
|
||||
const data = providerDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'provider_id' in data && data.provider_id;
|
||||
const result = isEdit
|
||||
? await updateProvider(data.provider_id as number, data)
|
||||
: await createProvider(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Provider updated successfully' : 'Provider created successfully');
|
||||
setProviderDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'provider') return;
|
||||
try {
|
||||
const result = await deleteProvider(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Provider deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleProviderActive = async (provider: LlmProvider) => {
|
||||
try {
|
||||
const result = await updateProvider(provider.provider_id, { is_active: !provider.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestProvider = async (providerId: number) => {
|
||||
setTestingProvider(providerId);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testProvider(providerId);
|
||||
setTestResult({
|
||||
providerId,
|
||||
success: result.data?.success || false,
|
||||
latencyMs: result.data?.latencyMs,
|
||||
error: result.data?.error,
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
providerId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Test failed',
|
||||
});
|
||||
} finally {
|
||||
setTestingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Model CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
const data = modelDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'model_id' in data && data.model_id;
|
||||
const result = isEdit
|
||||
? await updateModel(data.model_id as number, data)
|
||||
: await createModel(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Model updated successfully' : 'Model created successfully');
|
||||
setModelDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'model') return;
|
||||
try {
|
||||
const result = await deleteModel(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Model deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleModelActive = async (model: LlmModel) => {
|
||||
try {
|
||||
const result = await updateModel(model.model_id, { is_active: !model.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Assignment CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveAssignment = async () => {
|
||||
const data = assignmentDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'assignment_id' in data && data.assignment_id;
|
||||
const result = isEdit
|
||||
? await updateAssignment(data.assignment_id as number, data)
|
||||
: await createAssignment(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Assignment updated successfully' : 'Assignment created successfully');
|
||||
setAssignmentDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAssignment = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'assignment') return;
|
||||
try {
|
||||
const result = await deleteAssignment(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Assignment deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAssignmentEnabled = async (assignment: ComponentAssignment) => {
|
||||
try {
|
||||
const result = await updateAssignment(assignment.assignment_id, { is_enabled: !assignment.is_enabled });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── API Key CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveApiKey = async () => {
|
||||
const data = apiKeyDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'api_key_id' in data && data.api_key_id;
|
||||
const result = isEdit
|
||||
? await updateApiKey(data.api_key_id as number, data)
|
||||
: await createApiKey(data as { provider_id: number; key_name: string; api_key_value: string });
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'API Key updated successfully' : 'API Key created successfully');
|
||||
setApiKeyDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'apikey') return;
|
||||
try {
|
||||
const result = await deleteApiKey(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('API Key deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleApiKeyActive = async (key: ApiKey) => {
|
||||
try {
|
||||
const result = await updateApiKey(key.api_key_id, { is_active: !key.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Dispatcher: which delete handler runs based on deleteDialog.type ────
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteDialog) return;
|
||||
switch (deleteDialog.type) {
|
||||
case 'provider': return handleDeleteProvider();
|
||||
case 'model': return handleDeleteModel();
|
||||
case 'assignment': return handleDeleteAssignment();
|
||||
case 'apikey': return handleDeleteApiKey();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>LLM Providers</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage LLM providers, models, assignments, and API keys
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={fetchData} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{loading && providers.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Tabs value={tabValue} onChange={(_e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab icon={<ProviderIcon />} label={`Providers (${providers.length})`} iconPosition="start" />
|
||||
<Tab icon={<ModelIcon />} label={`Models (${models.length})`} iconPosition="start" />
|
||||
<Tab icon={<AssignmentIcon />} label={`Assignments (${assignments.length})`} iconPosition="start" />
|
||||
<Tab icon={<KeyIcon />} label={`API Keys (${apiKeys.length})`} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<ProvidersTab
|
||||
providers={providers}
|
||||
testingProvider={testingProvider}
|
||||
testResult={testResult}
|
||||
onAdd={() => setProviderDialog({
|
||||
open: true,
|
||||
data: { provider_code: '', provider_name: '', base_url: '', auth_type: 'bearer', is_active: true, priority: 100, rate_limit_rpm: 60, rate_limit_tpm: 100000, description: '' },
|
||||
})}
|
||||
onEdit={(p) => setProviderDialog({ open: true, data: p })}
|
||||
onDelete={(p) => setDeleteDialog({ open: true, type: 'provider', id: p.provider_id, name: p.provider_name })}
|
||||
onToggleActive={handleToggleProviderActive}
|
||||
onTest={handleTestProvider}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<ModelsTab
|
||||
models={models}
|
||||
hasProviders={providers.length > 0}
|
||||
onAdd={() => setModelDialog({
|
||||
open: true,
|
||||
data: { provider_id: providers[0]?.provider_id, model_code: '', model_name: '', context_window: 32000, max_output_tokens: 4096, input_cost_per_1m: 0, output_cost_per_1m: 0, supports_streaming: true, supports_tools: true, supports_vision: false, is_active: true, description: '' },
|
||||
})}
|
||||
onEdit={(m) => setModelDialog({ open: true, data: m })}
|
||||
onDelete={(m) => setDeleteDialog({ open: true, type: 'model', id: m.model_id, name: m.model_name })}
|
||||
onToggleActive={handleToggleModelActive}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<AssignmentsTab
|
||||
assignments={assignments}
|
||||
hasProvidersAndModels={providers.length > 0 && models.length > 0}
|
||||
onAdd={() => setAssignmentDialog({
|
||||
open: true,
|
||||
data: { component_code: '', component_name: '', provider_id: providers[0]?.provider_id, model_id: models[0]?.model_id, temperature: 0.3, max_tokens: 4096, timeout_ms: 120000, is_enabled: true, description: '' },
|
||||
})}
|
||||
onEdit={(a) => setAssignmentDialog({ open: true, data: a })}
|
||||
onDelete={(a) => setDeleteDialog({ open: true, type: 'assignment', id: a.assignment_id, name: a.component_name })}
|
||||
onToggleEnabled={handleToggleAssignmentEnabled}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<ApiKeysTab
|
||||
apiKeys={apiKeys}
|
||||
hasProviders={providers.length > 0}
|
||||
onAdd={() => setApiKeyDialog({
|
||||
open: true,
|
||||
data: { provider_id: providers[0]?.provider_id, key_name: '', api_key_value: '', is_active: true },
|
||||
})}
|
||||
onEdit={(k) => setApiKeyDialog({ open: true, data: k })}
|
||||
onDelete={(k) => setDeleteDialog({ open: true, type: 'apikey', id: k.api_key_id, name: k.key_name })}
|
||||
onToggleActive={handleToggleApiKeyActive}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<ProviderDialog
|
||||
open={providerDialog.open}
|
||||
data={providerDialog.data}
|
||||
onClose={() => setProviderDialog({ open: false, data: null })}
|
||||
onChange={(data) => setProviderDialog({ open: true, data })}
|
||||
onSave={handleSaveProvider}
|
||||
/>
|
||||
|
||||
<ModelDialog
|
||||
open={modelDialog.open}
|
||||
data={modelDialog.data}
|
||||
providers={providers}
|
||||
onClose={() => setModelDialog({ open: false, data: null })}
|
||||
onChange={(data) => setModelDialog({ open: true, data })}
|
||||
onSave={handleSaveModel}
|
||||
/>
|
||||
|
||||
<AssignmentDialog
|
||||
open={assignmentDialog.open}
|
||||
data={assignmentDialog.data}
|
||||
providers={providers}
|
||||
models={models}
|
||||
onClose={() => setAssignmentDialog({ open: false, data: null })}
|
||||
onChange={(data) => setAssignmentDialog({ open: true, data })}
|
||||
onSave={handleSaveAssignment}
|
||||
/>
|
||||
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialog.open}
|
||||
data={apiKeyDialog.data}
|
||||
providers={providers}
|
||||
onClose={() => setApiKeyDialog({ open: false, data: null })}
|
||||
onChange={(data) => setApiKeyDialog({ open: true, data })}
|
||||
onSave={handleSaveApiKey}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteDialog}
|
||||
name={deleteDialog?.name ?? ''}
|
||||
onCancel={() => setDeleteDialog(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProvidersManagement;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Domain types for the Providers Management page + a small TabPanel utility.
|
||||
*
|
||||
* All four entities (Provider, Model, ComponentAssignment, ApiKey) map 1:1
|
||||
* to /framework/api/providers/* endpoints and are joined into a single
|
||||
* `/all` payload by the backend so we can render the dashboard with one fetch.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export const API_BASE = '/framework/api/providers';
|
||||
|
||||
export interface LlmProvider {
|
||||
provider_id: number;
|
||||
provider_code: string;
|
||||
provider_name: string;
|
||||
base_url: string;
|
||||
auth_type: string;
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
rate_limit_rpm: number;
|
||||
rate_limit_tpm: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LlmModel {
|
||||
model_id: number;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
provider_name?: string;
|
||||
model_code: string;
|
||||
model_name: string;
|
||||
context_window: number;
|
||||
max_output_tokens: number;
|
||||
input_cost_per_1m: number;
|
||||
output_cost_per_1m: number;
|
||||
supports_streaming: boolean;
|
||||
supports_tools: boolean;
|
||||
supports_vision: boolean;
|
||||
is_active: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ComponentAssignment {
|
||||
assignment_id: number;
|
||||
component_code: string;
|
||||
component_name: string;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
model_id: number;
|
||||
model_code?: string;
|
||||
fallback_provider_id: number | null;
|
||||
fallback_provider_code?: string;
|
||||
fallback_model_id: number | null;
|
||||
fallback_model_code?: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
timeout_ms: number;
|
||||
is_enabled: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
api_key_id: number;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
key_name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
usage_count: number;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => (
|
||||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Thin fetch wrappers for the /framework/api/providers/* CRUD surface.
|
||||
*
|
||||
* Each function returns whatever the backend wraps in its response (typically
|
||||
* `{ success, data?, error? }`). Callers check `.success` and use `.data` /
|
||||
* surface `.error` themselves — no error handling here so the page can show
|
||||
* its own UX for failures (toasts, inline errors).
|
||||
*/
|
||||
import {
|
||||
API_BASE,
|
||||
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
|
||||
} from './ProvidersManagement.types';
|
||||
|
||||
export async function fetchAll() {
|
||||
const res = await fetch(`${API_BASE}/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createProvider(data: Partial<LlmProvider>) {
|
||||
const res = await fetch(`${API_BASE}/configs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateProvider(id: number, data: Partial<LlmProvider>) {
|
||||
const res = await fetch(`${API_BASE}/configs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: number) {
|
||||
const res = await fetch(`${API_BASE}/configs/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createModel(data: Partial<LlmModel>) {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateModel(id: number, data: Partial<LlmModel>) {
|
||||
const res = await fetch(`${API_BASE}/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteModel(id: number) {
|
||||
const res = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createAssignment(data: Partial<ComponentAssignment>) {
|
||||
const res = await fetch(`${API_BASE}/assignments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAssignment(id: number, data: Partial<ComponentAssignment>) {
|
||||
const res = await fetch(`${API_BASE}/assignments/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAssignment(id: number) {
|
||||
const res = await fetch(`${API_BASE}/assignments/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createApiKey(data: { provider_id: number; key_name: string; api_key_value: string; is_active?: boolean }) {
|
||||
const res = await fetch(`${API_BASE}/keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateApiKey(id: number, data: Partial<ApiKey & { api_key_value?: string }>) {
|
||||
const res = await fetch(`${API_BASE}/keys/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id: number) {
|
||||
const res = await fetch(`${API_BASE}/keys/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function testProvider(providerId: number) {
|
||||
const res = await fetch(`${API_BASE}/test/${providerId}`, { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Add/Edit dialog for an ApiKey row.
|
||||
*
|
||||
* `api_key_value` is a transient field — only sent to the backend on create
|
||||
* or when explicitly changed during edit. Backend stores it encrypted and
|
||||
* only returns the prefix on subsequent reads.
|
||||
*
|
||||
* Toggle button hides/shows the key value with a password-style mask.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, IconButton,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import type { ApiKey, LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<ApiKey & { api_key_value?: string }> | null;
|
||||
providers: LlmProvider[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<ApiKey & { api_key_value?: string }>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ApiKeyDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
if (!data) return null;
|
||||
const isEdit = 'api_key_id' in data && !!data.api_key_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Key Name" value={data.key_name || ''} onChange={(e) => onChange({ ...data, key_name: e.target.value })} required fullWidth placeholder="Primary Key, Backup Key, etc." />
|
||||
<TextField
|
||||
label={isEdit ? 'New API Key (leave empty to keep existing)' : 'API Key'}
|
||||
value={data.api_key_value || ''}
|
||||
onChange={(e) => onChange({ ...data, api_key_value: e.target.value })}
|
||||
required={!isEdit}
|
||||
fullWidth
|
||||
type={showKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowKey(!showKey)} edge="end">
|
||||
{showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.key_name || (!isEdit && !data.api_key_value)}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Add/Edit dialog for a ComponentAssignment row.
|
||||
*
|
||||
* Maps a component (techniques/claims_extract/etc) to a primary provider+model
|
||||
* with optional fallback provider+model. Models dropdown filters to only show
|
||||
* models belonging to the currently-selected provider.
|
||||
*
|
||||
* Fallback section is fully optional — empty fallback_provider_id disables
|
||||
* the fallback model dropdown.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, Typography,
|
||||
} from '@mui/material';
|
||||
import type { ComponentAssignment, LlmProvider, LlmModel } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<ComponentAssignment> | null;
|
||||
providers: LlmProvider[];
|
||||
models: LlmModel[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<ComponentAssignment>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const AssignmentDialog: React.FC<Props> = ({ open, data, providers, models, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'assignment_id' in data && !!data.assignment_id;
|
||||
|
||||
const filteredModels = models.filter((m) => m.provider_id === data.provider_id);
|
||||
const filteredFallbackModels = models.filter((m) => m.provider_id === data.fallback_provider_id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Assignment' : 'Add Assignment'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<TextField label="Component Code" value={data.component_code || ''} onChange={(e) => onChange({ ...data, component_code: e.target.value })} required fullWidth placeholder="techniques, claims_extract, etc." />
|
||||
<TextField label="Component Name" value={data.component_name || ''} onChange={(e) => onChange({ ...data, component_name: e.target.value })} required fullWidth />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Primary Provider/Model</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControl sx={{ flex: 1 }} required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value), model_id: undefined })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ flex: 1 }} required>
|
||||
<InputLabel>Model</InputLabel>
|
||||
<Select value={data.model_id || ''} label="Model" onChange={(e) => onChange({ ...data, model_id: Number(e.target.value) })}>
|
||||
{filteredModels.map((m) => (
|
||||
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Fallback Provider/Model (Optional)</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Fallback Provider</InputLabel>
|
||||
<Select value={data.fallback_provider_id || ''} label="Fallback Provider" onChange={(e) => onChange({ ...data, fallback_provider_id: e.target.value ? Number(e.target.value) : null, fallback_model_id: null })}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Fallback Model</InputLabel>
|
||||
<Select value={data.fallback_model_id || ''} label="Fallback Model" onChange={(e) => onChange({ ...data, fallback_model_id: e.target.value ? Number(e.target.value) : null })} disabled={!data.fallback_provider_id}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{filteredFallbackModels.map((m) => (
|
||||
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Settings</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Temperature" type="number" value={data.temperature || 0.3} onChange={(e) => onChange({ ...data, temperature: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.1, min: 0, max: 2 }} />
|
||||
<TextField label="Max Tokens" type="number" value={data.max_tokens || 4096} onChange={(e) => onChange({ ...data, max_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Timeout (ms)" type="number" value={data.timeout_ms || 120000} onChange={(e) => onChange({ ...data, timeout_ms: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_enabled !== false} onChange={(e) => onChange({ ...data, is_enabled: e.target.checked })} />} label="Enabled" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.component_code || !data.component_name || !data.provider_id || !data.model_id}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Generic delete-confirmation dialog used by all four entity types.
|
||||
* Caller dispatches the actual delete via onConfirm based on the row's `type`.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
name: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteConfirmDialog: React.FC<Props> = ({ open, name, onCancel, onConfirm }) => (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete <strong>{name}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Add/Edit dialog for an LlmModel row.
|
||||
* Provider dropdown is required; cost fields default to $0 (FREE).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
|
||||
} from '@mui/material';
|
||||
import type { LlmModel, LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<LlmModel> | null;
|
||||
providers: LlmProvider[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<LlmModel>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ModelDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'model_id' in data && !!data.model_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Model' : 'Add Model'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Model Code" value={data.model_code || ''} onChange={(e) => onChange({ ...data, model_code: e.target.value })} required fullWidth placeholder="gpt-4o, claude-3-opus, etc." />
|
||||
<TextField label="Model Name" value={data.model_name || ''} onChange={(e) => onChange({ ...data, model_name: e.target.value })} required fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Context Window" type="number" value={data.context_window || 32000} onChange={(e) => onChange({ ...data, context_window: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Max Output Tokens" type="number" value={data.max_output_tokens || 4096} onChange={(e) => onChange({ ...data, max_output_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Input Cost ($/1M)" type="number" value={data.input_cost_per_1m || 0} onChange={(e) => onChange({ ...data, input_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
|
||||
<TextField label="Output Cost ($/1M)" type="number" value={data.output_cost_per_1m || 0} onChange={(e) => onChange({ ...data, output_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_streaming !== false} onChange={(e) => onChange({ ...data, supports_streaming: e.target.checked })} />} label="Streaming" />
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_tools !== false} onChange={(e) => onChange({ ...data, supports_tools: e.target.checked })} />} label="Tools" />
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_vision === true} onChange={(e) => onChange({ ...data, supports_vision: e.target.checked })} />} label="Vision" />
|
||||
</Box>
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.model_code || !data.model_name}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Add/Edit dialog for an LlmProvider row.
|
||||
* Save is disabled until provider_code + provider_name are non-empty.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
|
||||
} from '@mui/material';
|
||||
import type { LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<LlmProvider> | null;
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<LlmProvider>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ProviderDialog: React.FC<Props> = ({ open, data, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'provider_id' in data && !!data.provider_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Provider' : 'Add Provider'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<TextField label="Provider Code" value={data.provider_code || ''} onChange={(e) => onChange({ ...data, provider_code: e.target.value })} required fullWidth />
|
||||
<TextField label="Provider Name" value={data.provider_name || ''} onChange={(e) => onChange({ ...data, provider_name: e.target.value })} required fullWidth />
|
||||
<TextField label="Base URL" value={data.base_url || ''} onChange={(e) => onChange({ ...data, base_url: e.target.value })} fullWidth placeholder="https://api.example.com/v1" />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Auth Type</InputLabel>
|
||||
<Select value={data.auth_type || 'bearer'} label="Auth Type" onChange={(e) => onChange({ ...data, auth_type: e.target.value })}>
|
||||
<MenuItem value="bearer">Bearer Token</MenuItem>
|
||||
<MenuItem value="api_key">API Key Header</MenuItem>
|
||||
<MenuItem value="x-api-key">X-API-Key Header</MenuItem>
|
||||
<MenuItem value="none">No Auth</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Priority" type="number" value={data.priority || 100} onChange={(e) => onChange({ ...data, priority: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Rate Limit (RPM)" type="number" value={data.rate_limit_rpm || 60} onChange={(e) => onChange({ ...data, rate_limit_rpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Rate Limit (TPM)" type="number" value={data.rate_limit_tpm || 100000} onChange={(e) => onChange({ ...data, rate_limit_tpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_code || !data.provider_name}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ProvidersManagement } from './ProvidersManagement';
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* API Keys tab — list of stored keys with usage telemetry.
|
||||
*
|
||||
* Backend redacts the key value to a prefix; frontend never sees the full key
|
||||
* after creation. Edit dialog requires re-entering it if rotation is needed.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { ApiKey } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
apiKeys: ApiKey[];
|
||||
hasProviders: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (k: ApiKey) => void;
|
||||
onDelete: (k: ApiKey) => void;
|
||||
onToggleActive: (k: ApiKey) => void;
|
||||
}
|
||||
|
||||
export const ApiKeysTab: React.FC<Props> = ({ apiKeys, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
|
||||
Add API Key
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key Name</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Prefix</TableCell>
|
||||
<TableCell>Usage</TableCell>
|
||||
<TableCell>Last Used</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{apiKeys.map((k) => (
|
||||
<TableRow key={k.api_key_id} hover>
|
||||
<TableCell><strong>{k.key_name}</strong></TableCell>
|
||||
<TableCell><Chip label={k.provider_code} size="small" /></TableCell>
|
||||
<TableCell><code>{k.key_prefix}...</code></TableCell>
|
||||
<TableCell>{k.usage_count} calls</TableCell>
|
||||
<TableCell>{k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : 'Never'}</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={k.is_active} onChange={() => onToggleActive(k)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(k)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(k)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Assignments tab — maps each component to a primary provider+model with
|
||||
* an optional fallback. Settings column packs temp/max/timeout into one
|
||||
* caption row to keep the table scannable.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { ComponentAssignment } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
assignments: ComponentAssignment[];
|
||||
hasProvidersAndModels: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (a: ComponentAssignment) => void;
|
||||
onDelete: (a: ComponentAssignment) => void;
|
||||
onToggleEnabled: (a: ComponentAssignment) => void;
|
||||
}
|
||||
|
||||
export const AssignmentsTab: React.FC<Props> = ({ assignments, hasProvidersAndModels, onAdd, onEdit, onDelete, onToggleEnabled }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProvidersAndModels}>
|
||||
Add Assignment
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Provider / Model</TableCell>
|
||||
<TableCell>Fallback</TableCell>
|
||||
<TableCell>Settings</TableCell>
|
||||
<TableCell>Enabled</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{assignments.map((a) => (
|
||||
<TableRow key={a.assignment_id} hover>
|
||||
<TableCell>
|
||||
<strong>{a.component_name}</strong>
|
||||
<Typography variant="caption" display="block" color="text.secondary">{a.component_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={a.provider_code} size="small" sx={{ mr: 0.5 }} />
|
||||
<Chip label={a.model_code} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{a.fallback_provider_code ? (
|
||||
<>
|
||||
<Chip label={a.fallback_provider_code} size="small" sx={{ mr: 0.5 }} />
|
||||
<Chip label={a.fallback_model_code} size="small" variant="outlined" />
|
||||
</>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">
|
||||
temp: {a.temperature} | max: {a.max_tokens} | timeout: {a.timeout_ms}ms
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={a.is_enabled} onChange={() => onToggleEnabled(a)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(a)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(a)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Models tab — list of LLM models with provider, context, cost, features.
|
||||
*
|
||||
* Cost column shows "FREE" chip when input cost is $0; otherwise shows
|
||||
* "$<input> / $<output>" per 1M tokens. Features are 3 small chips
|
||||
* (Streaming/Tools/Vision) shown only when supported.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { LlmModel } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
models: LlmModel[];
|
||||
hasProviders: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (m: LlmModel) => void;
|
||||
onDelete: (m: LlmModel) => void;
|
||||
onToggleActive: (m: LlmModel) => void;
|
||||
}
|
||||
|
||||
export const ModelsTab: React.FC<Props> = ({ models, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
|
||||
Add Model
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Context</TableCell>
|
||||
<TableCell>Cost (per 1M)</TableCell>
|
||||
<TableCell>Features</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{models.map((m) => (
|
||||
<TableRow key={m.model_id} hover>
|
||||
<TableCell>
|
||||
<strong>{m.model_name}</strong>
|
||||
<Typography variant="caption" display="block" color="text.secondary">{m.model_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={m.provider_code} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{(m.context_window / 1000).toFixed(0)}K</TableCell>
|
||||
<TableCell>
|
||||
{m.input_cost_per_1m === 0
|
||||
? <Chip label="FREE" size="small" color="success" />
|
||||
: `$${m.input_cost_per_1m} / $${m.output_cost_per_1m}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.supports_streaming && <Chip label="Stream" size="small" sx={{ mr: 0.5 }} />}
|
||||
{m.supports_tools && <Chip label="Tools" size="small" sx={{ mr: 0.5 }} />}
|
||||
{m.supports_vision && <Chip label="Vision" size="small" color="primary" />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={m.is_active} onChange={() => onToggleActive(m)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(m)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Providers tab — list of LLM providers with test-connection action.
|
||||
*
|
||||
* Test-connection runs against the backend's /test/:providerId endpoint
|
||||
* and shows ✓ / ✗ + latency_ms inline next to the play button. Result
|
||||
* stays visible until the next click (no auto-clear).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon,
|
||||
PlayArrow as TestIcon, CheckCircle, Cancel,
|
||||
} from '@mui/icons-material';
|
||||
import type { LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
providers: LlmProvider[];
|
||||
testingProvider: number | null;
|
||||
testResult: { providerId: number; success: boolean; latencyMs?: number; error?: string } | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (p: LlmProvider) => void;
|
||||
onDelete: (p: LlmProvider) => void;
|
||||
onToggleActive: (p: LlmProvider) => void;
|
||||
onTest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const ProvidersTab: React.FC<Props> = ({
|
||||
providers, testingProvider, testResult,
|
||||
onAdd, onEdit, onDelete, onToggleActive, onTest,
|
||||
}) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd}>
|
||||
Add Provider
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Base URL</TableCell>
|
||||
<TableCell>Auth</TableCell>
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Rate Limits</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.provider_id} hover>
|
||||
<TableCell><strong>{p.provider_name}</strong></TableCell>
|
||||
<TableCell><Chip label={p.provider_code} size="small" /></TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<Tooltip title={p.base_url}><span>{p.base_url}</span></Tooltip>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={p.auth_type} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{p.priority}</TableCell>
|
||||
<TableCell>{p.rate_limit_rpm} RPM / {p.rate_limit_tpm} TPM</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={p.is_active} onChange={() => onToggleActive(p)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Test Connection">
|
||||
<IconButton size="small" onClick={() => onTest(p.provider_id)} disabled={testingProvider === p.provider_id}>
|
||||
{testingProvider === p.provider_id ? <CircularProgress size={16} /> : <TestIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{testResult?.providerId === p.provider_id && (
|
||||
<Tooltip title={testResult.success ? `${testResult.latencyMs}ms` : testResult.error}>
|
||||
{testResult.success
|
||||
? <CheckCircle color="success" fontSize="small" />
|
||||
: <Cancel color="error" fontSize="small" />}
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => onEdit(p)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(p)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* RolesDialog — multi-select Keycloak realm roles for a user.
|
||||
*
|
||||
* UX:
|
||||
* 1. On open, load realm roles + user's current roles.
|
||||
* 2. Show checkbox list with description tooltip.
|
||||
* 3. Save → PUT /api/admin/users/:id/roles with the desired list.
|
||||
* Server diffs add/remove and reports counts.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
interface RealmRole {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PutRolesResponse {
|
||||
success: boolean;
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
errors?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token
|
||||
? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
: { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export const RolesDialog: React.FC<Props> = ({ open, userId, userEmail, onClose, onSaved }) => {
|
||||
const [allRoles, setAllRoles] = useState<RealmRole[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [initial, setInitial] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [rolesRes, userRolesRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/admin/realm-roles`, { headers: authHeaders() }),
|
||||
fetch(`${API_BASE}/api/admin/users/${userId}/roles`, { headers: authHeaders() }),
|
||||
]);
|
||||
const roles = await rolesRes.json();
|
||||
const userRoles = await userRolesRes.json();
|
||||
if (cancelled) return;
|
||||
if (!roles.success || !userRoles.success) {
|
||||
setError((roles.error || userRoles.error) ?? 'Failed to load roles');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const all = (roles.data ?? []) as RealmRole[];
|
||||
const have = new Set<string>((userRoles.data?.roles ?? []) as string[]);
|
||||
setAllRoles(all);
|
||||
setSelected(new Set(have));
|
||||
setInitial(new Set(have));
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, userId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase();
|
||||
if (!f) return allRoles;
|
||||
return allRoles.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(f) ||
|
||||
r.description.toLowerCase().includes(f),
|
||||
);
|
||||
}, [allRoles, filter]);
|
||||
|
||||
const diffSummary = useMemo(() => {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
selected.forEach((n) => {
|
||||
if (!initial.has(n)) added.push(n);
|
||||
});
|
||||
initial.forEach((n) => {
|
||||
if (!selected.has(n)) removed.push(n);
|
||||
});
|
||||
return { added, removed };
|
||||
}, [selected, initial]);
|
||||
|
||||
const toggle = (name: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${userId}/roles`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ roles: Array.from(selected) }),
|
||||
});
|
||||
const data = (await res.json()) as PutRolesResponse;
|
||||
if (!data.success) {
|
||||
setError(data.error ?? `Saved with errors: ${(data.errors ?? []).join('; ')}`);
|
||||
if (res.status === 207) onSaved();
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = diffSummary.added.length > 0 || diffSummary.removed.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Realm roles — {userEmail}
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Realm: didi-clients (Keycloak SSO cluster)
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && (
|
||||
<Stack alignItems="center" py={4}>
|
||||
<CircularProgress size={28} />
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Filter roles"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
{filtered.length === 0 && (
|
||||
<Alert severity="info">No roles match the filter.</Alert>
|
||||
)}
|
||||
<List dense disablePadding sx={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
{filtered.map((r) => (
|
||||
<ListItem key={r.name} disableGutters sx={{ py: 0 }}>
|
||||
<FormControlLabel
|
||||
sx={{ width: '100%' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selected.has(r.name)}
|
||||
onChange={() => toggle(r.name)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Tooltip title={r.description || '—'}>
|
||||
<ListItemText
|
||||
primary={r.name}
|
||||
secondary={r.description || undefined}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
}}
|
||||
secondaryTypographyProps={{ fontSize: 11 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{dirty && (
|
||||
<Alert severity="warning">
|
||||
{diffSummary.added.length > 0 && (
|
||||
<div>
|
||||
<strong>Add:</strong> {diffSummary.added.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{diffSummary.removed.length > 0 && (
|
||||
<div>
|
||||
<strong>Remove:</strong> {diffSummary.removed.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* UsageHistoryModal — last N analyses + total cheltuit pentru un user.
|
||||
*
|
||||
* Reads bos_sysadmin.ai_credit_usage via /api/admin/users/:id/usage-history.
|
||||
* The endpoint returns rows with whatever columns the table has — we render
|
||||
* defensively (display known fields if present, fall back to a small pretty
|
||||
* JSON for unknown columns).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { Close as CloseIcon, Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface UsageRow {
|
||||
// Best-effort columns. Names match what we observed in the schema; any
|
||||
// extra columns the row has are still rendered via JSON fallback.
|
||||
internet_user_id?: number;
|
||||
session_id?: string;
|
||||
input_type?: string;
|
||||
credits_used?: number;
|
||||
used_at?: string;
|
||||
created_at?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface UsageResponse {
|
||||
success: boolean;
|
||||
data: UsageRow[];
|
||||
stats?: { rows: number; total_credits: number };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
const TYPE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'info'> = {
|
||||
text: 'primary',
|
||||
url: 'info',
|
||||
image: 'secondary',
|
||||
audio: 'warning',
|
||||
video: 'success',
|
||||
};
|
||||
|
||||
function fmt(s: unknown): string {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
return new Date(s as string).toLocaleString();
|
||||
} catch {
|
||||
return String(s);
|
||||
}
|
||||
}
|
||||
|
||||
function pickWhen(r: UsageRow): unknown {
|
||||
return r.used_at ?? r.created_at ?? (r as Record<string, unknown>).timestamp ?? null;
|
||||
}
|
||||
|
||||
export const UsageHistoryModal: React.FC<Props> = ({ open, userId, userEmail, onClose }) => {
|
||||
const [rows, setRows] = useState<UsageRow[]>([]);
|
||||
const [stats, setStats] = useState<{ rows: number; total_credits: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/users/${userId}/usage-history?limit=100`,
|
||||
{ headers: authHeaders() },
|
||||
);
|
||||
const data = (await res.json()) as UsageResponse;
|
||||
if (!data.success) {
|
||||
setError(data.error ?? 'Failed to load usage history');
|
||||
return;
|
||||
}
|
||||
setRows(data.data ?? []);
|
||||
setStats(data.stats ?? null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, userId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="h6">Usage history — {userEmail}</Typography>
|
||||
{stats && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{stats.rows} rows · {stats.total_credits} credits used (sum)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<IconButton onClick={load} title="Refresh">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && (
|
||||
<Stack alignItems="center" py={4}>
|
||||
<CircularProgress size={28} />
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && error && <Alert severity="error">{error}</Alert>}
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<Alert severity="info">No usage rows recorded for this user.</Alert>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Input type</TableCell>
|
||||
<TableCell align="right">Credits</TableCell>
|
||||
<TableCell>Session</TableCell>
|
||||
<TableCell>Other</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((r, idx) => {
|
||||
const inputType = (r.input_type ?? '') as string;
|
||||
const credits = r.credits_used ??
|
||||
(r as Record<string, unknown>).credits ??
|
||||
(r as Record<string, unknown>).cost ??
|
||||
null;
|
||||
const session = (r.session_id ?? '') as string;
|
||||
|
||||
// Other columns — exclude the ones we already show.
|
||||
const known = new Set(['internet_user_id','session_id','input_type','credits_used','used_at','created_at','credits','cost']);
|
||||
const extras: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(r)) {
|
||||
if (!known.has(k)) extras[k] = v;
|
||||
}
|
||||
const extrasStr = Object.keys(extras).length === 0
|
||||
? '—'
|
||||
: JSON.stringify(extras).slice(0, 80);
|
||||
|
||||
return (
|
||||
<TableRow key={idx} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{fmt(pickWhen(r))}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{inputType ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={inputType}
|
||||
color={TYPE_COLOR[inputType] ?? 'default'}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{credits === null ? '—' : String(credits)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
title={session}
|
||||
>
|
||||
{session ? session.slice(0, 12) + '…' : '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', color: 'text.secondary' }}
|
||||
>
|
||||
{extrasStr}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Stack,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import { LockReset as LockResetIcon } from '@mui/icons-material';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
creditsRemained: number;
|
||||
creditsSpent: number;
|
||||
subscriptionPlanId: number;
|
||||
subscriptionPlanName: string;
|
||||
subscriptionStatus: number;
|
||||
isActive: boolean;
|
||||
keycloakId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
creditsIncluded: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface UserEditModalProps {
|
||||
open: boolean;
|
||||
user: User;
|
||||
plans: SubscriptionPlan[];
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
export const UserEditModal: React.FC<UserEditModalProps> = ({
|
||||
open,
|
||||
user,
|
||||
plans,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
isActive: true,
|
||||
creditsRemained: 0,
|
||||
subscriptionPlanId: 1,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Phase U — group select + reset password (live values from Keycloak)
|
||||
const [allGroups, setAllGroups] = useState<{ id: string; name: string }[]>([]);
|
||||
const [currentGroup, setCurrentGroup] = useState<string>('');
|
||||
const [initialGroup, setInitialGroup] = useState<string>('');
|
||||
const [resetSent, setResetSent] = useState<string | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
phone: user.phone || '',
|
||||
isActive: user.isActive,
|
||||
creditsRemained: user.creditsRemained || 0,
|
||||
subscriptionPlanId: user.subscriptionPlanId || 1,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load Keycloak groups + the user's current group when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open || !user?.id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [groupsRes, userGroupRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/admin/groups`, { headers: getAuthHeader() as any }),
|
||||
fetch(`${API_BASE}/api/admin/users/${user.id}/group`, { headers: getAuthHeader() as any }),
|
||||
]);
|
||||
const groupsData = await groupsRes.json();
|
||||
const userGroupData = await userGroupRes.json();
|
||||
if (cancelled) return;
|
||||
if (groupsData.success) setAllGroups(groupsData.data ?? []);
|
||||
const g = (userGroupData.data?.groups ?? [])[0] ?? '';
|
||||
setCurrentGroup(g);
|
||||
setInitialGroup(g);
|
||||
} catch {
|
||||
// best-effort — leave dropdown empty if Keycloak unreachable
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, user?.id]);
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const handleChange = (field: string) => (event: any) => {
|
||||
const value = event.target.type === 'checkbox'
|
||||
? event.target.checked
|
||||
: event.target.value;
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Update user profile
|
||||
const profileResponse = await fetch(`${API_BASE}/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
phone: formData.phone,
|
||||
isActive: formData.isActive,
|
||||
creditsRemained: formData.creditsRemained,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
const data = await profileResponse.json();
|
||||
throw new Error(data.error || 'Failed to update user');
|
||||
}
|
||||
|
||||
// Update subscription if changed
|
||||
if (formData.subscriptionPlanId !== user.subscriptionPlanId) {
|
||||
const subscriptionResponse = await fetch(
|
||||
`${API_BASE}/api/admin/users/${user.id}/subscription`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
planId: formData.subscriptionPlanId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!subscriptionResponse.ok) {
|
||||
const data = await subscriptionResponse.json();
|
||||
throw new Error(data.error || 'Failed to update subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Update Keycloak group if changed (Phase U)
|
||||
if (currentGroup !== initialGroup) {
|
||||
const grpRes = await fetch(
|
||||
`${API_BASE}/api/admin/users/${user.id}/group`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ group: currentGroup || null }),
|
||||
}
|
||||
);
|
||||
if (!grpRes.ok) {
|
||||
const data = await grpRes.json();
|
||||
throw new Error(data.error || 'Failed to update group');
|
||||
}
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setResetting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${user.id}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ lifespanSeconds: 86400 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(
|
||||
data.error ||
|
||||
data.detail ||
|
||||
`Reset request failed (HTTP ${res.status})`,
|
||||
);
|
||||
}
|
||||
setResetSent(`Reset email queued for ${user.email}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedPlan = plans.find(p => p.id === formData.subscriptionPlanId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
{/* Email (readonly) */}
|
||||
<TextField
|
||||
label="Email"
|
||||
value={user.email}
|
||||
disabled
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* First Name */}
|
||||
<TextField
|
||||
label="First Name"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange('firstName')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Last Name */}
|
||||
<TextField
|
||||
label="Last Name"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange('lastName')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Phone */}
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange('phone')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Subscription & Credits
|
||||
</Typography>
|
||||
|
||||
{/* Subscription Plan */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Subscription Plan</InputLabel>
|
||||
<Select
|
||||
value={formData.subscriptionPlanId}
|
||||
onChange={handleChange('subscriptionPlanId')}
|
||||
label="Subscription Plan"
|
||||
>
|
||||
{plans.map(plan => (
|
||||
<MenuItem key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.creditsIncluded} credits)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedPlan && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Plan includes {selectedPlan.creditsIncluded} credits.
|
||||
{selectedPlan.price > 0 && ` Price: $${selectedPlan.price}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Credits */}
|
||||
<TextField
|
||||
label="Credits Remaining"
|
||||
type="number"
|
||||
value={formData.creditsRemained}
|
||||
onChange={handleChange('creditsRemained')}
|
||||
fullWidth
|
||||
size="small"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Credits spent: {user.creditsSpent}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Keycloak group
|
||||
</Typography>
|
||||
|
||||
{/* Phase U — Keycloak group (single-select).
|
||||
Empty value = remove from all groups. */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Group</InputLabel>
|
||||
<Select
|
||||
value={currentGroup}
|
||||
onChange={(e) => setCurrentGroup(e.target.value as string)}
|
||||
label="Group"
|
||||
disabled={!user.keycloakId}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>(no group)</em>
|
||||
</MenuItem>
|
||||
{allGroups.map((g) => (
|
||||
<MenuItem key={g.id} value={g.name}>
|
||||
{g.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{!user.keycloakId && (
|
||||
<Typography variant="caption" color="warning.main">
|
||||
No keycloak_id — sync this user first to manage their group.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Status */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onChange={handleChange('isActive')}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={formData.isActive ? 'Active' : 'Inactive'}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
variant="outlined"
|
||||
startIcon={resetting ? <CircularProgress size={16} /> : <LockResetIcon />}
|
||||
disabled={resetting || !user.keycloakId}
|
||||
color="warning"
|
||||
>
|
||||
Send password reset email
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Triggers Keycloak's UPDATE_PASSWORD flow. 24h lifespan.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
<Snackbar
|
||||
open={resetSent !== null}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setResetSent(null)}
|
||||
message={resetSent ?? ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue