livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Director intern de sesiune (asistent) — nu face parte din livrare
|
||||||
|
.claude/
|
||||||
52
README.txt
Normal file
52
README.txt
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
========================================================================
|
||||||
|
DIDI — LOT 2 (BACKEND) — PACHET DE LIVRARE
|
||||||
|
Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării
|
||||||
|
PNRR DIGI150 · contract 11.1.i3.c9
|
||||||
|
Data: 2026-07-10
|
||||||
|
========================================================================
|
||||||
|
|
||||||
|
CONȚINUTUL PACHETULUI
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
cod-sursa-lot2.tar.gz
|
||||||
|
Codul sursă complet al Lotului 2 (agent-v3 + workeri, didiFramework,
|
||||||
|
admin-dashboard, gateway, data-layer, observabilitate, scripturi build).
|
||||||
|
NU conține: secrete (.env), dependențe (node_modules), dump-ul bazei
|
||||||
|
de date, artefacte de build. Sunt incluse șabloanele *.env.example.
|
||||||
|
|
||||||
|
imagini-docker/
|
||||||
|
Imaginile Docker pre-construite pentru serviciile Lot 2:
|
||||||
|
- agent-v3.tar.gz (motor de analiză + cei 13 workeri)
|
||||||
|
- didi-framework.tar.gz (API management parametri)
|
||||||
|
- didi-admin.tar.gz (dashboard administrativ)
|
||||||
|
- didi-kong.tar.gz (API gateway)
|
||||||
|
incarca-imagini.sh — script care rulează `docker load` pentru toate.
|
||||||
|
(Serviciile de infrastructură — PostgreSQL, Redis, RabbitMQ, MinIO,
|
||||||
|
Keycloak — folosesc imagini publice oficiale, se descarcă la deploy.)
|
||||||
|
|
||||||
|
documente/
|
||||||
|
Dosarul de documentație (Markdown + .docx):
|
||||||
|
00_INDEX ............ cuprinsul dosarului
|
||||||
|
01_Arhitectura ...... arhitectura tehnică
|
||||||
|
02_Ghid_Instalare ... instalare & operare
|
||||||
|
03_Raport_Testare_API ......... testare API (374/374)
|
||||||
|
04_Raport_Testare_Integrare ... testare integrare Lot1↔Lot2
|
||||||
|
05_Ghid_Utilizare ............. manual cu capturi de ecran
|
||||||
|
06_Matrice_Trasabilitate ...... cerințe → dovezi
|
||||||
|
07_Specificatii_API ........... structura API-urilor
|
||||||
|
|
||||||
|
LIVRARE GENERICĂ
|
||||||
|
----------------
|
||||||
|
Pachetul este pregătit pentru livrare generică: nu conține adresa IP a
|
||||||
|
mașinii de dezvoltare și nici referințe la furnizori terți. Adresele de
|
||||||
|
platformă apar ca <HOST_IP> (documente) / didi.local (imagine dashboard) și
|
||||||
|
se configurează la instalare.
|
||||||
|
|
||||||
|
INSTALARE
|
||||||
|
---------
|
||||||
|
Vezi documente/02_Ghid_Instalare_Operare_Lot2. Pe scurt:
|
||||||
|
1. Încarcă imaginile: bash imagini-docker/incarca-imagini.sh
|
||||||
|
2. Dezarhivează sursa: tar xzf cod-sursa-lot2.tar.gz
|
||||||
|
3. Completează fișierele .env din șabloanele .env.example (host, secrete).
|
||||||
|
4. Rulează: backend/production/build-local.sh
|
||||||
|
========================================================================
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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