livrare lot 2

This commit is contained in:
EVOTECH IT SRL 2026-07-10 03:39:53 -07:00
commit 8ecc78e729
763 changed files with 164593 additions and 0 deletions

60
backend/.gitignore vendored Normal file
View 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

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

File diff suppressed because it is too large Load diff

96
backend/README.md Normal file
View 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
View 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

View 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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load diff

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

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

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
export { AnalysisHistory } from './AnalysisHistory';
export { AnalysisDetailModal } from './AnalysisDetailModal';

View file

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

View file

@ -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 &amp; 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>
);

View file

@ -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 &amp; Sync</Button>
</DialogActions>
</Dialog>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 &amp; 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>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [],
},
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export { ProvidersManagement } from './ProvidersManagement';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,234 @@
/**
* UsageHistoryModal last N analyses + total cheltuit pentru un user.
*
* Reads bos_sysadmin.ai_credit_usage via /api/admin/users/:id/usage-history.
* The endpoint returns rows with whatever columns the table has we render
* defensively (display known fields if present, fall back to a small pretty
* JSON for unknown columns).
*/
import React, { useEffect, useState } from 'react';
import {
Alert,
Box,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Button,
} from '@mui/material';
import { Close as CloseIcon, Refresh as RefreshIcon } from '@mui/icons-material';
interface UsageRow {
// Best-effort columns. Names match what we observed in the schema; any
// extra columns the row has are still rendered via JSON fallback.
internet_user_id?: number;
session_id?: string;
input_type?: string;
credits_used?: number;
used_at?: string;
created_at?: string;
[k: string]: unknown;
}
interface UsageResponse {
success: boolean;
data: UsageRow[];
stats?: { rows: number; total_credits: number };
error?: string;
}
interface Props {
open: boolean;
userId: number;
userEmail: string;
onClose: () => void;
}
const API_BASE = '/framework';
function authHeaders(): HeadersInit {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
const TYPE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'info'> = {
text: 'primary',
url: 'info',
image: 'secondary',
audio: 'warning',
video: 'success',
};
function fmt(s: unknown): string {
if (!s) return '—';
try {
return new Date(s as string).toLocaleString();
} catch {
return String(s);
}
}
function pickWhen(r: UsageRow): unknown {
return r.used_at ?? r.created_at ?? (r as Record<string, unknown>).timestamp ?? null;
}
export const UsageHistoryModal: React.FC<Props> = ({ open, userId, userEmail, onClose }) => {
const [rows, setRows] = useState<UsageRow[]>([]);
const [stats, setStats] = useState<{ rows: number; total_credits: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/api/admin/users/${userId}/usage-history?limit=100`,
{ headers: authHeaders() },
);
const data = (await res.json()) as UsageResponse;
if (!data.success) {
setError(data.error ?? 'Failed to load usage history');
return;
}
setRows(data.data ?? []);
setStats(data.stats ?? null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, userId]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="h6">Usage history {userEmail}</Typography>
{stats && (
<Typography variant="caption" color="text.secondary">
{stats.rows} rows · {stats.total_credits} credits used (sum)
</Typography>
)}
</Box>
<Stack direction="row" spacing={0.5}>
<IconButton onClick={load} title="Refresh">
<RefreshIcon />
</IconButton>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
</Stack>
</DialogTitle>
<DialogContent dividers>
{loading && (
<Stack alignItems="center" py={4}>
<CircularProgress size={28} />
</Stack>
)}
{!loading && error && <Alert severity="error">{error}</Alert>}
{!loading && !error && rows.length === 0 && (
<Alert severity="info">No usage rows recorded for this user.</Alert>
)}
{!loading && !error && rows.length > 0 && (
<TableContainer>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Input type</TableCell>
<TableCell align="right">Credits</TableCell>
<TableCell>Session</TableCell>
<TableCell>Other</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((r, idx) => {
const inputType = (r.input_type ?? '') as string;
const credits = r.credits_used ??
(r as Record<string, unknown>).credits ??
(r as Record<string, unknown>).cost ??
null;
const session = (r.session_id ?? '') as string;
// Other columns — exclude the ones we already show.
const known = new Set(['internet_user_id','session_id','input_type','credits_used','used_at','created_at','credits','cost']);
const extras: Record<string, unknown> = {};
for (const [k, v] of Object.entries(r)) {
if (!known.has(k)) extras[k] = v;
}
const extrasStr = Object.keys(extras).length === 0
? '—'
: JSON.stringify(extras).slice(0, 80);
return (
<TableRow key={idx} hover>
<TableCell>
<Typography variant="caption">{fmt(pickWhen(r))}</Typography>
</TableCell>
<TableCell>
{inputType ? (
<Chip
size="small"
label={inputType}
color={TYPE_COLOR[inputType] ?? 'default'}
/>
) : (
'—'
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>
{credits === null ? '—' : String(credits)}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace' }}
title={session}
>
{session ? session.slice(0, 12) + '…' : '—'}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', color: 'text.secondary' }}
>
{extrasStr}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Switch,
FormControlLabel,
Box,
Typography,
Alert,
CircularProgress,
Divider,
Stack,
Snackbar,
} from '@mui/material';
import { LockReset as LockResetIcon } from '@mui/icons-material';
interface User {
id: number;
email: string;
firstName: string;
lastName: string;
phone: string;
creditsRemained: number;
creditsSpent: number;
subscriptionPlanId: number;
subscriptionPlanName: string;
subscriptionStatus: number;
isActive: boolean;
keycloakId: string;
createdAt: string;
}
interface SubscriptionPlan {
id: number;
name: string;
description: string;
creditsIncluded: number;
price: number;
}
interface UserEditModalProps {
open: boolean;
user: User;
plans: SubscriptionPlan[];
onClose: () => void;
onSave: () => void;
}
const API_BASE = '/framework';
export const UserEditModal: React.FC<UserEditModalProps> = ({
open,
user,
plans,
onClose,
onSave,
}) => {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
phone: '',
isActive: true,
creditsRemained: 0,
subscriptionPlanId: 1,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Phase U — group select + reset password (live values from Keycloak)
const [allGroups, setAllGroups] = useState<{ id: string; name: string }[]>([]);
const [currentGroup, setCurrentGroup] = useState<string>('');
const [initialGroup, setInitialGroup] = useState<string>('');
const [resetSent, setResetSent] = useState<string | null>(null);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (user) {
setFormData({
firstName: user.firstName || '',
lastName: user.lastName || '',
phone: user.phone || '',
isActive: user.isActive,
creditsRemained: user.creditsRemained || 0,
subscriptionPlanId: user.subscriptionPlanId || 1,
});
}
}, [user]);
// Load Keycloak groups + the user's current group when the dialog opens.
useEffect(() => {
if (!open || !user?.id) return;
let cancelled = false;
(async () => {
try {
const [groupsRes, userGroupRes] = await Promise.all([
fetch(`${API_BASE}/api/admin/groups`, { headers: getAuthHeader() as any }),
fetch(`${API_BASE}/api/admin/users/${user.id}/group`, { headers: getAuthHeader() as any }),
]);
const groupsData = await groupsRes.json();
const userGroupData = await userGroupRes.json();
if (cancelled) return;
if (groupsData.success) setAllGroups(groupsData.data ?? []);
const g = (userGroupData.data?.groups ?? [])[0] ?? '';
setCurrentGroup(g);
setInitialGroup(g);
} catch {
// best-effort — leave dropdown empty if Keycloak unreachable
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, user?.id]);
const getAuthHeader = () => {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
const handleChange = (field: string) => (event: any) => {
const value = event.target.type === 'checkbox'
? event.target.checked
: event.target.value;
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setLoading(true);
setError(null);
try {
// Update user profile
const profileResponse = await fetch(`${API_BASE}/api/admin/users/${user.id}`, {
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone,
isActive: formData.isActive,
creditsRemained: formData.creditsRemained,
}),
});
if (!profileResponse.ok) {
const data = await profileResponse.json();
throw new Error(data.error || 'Failed to update user');
}
// Update subscription if changed
if (formData.subscriptionPlanId !== user.subscriptionPlanId) {
const subscriptionResponse = await fetch(
`${API_BASE}/api/admin/users/${user.id}/subscription`,
{
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId: formData.subscriptionPlanId,
}),
}
);
if (!subscriptionResponse.ok) {
const data = await subscriptionResponse.json();
throw new Error(data.error || 'Failed to update subscription');
}
}
// Update Keycloak group if changed (Phase U)
if (currentGroup !== initialGroup) {
const grpRes = await fetch(
`${API_BASE}/api/admin/users/${user.id}/group`,
{
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({ group: currentGroup || null }),
}
);
if (!grpRes.ok) {
const data = await grpRes.json();
throw new Error(data.error || 'Failed to update group');
}
}
onSave();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleResetPassword = async () => {
setResetting(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/admin/users/${user.id}/reset-password`, {
method: 'POST',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({ lifespanSeconds: 86400 }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(
data.error ||
data.detail ||
`Reset request failed (HTTP ${res.status})`,
);
}
setResetSent(`Reset email queued for ${user.email}`);
} catch (err: any) {
setError(err.message);
} finally {
setResetting(false);
}
};
const selectedPlan = plans.find(p => p.id === formData.subscriptionPlanId);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit User</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Email (readonly) */}
<TextField
label="Email"
value={user.email}
disabled
fullWidth
size="small"
/>
{/* First Name */}
<TextField
label="First Name"
value={formData.firstName}
onChange={handleChange('firstName')}
fullWidth
size="small"
/>
{/* Last Name */}
<TextField
label="Last Name"
value={formData.lastName}
onChange={handleChange('lastName')}
fullWidth
size="small"
/>
{/* Phone */}
<TextField
label="Phone"
value={formData.phone}
onChange={handleChange('phone')}
fullWidth
size="small"
/>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" color="text.secondary">
Subscription & Credits
</Typography>
{/* Subscription Plan */}
<FormControl fullWidth size="small">
<InputLabel>Subscription Plan</InputLabel>
<Select
value={formData.subscriptionPlanId}
onChange={handleChange('subscriptionPlanId')}
label="Subscription Plan"
>
{plans.map(plan => (
<MenuItem key={plan.id} value={plan.id}>
{plan.name} ({plan.creditsIncluded} credits)
</MenuItem>
))}
</Select>
</FormControl>
{selectedPlan && (
<Typography variant="caption" color="text.secondary">
Plan includes {selectedPlan.creditsIncluded} credits.
{selectedPlan.price > 0 && ` Price: $${selectedPlan.price}`}
</Typography>
)}
{/* Credits */}
<TextField
label="Credits Remaining"
type="number"
value={formData.creditsRemained}
onChange={handleChange('creditsRemained')}
fullWidth
size="small"
inputProps={{ min: 0 }}
/>
<Typography variant="caption" color="text.secondary">
Credits spent: {user.creditsSpent}
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" color="text.secondary">
Keycloak group
</Typography>
{/* Phase U Keycloak group (single-select).
Empty value = remove from all groups. */}
<FormControl fullWidth size="small">
<InputLabel>Group</InputLabel>
<Select
value={currentGroup}
onChange={(e) => setCurrentGroup(e.target.value as string)}
label="Group"
disabled={!user.keycloakId}
>
<MenuItem value="">
<em>(no group)</em>
</MenuItem>
{allGroups.map((g) => (
<MenuItem key={g.id} value={g.name}>
{g.name}
</MenuItem>
))}
</Select>
</FormControl>
{!user.keycloakId && (
<Typography variant="caption" color="warning.main">
No keycloak_id sync this user first to manage their group.
</Typography>
)}
<Divider sx={{ my: 1 }} />
{/* Status */}
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={handleChange('isActive')}
color="primary"
/>
}
label={formData.isActive ? 'Active' : 'Inactive'}
/>
<Divider sx={{ my: 1 }} />
<Stack direction="row" alignItems="center" spacing={2}>
<Button
onClick={handleResetPassword}
variant="outlined"
startIcon={resetting ? <CircularProgress size={16} /> : <LockResetIcon />}
disabled={resetting || !user.keycloakId}
color="warning"
>
Send password reset email
</Button>
<Typography variant="caption" color="text.secondary">
Triggers Keycloak's UPDATE_PASSWORD flow. 24h lifespan.
</Typography>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
</Button>
</DialogActions>
<Snackbar
open={resetSent !== null}
autoHideDuration={6000}
onClose={() => setResetSent(null)}
message={resetSent ?? ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Dialog>
);
};

Some files were not shown because too many files have changed in this diff Show more