Add admin panel, WebSocket support, and API versioning
Introduce an admin portal (React + Nginx), WebSocket routing, and API versioning middleware with `/api/v1/` prefix deprecation. Add master API key authentication, new Prometheus metrics for AI token consumption and active WebSockets, and extend S3 config with a public endpoint URL. Update test paths and fixtures to align with the new routing structure.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
.env
|
||||
.env.*
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
@@ -0,0 +1,5 @@
|
||||
# URL du backend Imago Hub
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Titre affiché dans l'interface
|
||||
VITE_APP_TITLE="Imago Hub Admin"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,16 @@
|
||||
# Stage 1 : Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ARG VITE_APP_TITLE="Imago Hub Admin"
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 : Serve
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 3000
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,115 @@
|
||||
# Imago Hub — Portail d'Administration
|
||||
|
||||
Portail d'administration web pour [Imago Hub](../README.md), construit avec **React 19**, **TypeScript**, **Vite**, et **Tailwind CSS**.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Section Admin
|
||||
- **Tableau de bord** — KPIs, graphiques uploads/stockage, santé des composants, activité récente
|
||||
- **Clients** — CRUD complet, rotation de clés API, gestion des plans et scopes
|
||||
- **Détail client** — Quotas (images/stockage/tokens), scopes, images récentes
|
||||
- **File de tâches** — Jobs en attente/cours/échec, retraitement unitaire et en lot
|
||||
- **Métriques** — Visualisation Prometheus : uploads, tokens, stockage, erreurs par étape
|
||||
- **Santé** — État des composants (DB, Redis, Queue, OCR, AI, Disque)
|
||||
- **Configuration** — Affichage lecture seule de la configuration backend
|
||||
- **Logs** — Flux de logs en temps réel avec filtres et export
|
||||
|
||||
### Section Galerie
|
||||
- **Galerie** — Grille/liste, filtres avancés, recherche, pagination
|
||||
- **Détail image** — 5 onglets (AI Vision, EXIF + carte GPS, OCR, Technique, Provenance)
|
||||
- **Galerie client** — Images filtrées par application
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Technologie | Usage |
|
||||
|---|---|
|
||||
| React 19 | UI framework |
|
||||
| TypeScript 5 | Typage statique strict |
|
||||
| Vite 7 | Bundler et dev server |
|
||||
| Tailwind CSS 3 | Styling utility-first, thème sombre |
|
||||
| TanStack Query 5 | Data fetching et cache |
|
||||
| Zustand 5 | State management (auth, filtres) |
|
||||
| React Router 7 | Routage SPA |
|
||||
| Recharts 3 | Graphiques et visualisations |
|
||||
| React-Leaflet 5 | Cartes GPS interactives |
|
||||
| Lucide React | Icônes |
|
||||
| Vitest | Tests unitaires |
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Développement
|
||||
```bash
|
||||
# Installation des dépendances
|
||||
npm install
|
||||
|
||||
# Lancer le serveur de développement
|
||||
npm run dev
|
||||
|
||||
# Le portail est accessible sur http://localhost:5173
|
||||
# Les requêtes API sont proxifiées vers http://localhost:8000
|
||||
```
|
||||
|
||||
### Scripts disponibles
|
||||
```bash
|
||||
npm run dev # Serveur dev avec HMR
|
||||
npm run build # Build production
|
||||
npm run preview # Prévisualiser le build
|
||||
npm run test # Lancer les tests unitaires
|
||||
npm run test:watch # Tests en mode watch
|
||||
npm run typecheck # Vérification TypeScript
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
#### Build et lancement autonome
|
||||
```bash
|
||||
docker build -t imago-admin .
|
||||
docker run -p 3000:3000 imago-admin
|
||||
```
|
||||
|
||||
#### Via docker-compose (stack complète)
|
||||
Depuis la racine du projet Imago (`../`), le `docker-compose.yml` inclut le service `admin` :
|
||||
```bash
|
||||
cd ..
|
||||
docker compose up -d
|
||||
# Admin : http://localhost:3000
|
||||
# Backend : http://localhost:8000
|
||||
```
|
||||
|
||||
## Architecture du projet
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Client HTTP + services (auth, images, metrics, health)
|
||||
├── components/
|
||||
│ ├── layout/ # Sidebar, TopBar, PageLayout
|
||||
│ └── shared/ # 12 composants UI réutilisables
|
||||
├── hooks/ # 6 hooks React (TanStack Query + WebSocket)
|
||||
├── pages/
|
||||
│ ├── admin/ # 8 pages admin
|
||||
│ └── gallery/ # 3 pages galerie
|
||||
├── store/ # 4 stores Zustand
|
||||
├── types/ # Interfaces TypeScript (API + métriques)
|
||||
├── utils/ # Utilitaires + tests (formats, couleurs, Prometheus, constantes)
|
||||
└── test/ # Configuration des tests
|
||||
```
|
||||
|
||||
## Authentification
|
||||
|
||||
Le portail utilise une **clé API** pour l'authentification :
|
||||
1. Saisie sur la page de login
|
||||
2. Stockée **uniquement en mémoire** (Zustand — pas de localStorage)
|
||||
3. Envoyée dans le header `Authorization: Bearer <clé>`
|
||||
4. Déconnexion automatique sur réponse 401/403
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `VITE_API_BASE_URL` | `http://localhost:8000` | URL de l'API backend |
|
||||
| `VITE_APP_TITLE` | `Imago Hub Admin` | Titre de l'application |
|
||||
|
||||
## Licence
|
||||
|
||||
Projet interne — voir la licence du projet parent.
|
||||
@@ -0,0 +1,104 @@
|
||||
services:
|
||||
# Portail admin (ce projet)
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_API_BASE_URL: http://backend:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# Backend Imago Hub (projet séparé)
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ../data:/app/data
|
||||
env_file: ../.env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Worker ARQ
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
command: python worker.py
|
||||
volumes:
|
||||
- ../data:/app/data
|
||||
env_file: ../.env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# PostgreSQL
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: imago
|
||||
POSTGRES_PASSWORD: imago
|
||||
POSTGRES_DB: imago
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U imago -d imago"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis (file ARQ + Pub/Sub)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# MinIO (stockage S3 compatible — développement)
|
||||
minio:
|
||||
image: minio/minio
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\api\images.ts
|
||||
14:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
18:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
102:35 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
102:43 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
122:31 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\components\layout\Sidebar.tsx
|
||||
17:14 error Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\components\shared\Toast.tsx
|
||||
19:14 error Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
|
||||
33:17 error Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
|
||||
85:39 error '_id' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\hooks\useWebSocket.ts
|
||||
51:60 error Error: Cannot access variable before it is declared
|
||||
|
||||
`connect` is accessed before it is declared, which prevents the earlier access from updating when this value changes over time.
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\hooks\useWebSocket.ts:51:60
|
||||
49 | const delay = reconnectDelay * Math.pow(2, reconnectCountRef.current);
|
||||
50 | reconnectCountRef.current++;
|
||||
> 51 | reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
| ^^^^^^^ `connect` accessed before it is declared
|
||||
52 | }
|
||||
53 | };
|
||||
54 |
|
||||
|
||||
C:\dev\git\python\imago\imago-admin\src\hooks\useWebSocket.ts:23:5
|
||||
21 | } = options;
|
||||
22 |
|
||||
> 23 | const connect = useCallback(() => {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 24 | if (!url) return;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 25 |
|
||||

|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 60 | }
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 61 | }, [url, reconnectDelay, maxReconnectAttempts]);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `connect` is declared here
|
||||
62 |
|
||||
63 | useEffect(() => {
|
||||
64 | connect(); react-hooks/immutability
|
||||
|
||||
Ô£û 10 problems (10 errors, 0 warnings)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Portail d'administration et de surveillance pour Imago Hub — gestion d'images avec pipeline AI multi-clients" />
|
||||
<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@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<title>Imago Hub Admin</title>
|
||||
</head>
|
||||
<body class="bg-surface text-content-primary antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
server {
|
||||
listen 3000;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — toutes les routes vers index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy vers le backend pour les routes API (v1 et Admin)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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 /admin/api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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 /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
# Cache agressif pour les assets buildés (hash dans le nom)
|
||||
location ~* \.(js|css|png|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+7692
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "imago-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mark.js": "^8.11.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.7.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mark.js": "^8.11.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6366F1;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||
<text x="50" y="65" text-anchor="middle" fill="white" font-family="Inter, sans-serif" font-weight="700" font-size="40">IH</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { Sidebar, SidebarProvider, useSidebar } from '@/components/layout/Sidebar';
|
||||
import { TopBar } from '@/components/layout/TopBar';
|
||||
import { ToastContainer } from '@/components/shared/Toast';
|
||||
import { Login } from '@/pages/Login';
|
||||
|
||||
// Lazy load pages
|
||||
import { lazy, Suspense, type ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const Dashboard = lazy(() => import('@/pages/admin/Dashboard'));
|
||||
const Clients = lazy(() => import('@/pages/admin/Clients'));
|
||||
const ClientDetail = lazy(() => import('@/pages/admin/ClientDetail'));
|
||||
const Queue = lazy(() => import('@/pages/admin/Queue'));
|
||||
const Metrics = lazy(() => import('@/pages/admin/Metrics'));
|
||||
const Health = lazy(() => import('@/pages/admin/Health'));
|
||||
const Config = lazy(() => import('@/pages/admin/Config'));
|
||||
const Logs = lazy(() => import('@/pages/admin/Logs'));
|
||||
const Docs = lazy(() => import('@/pages/admin/DocsPage'));
|
||||
const Gallery = lazy(() => import('@/pages/gallery/Gallery'));
|
||||
const ImageDetail = lazy(() => import('@/pages/gallery/ImageDetail'));
|
||||
const ClientGallery = lazy(() => import('@/pages/gallery/ClientGallery'));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function PageLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="animate-spin text-brand-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const { collapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<Sidebar />
|
||||
<div
|
||||
className="transition-all duration-200"
|
||||
style={{ marginLeft: collapsed ? '4rem' : '16rem' }}
|
||||
>
|
||||
<TopBar />
|
||||
<main className="p-6">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SidebarProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/admin/dashboard" element={<Dashboard />} />
|
||||
<Route path="/admin/clients" element={<Clients />} />
|
||||
<Route path="/admin/clients/:id" element={<ClientDetail />} />
|
||||
<Route path="/admin/queue" element={<Queue />} />
|
||||
<Route path="/admin/metrics" element={<Metrics />} />
|
||||
<Route path="/admin/health" element={<Health />} />
|
||||
<Route path="/admin/config" element={<Config />} />
|
||||
<Route path="/admin/logs" element={<Logs />} />
|
||||
<Route path="/admin/aides" element={<Docs />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/gallery/image/:id" element={<ImageDetail />} />
|
||||
<Route path="/gallery/client/:clientId" element={<ClientGallery />} />
|
||||
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
</SidebarProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||
import { ENDPOINTS } from '@/utils/constants';
|
||||
import type { APIClient, APIClientCreate, APIClientCreateResponse, APIClientUpdate } from '@/types/api.types';
|
||||
|
||||
export async function fetchClients(): Promise<APIClient[]> {
|
||||
return apiGet<APIClient[]>(ENDPOINTS.CLIENTS);
|
||||
}
|
||||
|
||||
export async function fetchClient(id: string): Promise<APIClient> {
|
||||
return apiGet<APIClient>(ENDPOINTS.CLIENT(id));
|
||||
}
|
||||
|
||||
export async function createClient(data: APIClientCreate): Promise<APIClientCreateResponse> {
|
||||
return apiPost<APIClientCreateResponse>(ENDPOINTS.CLIENTS, data);
|
||||
}
|
||||
|
||||
export async function updateClient(id: string, data: APIClientUpdate): Promise<APIClient> {
|
||||
return apiPatch<APIClient>(ENDPOINTS.CLIENT(id), data);
|
||||
}
|
||||
|
||||
export async function deleteClient(id: string): Promise<void> {
|
||||
return apiDelete(ENDPOINTS.CLIENT(id));
|
||||
}
|
||||
|
||||
export async function rotateClientKey(id: string): Promise<{ api_key: string }> {
|
||||
return apiPost<{ api_key: string }>(ENDPOINTS.CLIENT_ROTATE_KEY(id));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL || '';
|
||||
};
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message || `HTTP ${status}: ${statusText}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
customHeaders?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const baseUrl = getBaseUrl();
|
||||
const url = `${baseUrl}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...customHeaders,
|
||||
};
|
||||
|
||||
const apiKey = useAuthStore.getState().apiKey;
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
if (body && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const timeout = 15000; // 15 seconds timeout
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(id);
|
||||
|
||||
// Handle auth errors
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
useAuthStore.getState().logout();
|
||||
throw new ApiError(response.status, response.statusText, 'Session expirée. Veuillez vous reconnecter.');
|
||||
}
|
||||
|
||||
// Handle rate limiting
|
||||
if (response.status === 429) {
|
||||
throw new ApiError(429, 'Too Many Requests', 'Limite de requêtes atteinte. Réessayez dans quelques instants.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage: string | undefined;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
errorMessage = errorBody.detail || errorBody.message;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, errorMessage);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Handle text responses (metrics endpoint)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/plain') || contentType.includes('text/html')) {
|
||||
return (await response.text()) as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error: unknown) {
|
||||
clearTimeout(id);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ApiError(408, 'Request Timeout', 'La requête a pris trop de temps (timeout 15s).');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>('GET', path, undefined, undefined, signal);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>('POST', path, body, undefined, signal);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>('PATCH', path, body, undefined, signal);
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>('DELETE', path, undefined, undefined, signal);
|
||||
}
|
||||
|
||||
export async function apiUpload<T>(path: string, formData: FormData, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>('POST', path, formData, undefined, signal);
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiGet } from './client';
|
||||
|
||||
export interface DocMeta {
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface DocContent {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function getDocsList() {
|
||||
return apiGet<DocMeta[]>('/admin/api/docs');
|
||||
}
|
||||
|
||||
export function getDocContent(filename: string) {
|
||||
return apiGet<DocContent>(`/admin/api/docs/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiGet } from './client';
|
||||
import { ENDPOINTS } from '@/utils/constants';
|
||||
import type { HealthCheck, SimpleHealth } from '@/types/api.types';
|
||||
|
||||
export async function fetchHealthSimple(): Promise<SimpleHealth> {
|
||||
return apiGet<SimpleHealth>(ENDPOINTS.HEALTH);
|
||||
}
|
||||
|
||||
export async function fetchHealthDetailed(): Promise<HealthCheck> {
|
||||
return apiGet<HealthCheck>(ENDPOINTS.HEALTH_DETAILED);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { apiGet, apiPost, apiDelete } from './client';
|
||||
import { ENDPOINTS } from '@/utils/constants';
|
||||
import type { Image, ImageFilters } from '@/types/api.types';
|
||||
|
||||
interface ImagesResponse {
|
||||
items: Image[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// Helper to map backend image (nested or flat) to frontend Image interface
|
||||
function mapImage(data: any): Image {
|
||||
if (!data) return data;
|
||||
|
||||
// Base properties (always flat)
|
||||
const result: any = {
|
||||
id: data.id,
|
||||
uuid: data.uuid,
|
||||
client_id: data.client_id,
|
||||
client_name: data.client_name,
|
||||
original_name: data.original_name,
|
||||
filename: data.filename,
|
||||
mime_type: data.mime_type,
|
||||
file_size: data.file_size,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
uploaded_at: data.uploaded_at,
|
||||
processing_status: data.processing_status,
|
||||
processing_error: data.processing_error,
|
||||
processing_started_at: data.processing_started_at,
|
||||
processing_done_at: data.processing_done_at,
|
||||
};
|
||||
|
||||
// Handle nested structures if present (ImageDetail)
|
||||
if (data.exif) {
|
||||
const { camera = {}, gps = {} } = data.exif;
|
||||
result.exif_make = camera.make ?? null;
|
||||
result.exif_model = camera.model ?? null;
|
||||
result.exif_lens = camera.lens ?? null;
|
||||
result.exif_taken_at = camera.taken_at ?? null;
|
||||
result.exif_iso = camera.iso ?? null;
|
||||
result.exif_aperture = camera.aperture ?? null;
|
||||
result.exif_shutter = camera.shutter_speed ?? null;
|
||||
result.exif_focal = camera.focal_length ? parseFloat(camera.focal_length) : null;
|
||||
result.exif_gps_lat = gps.latitude ?? null;
|
||||
result.exif_gps_lon = gps.longitude ?? null;
|
||||
result.exif_altitude = gps.altitude ?? null;
|
||||
}
|
||||
|
||||
if (data.ocr) {
|
||||
result.ocr_text = data.ocr.text ?? null;
|
||||
result.ocr_language = data.ocr.language ?? null;
|
||||
result.ocr_has_text = data.ocr.has_text ?? false;
|
||||
result.ocr_confidence = data.ocr.confidence ?? null;
|
||||
}
|
||||
|
||||
if (data.ai) {
|
||||
result.ai_description = data.ai.description ?? null;
|
||||
result.ai_tags = data.ai.tags ?? [];
|
||||
result.ai_confidence = data.ai.confidence ?? null;
|
||||
result.ai_model_used = data.ai.model_used ?? null;
|
||||
result.ai_processed_at = data.ai.processed_at ?? null;
|
||||
result.ai_prompt_tokens = data.ai.prompt_tokens ?? null;
|
||||
result.ai_output_tokens = data.ai.output_tokens ?? null;
|
||||
}
|
||||
|
||||
if (data.processing) {
|
||||
result.processing_status = data.processing.status ?? result.processing_status;
|
||||
result.processing_error = data.processing.error ?? result.processing_error;
|
||||
result.processing_started_at = data.processing.started_at ?? result.processing_started_at;
|
||||
result.processing_done_at = data.processing.done_at ?? result.processing_done_at;
|
||||
}
|
||||
|
||||
// Map flat summary fields if they exist (ImageSummary)
|
||||
if (data.ai_tags) result.ai_tags = data.ai_tags;
|
||||
if (data.ai_description) result.ai_description = data.ai_description;
|
||||
|
||||
return result as Image;
|
||||
}
|
||||
|
||||
export async function fetchImages(filters: ImageFilters = {}): Promise<ImagesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.client_id) params.set('client_id', filters.client_id);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.status?.length) params.set('status', filters.status.join(','));
|
||||
if (filters.tags?.length) params.set('tags', filters.tags.join(','));
|
||||
if (filters.date_from) params.set('date_from', filters.date_from);
|
||||
if (filters.date_to) params.set('date_to', filters.date_to);
|
||||
if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps));
|
||||
if (filters.has_ocr !== undefined) params.set('has_ocr', String(filters.has_ocr));
|
||||
if (filters.sort_by) params.set('sort_by', filters.sort_by);
|
||||
if (filters.sort_order) params.set('sort_order', filters.sort_order);
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.page_size) params.set('page_size', String(filters.page_size));
|
||||
|
||||
const query = params.toString();
|
||||
const url = `${ENDPOINTS.IMAGES}${query ? `?${query}` : ''}`;
|
||||
|
||||
const response = await apiGet<any[] | any>(url);
|
||||
|
||||
// Normalize and map the response
|
||||
if (Array.isArray(response)) {
|
||||
return {
|
||||
items: response.map(mapImage),
|
||||
total: response.length,
|
||||
page: 1,
|
||||
page_size: response.length,
|
||||
pages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
items: (response.items || []).map(mapImage)
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchImage(id: number | string): Promise<Image> {
|
||||
const data = await apiGet<any>(ENDPOINTS.IMAGE(id));
|
||||
return mapImage(data);
|
||||
}
|
||||
|
||||
export async function fetchImageThumbnailUrl(id: number | string): Promise<{ url: string }> {
|
||||
return apiGet<{ url: string }>(ENDPOINTS.IMAGE_THUMBNAIL(id));
|
||||
}
|
||||
|
||||
export async function fetchImageDownloadUrl(id: number | string): Promise<{ url: string }> {
|
||||
return apiGet<{ url: string }>(ENDPOINTS.IMAGE_DOWNLOAD(id));
|
||||
}
|
||||
|
||||
export async function fetchImageExif(id: number | string): Promise<Record<string, unknown>> {
|
||||
return apiGet<Record<string, unknown>>(ENDPOINTS.IMAGE_EXIF(id));
|
||||
}
|
||||
|
||||
export async function fetchImageOcr(id: number | string): Promise<Record<string, unknown>> {
|
||||
return apiGet<Record<string, unknown>>(ENDPOINTS.IMAGE_OCR(id));
|
||||
}
|
||||
|
||||
export async function fetchImageAi(id: number | string): Promise<Record<string, unknown>> {
|
||||
return apiGet<Record<string, unknown>>(ENDPOINTS.IMAGE_AI(id));
|
||||
}
|
||||
|
||||
export async function reprocessImage(id: number | string): Promise<void> {
|
||||
return apiPost<void>(ENDPOINTS.IMAGE_REPROCESS(id));
|
||||
}
|
||||
|
||||
export async function deleteImage(id: number | string): Promise<void> {
|
||||
return apiDelete(ENDPOINTS.IMAGE(id));
|
||||
}
|
||||
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
return apiGet<string[]>(ENDPOINTS.IMAGE_TAGS);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiGet } from './client';
|
||||
import { ENDPOINTS } from '@/utils/constants';
|
||||
|
||||
export async function fetchMetricsRaw(): Promise<string> {
|
||||
return apiGet<string>(ENDPOINTS.METRICS);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import Mark from 'mark.js';
|
||||
import { clsx } from 'clsx';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, searchTerm }: MarkdownViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const markInstance = new Mark(containerRef.current);
|
||||
|
||||
// Unmark previous highlights
|
||||
markInstance.unmark({
|
||||
done: () => {
|
||||
// If there's a search term, highlight it
|
||||
if (searchTerm.trim()) {
|
||||
markInstance.mark(searchTerm, {
|
||||
element: 'mark',
|
||||
className: 'bg-brand-500/30 text-brand-300 rounded px-1',
|
||||
separateWordSearch: false,
|
||||
diacritics: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
markInstance.unmark();
|
||||
};
|
||||
}, [content, searchTerm]);
|
||||
|
||||
const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
// Try to find the element by ID as is
|
||||
const rawId = href.slice(1);
|
||||
let element = document.getElementById(rawId);
|
||||
|
||||
// If not found, try decoded ID (for emojis/special chars)
|
||||
if (!element) {
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
element = document.getElementById(decodedId);
|
||||
}
|
||||
|
||||
// If still not found, try slugified version (GH style)
|
||||
if (!element) {
|
||||
const slugId = decodeURIComponent(rawId)
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // remove special chars but keep spaces/hyphens
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
element = document.getElementById(slugId);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
"prose prose-invert max-w-none",
|
||||
"prose-headings:text-content-primary prose-headings:font-bold prose-headings:tracking-tight",
|
||||
"prose-h1:text-3xl prose-h1:mb-8 prose-h1:pb-4 prose-h1:border-b prose-h1:border-border",
|
||||
"prose-h2:text-2xl prose-h2:mt-12 prose-h2:mb-4",
|
||||
"prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3",
|
||||
"prose-p:text-content-secondary prose-p:leading-relaxed prose-p:mb-4",
|
||||
"prose-a:text-brand-400 prose-a:font-medium prose-a:no-underline hover:prose-a:underline transition-colors",
|
||||
"prose-strong:text-content-primary prose-strong:font-semibold",
|
||||
"prose-code:text-brand-300 prose-code:bg-surface-raised prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:font-mono prose-code:before:content-none prose-code:after:content-none",
|
||||
"prose-pre:bg-surface-raised prose-pre:border prose-pre:border-border prose-pre:rounded-xl prose-pre:shadow-inner prose-pre:p-4",
|
||||
"prose-pre:code:bg-transparent prose-pre:code:p-0",
|
||||
"prose-blockquote:border-l-4 prose-blockquote:border-l-brand-500 prose-blockquote:bg-brand-500/5 prose-blockquote:not-italic prose-blockquote:rounded-r-lg prose-blockquote:py-2 prose-blockquote:px-6 prose-blockquote:my-6",
|
||||
"prose-li:text-content-secondary prose-li:my-1",
|
||||
"prose-ul:list-disc prose-ol:list-decimal",
|
||||
"prose-img:rounded-xl prose-img:shadow-lg",
|
||||
"prose-hr:border-border prose-hr:my-12",
|
||||
"prose-table:border-collapse prose-table:w-full prose-table:my-8",
|
||||
"prose-th:bg-surface-raised prose-th:px-4 prose-th:py-2 prose-th:text-left prose-th:border prose-th:border-border",
|
||||
"prose-td:px-4 prose-td:py-2 prose-td:border prose-td:border-border"
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSlug]}
|
||||
components={{
|
||||
a: ({ node, ...props }) => {
|
||||
const { href, ...rest } = props;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...rest}
|
||||
onClick={(e) => href ? handleAnchorClick(e, href) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface PageLayoutProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageLayout({ title, description, actions, children, className }: PageLayoutProps) {
|
||||
return (
|
||||
<div className={clsx('animate-fade-in', className)}>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-content-secondary mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useCallback, createContext, useContext, type ReactNode } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, ListOrdered, BarChart3, Heart, Settings, ScrollText,
|
||||
Images, ChevronLeft, ChevronRight, LogOut, Wifi, WifiOff, BookOpen
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface SidebarContextType {
|
||||
collapsed: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType>({ collapsed: false, toggle: () => { } });
|
||||
export const useSidebar = () => useContext(SidebarContext);
|
||||
|
||||
interface SidebarProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const toggle = useCallback(() => setCollapsed((c) => !c), []);
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, toggle }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const adminLinks = [
|
||||
{ to: '/admin/dashboard', icon: LayoutDashboard, label: 'Tableau de bord' },
|
||||
{ to: '/admin/clients', icon: Users, label: 'Clients & Applications' },
|
||||
{ to: '/admin/queue', icon: ListOrdered, label: 'File de tâches' },
|
||||
{ to: '/admin/metrics', icon: BarChart3, label: 'Métriques' },
|
||||
{ to: '/admin/health', icon: Heart, label: 'Santé' },
|
||||
{ to: '/admin/config', icon: Settings, label: 'Configuration' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Logs temps réel' },
|
||||
{ to: '/admin/aides', icon: BookOpen, label: 'Aides' },
|
||||
];
|
||||
|
||||
const galleryLinks = [
|
||||
{ to: '/gallery', icon: Images, label: 'Toutes les images' },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { collapsed, toggle } = useSidebar();
|
||||
const location = useLocation();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const { data: health } = useHealth();
|
||||
|
||||
const isBackendOnline = health?.status === 'healthy' || health?.status === 'degraded';
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-40 h-screen bg-surface-raised border-r border-border flex flex-col transition-all duration-200',
|
||||
collapsed ? 'w-16' : 'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-border">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-brand-500 to-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
IH
|
||||
</div>
|
||||
<span className="font-semibold text-content-primary">Imago Hub</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-overlay text-content-secondary transition-colors"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-6">
|
||||
{/* Admin section */}
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<p className="text-xs uppercase tracking-wider text-content-muted font-semibold px-3 mb-2">
|
||||
Administration
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{adminLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-150',
|
||||
isActive || location.pathname.startsWith(link.to)
|
||||
? 'bg-brand-500/10 text-brand-400'
|
||||
: 'text-content-secondary hover:text-content-primary hover:bg-surface-overlay',
|
||||
collapsed && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
title={collapsed ? link.label : undefined}
|
||||
>
|
||||
<link.icon size={18} className="shrink-0" />
|
||||
{!collapsed && <span>{link.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery section */}
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<p className="text-xs uppercase tracking-wider text-content-muted font-semibold px-3 mb-2">
|
||||
Galerie
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{galleryLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-brand-500/10 text-brand-400'
|
||||
: 'text-content-secondary hover:text-content-primary hover:bg-surface-overlay',
|
||||
collapsed && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
title={collapsed ? link.label : undefined}
|
||||
>
|
||||
<link.icon size={18} className="shrink-0" />
|
||||
{!collapsed && <span>{link.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border p-3 space-y-2">
|
||||
{/* Backend status */}
|
||||
<div className={clsx('flex items-center gap-2 px-2', collapsed && 'justify-center')}>
|
||||
{isBackendOnline ? (
|
||||
<Wifi size={14} className="text-status-ok" />
|
||||
) : (
|
||||
<WifiOff size={14} className="text-status-error" />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className={clsx('text-xs', isBackendOnline ? 'text-status-ok' : 'text-status-error')}>
|
||||
{isBackendOnline ? 'Backend connecté' : 'Backend hors ligne'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-content-secondary hover:text-status-error hover:bg-status-error/10 transition-colors',
|
||||
collapsed && 'justify-center px-0',
|
||||
)}
|
||||
title="Se déconnecter"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
{!collapsed && <span>Déconnexion</span>}
|
||||
</button>
|
||||
|
||||
{/* Version */}
|
||||
{!collapsed && (
|
||||
<p className="text-[10px] text-content-muted text-center">
|
||||
Imago Admin v1.0.0
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Bell, RefreshCw } from 'lucide-react';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function TopBar() {
|
||||
const { data: health } = useHealth();
|
||||
const queryClient = useQueryClient();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await queryClient.invalidateQueries();
|
||||
setTimeout(() => setRefreshing(false), 500);
|
||||
}, [queryClient]);
|
||||
|
||||
const hasDegradedComponents = health?.status === 'degraded';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 h-16 bg-surface-raised/80 backdrop-blur-sm border-b border-border flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-content-primary sr-only">Imago Hub Admin</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* API Version */}
|
||||
{health?.version && (
|
||||
<span className="text-xs text-content-muted bg-surface-overlay px-2 py-1 rounded">
|
||||
API v{health.version}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 rounded-lg hover:bg-surface-overlay text-content-secondary transition-colors"
|
||||
aria-label="Rafraîchir les données"
|
||||
title="Rafraîchir les données"
|
||||
>
|
||||
<RefreshCw size={16} className={clsx(refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
|
||||
{/* Alerts indicator */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className="p-2 rounded-lg hover:bg-surface-overlay text-content-secondary transition-colors"
|
||||
aria-label="Alertes"
|
||||
>
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
{hasDegradedComponents && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-status-warning rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'warning';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirmer',
|
||||
cancelLabel = 'Annuler',
|
||||
variant = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative bg-surface-raised border border-border rounded-xl shadow-2xl max-w-md w-full animate-slide-in"
|
||||
>
|
||||
<div className="flex items-start gap-4 p-6">
|
||||
<div className={`p-2 rounded-lg ${variant === 'danger' ? 'bg-status-error/10' : 'bg-status-warning/10'}`}>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className={variant === 'danger' ? 'text-status-error' : 'text-status-warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 id="confirm-dialog-title" className="font-semibold text-content-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-content-secondary">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 rounded hover:bg-surface-overlay text-content-muted"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||
<button onClick={onCancel} className="btn-secondary text-sm">
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={variant === 'danger' ? 'btn-danger text-sm' : 'btn-primary text-sm'}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label, className }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
copied
|
||||
? 'bg-status-ok/20 text-status-ok'
|
||||
: 'bg-surface-overlay hover:bg-border text-content-secondary',
|
||||
className,
|
||||
)}
|
||||
aria-label={copied ? 'Copié !' : 'Copier'}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{label && <span>{copied ? 'Copié !' : label}</span>}
|
||||
{!label && <span>{copied ? 'Copié !' : 'Copier'}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { InboxIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col items-center justify-center py-16 text-center', className)}>
|
||||
<div className="p-4 bg-surface-overlay rounded-2xl mb-4">
|
||||
{icon || <InboxIcon size={32} className="text-content-muted" />}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-content-primary mb-1">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-content-secondary max-w-md mb-4">{description}</p>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface HealthDotProps {
|
||||
status: 'ok' | 'warning' | 'error' | string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function HealthDot({ status, size = 'md', animate = true }: HealthDotProps) {
|
||||
const colorClass =
|
||||
status === 'ok' ? 'bg-status-ok' :
|
||||
status === 'warning' ? 'bg-status-warning' :
|
||||
status === 'error' ? 'bg-status-error' :
|
||||
'bg-content-muted';
|
||||
|
||||
const sizeClass =
|
||||
size === 'sm' ? 'w-2 h-2' :
|
||||
size === 'lg' ? 'w-4 h-4' :
|
||||
'w-3 h-3';
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex" role="status" aria-label={`Status: ${status}`}>
|
||||
{animate && status !== 'error' && (
|
||||
<span className={clsx('absolute inline-flex rounded-full opacity-40 animate-ping', colorClass, sizeClass)} />
|
||||
)}
|
||||
<span className={clsx('relative inline-flex rounded-full', colorClass, sizeClass)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchImageThumbnailUrl } from '@/api/images';
|
||||
import { Images, AlertCircle } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface ImageLoaderProps {
|
||||
imageId: number | string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
aspectRatio?: 'aspect-[4/3]' | 'aspect-square' | 'aspect-video';
|
||||
showPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
export function ImageLoader({
|
||||
imageId,
|
||||
alt,
|
||||
className,
|
||||
aspectRatio = 'aspect-[4/3]',
|
||||
showPlaceholder = true
|
||||
}: ImageLoaderProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadUrl() {
|
||||
try {
|
||||
setStatus('loading');
|
||||
const data = await fetchImageThumbnailUrl(imageId);
|
||||
if (isMounted) {
|
||||
setUrl(data.url);
|
||||
setStatus('success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load thumbnail for image ${imageId}:`, err);
|
||||
if (isMounted) {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl();
|
||||
return () => { isMounted = false; };
|
||||
}, [imageId]);
|
||||
|
||||
if (status === 'loading' && showPlaceholder) {
|
||||
return (
|
||||
<div className={clsx(aspectRatio, 'bg-surface-overlay animate-pulse rounded-lg flex items-center justify-center', className)}>
|
||||
<div className="w-8 h-8 rounded-full border-2 border-brand-500/20 border-t-brand-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error' && showPlaceholder) {
|
||||
return (
|
||||
<div className={clsx(aspectRatio, 'bg-status-error/5 border border-status-error/10 rounded-lg flex flex-col items-center justify-center p-4 text-center', className)}>
|
||||
<AlertCircle size={24} className="text-status-error mb-2 opacity-50" />
|
||||
<span className="text-[10px] text-status-error/60 font-medium">Image non disponible</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'success' && url) {
|
||||
return (
|
||||
<div className={clsx(aspectRatio, 'bg-surface-overlay rounded-lg overflow-hidden flex items-center justify-center group-hover:scale-[1.02] transition-transform duration-300', className)}>
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setStatus('error')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(aspectRatio, 'bg-surface-overlay rounded-lg flex items-center justify-center', className)}>
|
||||
<Images size={32} className="text-content-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: LucideIcon;
|
||||
trend?: { value: string; direction: 'up' | 'down' | 'neutral' };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, subtitle, icon: Icon, trend, className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={clsx('card hover:border-border-light transition-colors group', className)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-content-secondary">{title}</p>
|
||||
<p className="text-2xl font-bold text-content-primary">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-content-muted">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2.5 bg-brand-500/10 rounded-lg group-hover:bg-brand-500/20 transition-colors">
|
||||
<Icon size={20} className="text-brand-400" />
|
||||
</div>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1 mt-3 pt-3 border-t border-border">
|
||||
{trend.direction === 'up' && <TrendingUp size={14} className="text-status-ok" />}
|
||||
{trend.direction === 'down' && <TrendingDown size={14} className="text-status-error" />}
|
||||
{trend.direction === 'neutral' && <Minus size={14} className="text-content-muted" />}
|
||||
<span
|
||||
className={clsx(
|
||||
'text-xs font-medium',
|
||||
trend.direction === 'up' && 'text-status-ok',
|
||||
trend.direction === 'down' && 'text-status-error',
|
||||
trend.direction === 'neutral' && 'text-content-muted',
|
||||
)}
|
||||
>
|
||||
{trend.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange, className }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages = getPageNumbers(page, totalPages);
|
||||
|
||||
return (
|
||||
<nav className={clsx('flex items-center gap-1', className)} aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 rounded-lg hover:bg-surface-overlay text-content-secondary disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Page précédente"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-content-muted text-sm">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p as number)}
|
||||
className={clsx(
|
||||
'min-w-[32px] h-8 rounded-lg text-sm font-medium transition-colors',
|
||||
p === page
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-content-secondary hover:bg-surface-overlay',
|
||||
)}
|
||||
aria-current={p === page ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 rounded-lg hover:bg-surface-overlay text-content-secondary disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Page suivante"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | string)[] = [1];
|
||||
|
||||
if (current > 3) pages.push('...');
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (current < total - 2) pages.push('...');
|
||||
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Plan } from '@/types/api.types';
|
||||
import { getPlanColor } from '@/utils/colors';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface PlanBadgeProps {
|
||||
plan: Plan;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<Plan, string> = {
|
||||
free: 'Free',
|
||||
standard: 'Standard',
|
||||
premium: 'Premium',
|
||||
};
|
||||
|
||||
export function PlanBadge({ plan }: PlanBadgeProps) {
|
||||
const colors = getPlanColor(plan);
|
||||
|
||||
return (
|
||||
<span className={clsx('badge', colors.bg, colors.text)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', colors.dot)} />
|
||||
{PLAN_LABELS[plan]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getQuotaColor, getQuotaTextColor } from '@/utils/colors';
|
||||
import { formatPercent } from '@/utils/formatters';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface QuotaBarProps {
|
||||
used: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
formatValue?: (v: number) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuotaBar({ used, total, label, formatValue, className }: QuotaBarProps) {
|
||||
const rawPercent = total === 0 ? 0 : (used / total) * 100;
|
||||
const percent = formatPercent(used, total);
|
||||
const barWidth = Math.min(rawPercent, 100);
|
||||
|
||||
const usedDisplay = formatValue ? formatValue(used) : used.toLocaleString();
|
||||
const totalDisplay = formatValue ? formatValue(total) : total.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-1', className)}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-content-secondary">{label}</span>
|
||||
<span className={clsx('font-mono text-xs', getQuotaTextColor(percent))}>
|
||||
{usedDisplay} / {totalDisplay}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx('h-full rounded-full transition-all duration-500 ease-out', getQuotaColor(percent))}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label ? `${label}: ${percent}%` : `${percent}%`}
|
||||
/>
|
||||
</div>
|
||||
{!label && (
|
||||
<p className={clsx('text-xs font-mono', getQuotaTextColor(percent))}>
|
||||
{percent}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Rechercher…',
|
||||
debounceMs = 300,
|
||||
className,
|
||||
}: SearchInputProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
onChange(newValue);
|
||||
}, debounceMs);
|
||||
},
|
||||
[onChange, debounceMs],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalValue('');
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="input-field w-full pl-9 pr-8"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-surface-overlay text-content-muted"
|
||||
aria-label="Effacer la recherche"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function Skeleton({ className, count = 1 }: SkeletonProps) {
|
||||
if (count > 1) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={clsx('skeleton-shimmer rounded h-4', className)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={clsx('skeleton-shimmer rounded', className)} />;
|
||||
}
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="card animate-pulse space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="skeleton h-4 w-24 rounded" />
|
||||
<div className="skeleton h-8 w-32 rounded" />
|
||||
</div>
|
||||
<div className="skeleton h-10 w-10 rounded-lg" />
|
||||
</div>
|
||||
<div className="skeleton h-3 w-20 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-3 animate-pulse">
|
||||
<div className="skeleton h-4 w-4 rounded" />
|
||||
<div className="skeleton h-4 flex-1 rounded" />
|
||||
<div className="skeleton h-4 w-20 rounded" />
|
||||
<div className="skeleton h-4 w-24 rounded" />
|
||||
<div className="skeleton h-4 w-16 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageGridSkeleton({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="card-compact animate-pulse">
|
||||
<div className="skeleton-shimmer aspect-[4/3] rounded-lg mb-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="skeleton h-4 w-3/4 rounded" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
<div className="flex gap-1">
|
||||
<div className="skeleton h-5 w-12 rounded-full" />
|
||||
<div className="skeleton h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProcessingStatus } from '@/types/api.types';
|
||||
import { getStatusColor } from '@/utils/colors';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: ProcessingStatus | string;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
done: 'Terminé',
|
||||
processing: 'En cours',
|
||||
pending: 'En attente',
|
||||
error: 'Erreur',
|
||||
ok: 'OK',
|
||||
warning: 'Attention',
|
||||
healthy: 'Sain',
|
||||
degraded: 'Dégradé',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, label, size = 'sm' }: StatusBadgeProps) {
|
||||
const colors = getStatusColor(status as ProcessingStatus);
|
||||
const displayLabel = label || STATUS_LABELS[status] || status;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'badge',
|
||||
colors.bg,
|
||||
colors.text,
|
||||
size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-1',
|
||||
)}
|
||||
>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', colors.dot, status === 'processing' && 'animate-pulse')} />
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { create } from 'zustand';
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { X, CheckCircle, AlertTriangle, XCircle, Info } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast) => {
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { ...toast, id }],
|
||||
}));
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
}));
|
||||
|
||||
export function toast(type: Toast['type'], message: string, duration = 5000) {
|
||||
useToastStore.getState().addToast({ type, message, duration });
|
||||
}
|
||||
|
||||
function ToastItem({ t }: { t: Toast }) {
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
removeToast(t.id);
|
||||
}, t.duration || 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [t.id, t.duration, removeToast]);
|
||||
|
||||
const icons: Record<Toast['type'], ReactNode> = {
|
||||
success: <CheckCircle size={16} className="text-status-ok" />,
|
||||
error: <XCircle size={16} className="text-status-error" />,
|
||||
warning: <AlertTriangle size={16} className="text-status-warning" />,
|
||||
info: <Info size={16} className="text-brand-400" />,
|
||||
};
|
||||
|
||||
const bgColors: Record<Toast['type'], string> = {
|
||||
success: 'border-status-ok/30',
|
||||
error: 'border-status-error/30',
|
||||
warning: 'border-status-warning/30',
|
||||
info: 'border-brand-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-start gap-3 p-4 bg-surface-raised border rounded-xl shadow-lg animate-slide-in max-w-sm',
|
||||
bgColors[t.type],
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icons[t.type]}</span>
|
||||
<p className="text-sm text-content-primary flex-1">{t.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(t.id)}
|
||||
className="p-0.5 rounded hover:bg-surface-overlay text-content-muted shrink-0"
|
||||
aria-label="Fermer la notification"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
|
||||
const handleRemove = useCallback((_id: string) => {
|
||||
// handled by ToastItem
|
||||
}, []);
|
||||
|
||||
// Suppress unused warning
|
||||
void handleRemove;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} t={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from '@/api/client';
|
||||
|
||||
interface UseApiOptions<T> {
|
||||
queryKey: string[];
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
refetchInterval?: number;
|
||||
staleTime?: number;
|
||||
select?: (data: T) => T;
|
||||
}
|
||||
|
||||
export function useApi<T>({ queryKey, url, enabled = true, refetchInterval, staleTime = 30_000, select }: UseApiOptions<T>) {
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => apiGet<T>(url, signal),
|
||||
enabled,
|
||||
refetchInterval,
|
||||
staleTime,
|
||||
select,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchClients, fetchClient, createClient, updateClient, deleteClient, rotateClientKey } from '@/api/auth';
|
||||
import type { APIClientCreate, APIClientUpdate } from '@/types/api.types';
|
||||
import { useClientsStore } from '@/store/clients.store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useClients() {
|
||||
const setClients = useClientsStore((s) => s.setClients);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['clients'],
|
||||
queryFn: fetchClients,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setClients(query.data);
|
||||
}
|
||||
}, [query.data, setClients]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useClient(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['client', id],
|
||||
queryFn: () => fetchClient(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: APIClientCreate) => createClient(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClient() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: APIClientUpdate }) =>
|
||||
updateClient(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClient() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteClient(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateClientKey() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => rotateClientKey(id),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchHealthDetailed } from '@/api/health';
|
||||
import { POLLING_INTERVALS } from '@/utils/constants';
|
||||
|
||||
export function useHealth(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: fetchHealthDetailed,
|
||||
refetchInterval: POLLING_INTERVALS.HEALTH,
|
||||
enabled,
|
||||
retry: 1,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchImages, fetchImage, fetchAllTags, reprocessImage, deleteImage } from '@/api/images';
|
||||
import type { ImageFilters } from '@/types/api.types';
|
||||
|
||||
export function useImages(filters: ImageFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['images', filters],
|
||||
queryFn: () => fetchImages(filters),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImage(id: number | string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['image', id],
|
||||
queryFn: () => fetchImage(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: fetchAllTags,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReprocessImage() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number | string) => reprocessImage(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['image'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteImage() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number | string) => deleteImage(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchMetricsRaw } from '@/api/metrics';
|
||||
import { parsePrometheus } from '@/utils/prometheus';
|
||||
import { POLLING_INTERVALS } from '@/utils/constants';
|
||||
|
||||
export function useMetrics(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: async () => {
|
||||
const raw = await fetchMetricsRaw();
|
||||
return parsePrometheus(raw);
|
||||
},
|
||||
refetchInterval: POLLING_INTERVALS.METRICS,
|
||||
enabled,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMetricsRaw(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['metrics-raw'],
|
||||
queryFn: fetchMetricsRaw,
|
||||
refetchInterval: POLLING_INTERVALS.METRICS,
|
||||
enabled,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onMessage: (data: unknown) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
reconnectDelay?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export function useWebSocket(url: string | null, options: UseWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectCountRef = useRef(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const {
|
||||
reconnectDelay = 2000,
|
||||
maxReconnectAttempts = 5,
|
||||
} = options;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectCountRef.current = 0;
|
||||
optionsRef.current.onOpen?.();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
optionsRef.current.onMessage(data);
|
||||
} catch {
|
||||
optionsRef.current.onMessage(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
optionsRef.current.onClose?.();
|
||||
|
||||
// Attempt reconnect with exponential backoff
|
||||
if (reconnectCountRef.current < maxReconnectAttempts) {
|
||||
const delay = reconnectDelay * Math.pow(2, reconnectCountRef.current);
|
||||
reconnectCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
} catch {
|
||||
// Connection failed, will retry via onclose
|
||||
}
|
||||
}, [url, reconnectDelay, maxReconnectAttempts]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null; // Prevent reconnect on cleanup
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const send = useCallback((data: unknown) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { send };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface text-content-primary font-sans;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-surface;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-surface-overlay rounded-full border-2 border-surface;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-border;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-surface-raised border border-border rounded-xl p-6;
|
||||
}
|
||||
|
||||
.card-compact {
|
||||
@apply bg-surface-raised border border-border rounded-lg p-4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-brand-500 hover:bg-brand-600 text-white font-medium px-4 py-2 rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-surface-overlay hover:bg-border-light text-content-primary font-medium px-4 py-2 rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-status-error/10 hover:bg-status-error/20 text-status-error font-medium px-4 py-2 rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-status-error focus:ring-offset-2 focus:ring-offset-surface disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply hover:bg-surface-overlay text-content-secondary hover:text-content-primary font-medium px-4 py-2 rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply bg-surface border border-border rounded-lg px-3 py-2 text-content-primary placeholder-content-muted focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-surface-overlay rounded animate-pulse;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Prose customization for premium readability */
|
||||
.prose pre {
|
||||
@apply bg-surface-raised border border-border rounded-xl shadow-lg !important;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply font-mono !important;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.surface.overlay') 25%,
|
||||
theme('colors.border.DEFAULT') 50%,
|
||||
theme('colors.surface.overlay') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { Eye, EyeOff, LogIn, Loader2 } from 'lucide-react';
|
||||
import type { SimpleHealth } from '@/types/api.types';
|
||||
|
||||
export function Login() {
|
||||
const [key, setKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: health } = useApi<SimpleHealth>({
|
||||
queryKey: ['health-simple'],
|
||||
url: '/health',
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!key.trim()) {
|
||||
setError('Veuillez entrer une clé API');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const success = await login(key.trim());
|
||||
|
||||
if (success) {
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
} else {
|
||||
setError('Clé API invalide ou permissions insuffisantes');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface p-4">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-1/2 -right-1/2 w-full h-full bg-gradient-to-br from-brand-500/5 via-transparent to-indigo-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-1/2 -left-1/2 w-full h-full bg-gradient-to-tr from-brand-600/5 via-transparent to-purple-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo & title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-brand-500 to-indigo-500 rounded-2xl mb-4 shadow-lg shadow-brand-500/20">
|
||||
<span className="text-white font-bold text-2xl">IH</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">Imago Hub</h1>
|
||||
<p className="text-content-secondary mt-1">Portail d'administration</p>
|
||||
{health?.version && (
|
||||
<span className="inline-block mt-2 text-xs text-content-muted bg-surface-raised border border-border px-2 py-0.5 rounded">
|
||||
API v{health.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-surface-raised border border-border rounded-2xl p-8 shadow-xl shadow-black/10"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="api-key" className="block text-sm font-medium text-content-secondary mb-2">
|
||||
Clé API Admin
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="api-key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Entrez votre clé API"
|
||||
className="input-field w-full pr-10 font-mono text-sm"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
aria-describedby={error ? 'login-error' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-surface-overlay text-content-muted transition-colors"
|
||||
aria-label={showKey ? 'Masquer la clé' : 'Afficher la clé'}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id="login-error"
|
||||
className="flex items-center gap-2 p-3 bg-status-error/10 border border-status-error/20 rounded-lg"
|
||||
role="alert"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-status-error rounded-full shrink-0" />
|
||||
<p className="text-sm text-status-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !key.trim()}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Connexion en cours…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn size={16} />
|
||||
Se connecter
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-content-muted mt-6">
|
||||
Connectez-vous avec la clé définie par <code className="text-content-secondary">ADMIN_API_KEY</code> dans le backend
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { PlanBadge } from '@/components/shared/PlanBadge';
|
||||
import { QuotaBar } from '@/components/shared/QuotaBar';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useClient } from '@/hooks/useClients';
|
||||
import { useImages } from '@/hooks/useImages';
|
||||
import { formatDate, formatTokens, formatMB } from '@/utils/formatters';
|
||||
import { ArrowLeft, Images, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function ClientDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: client, isLoading } = useClient(id);
|
||||
const { data: clientImages } = useImages({ client_id: id, page_size: 20, sort_order: 'desc' });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Chargement…">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return (
|
||||
<PageLayout title="Client introuvable">
|
||||
<Link to="/admin/clients" className="btn-secondary flex items-center gap-2 w-fit">
|
||||
<ArrowLeft size={16} /> Retour aux clients
|
||||
</Link>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const storageMbUsed = client.storage_used_bytes / (1024 * 1024);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={client.name}
|
||||
description={`Créé le ${formatDate(client.created_at)}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<PlanBadge plan={client.plan} />
|
||||
<span className={`badge ${client.is_active ? 'bg-status-ok/20 text-status-ok' : 'bg-content-muted/20 text-content-muted'}`}>
|
||||
{client.is_active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
<Link to="/admin/clients" className="btn-ghost text-sm flex items-center gap-1">
|
||||
<ArrowLeft size={14} /> Retour
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Quotas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="card">
|
||||
<QuotaBar
|
||||
label="Images"
|
||||
used={clientImages?.total ?? 0}
|
||||
total={client.quota_images}
|
||||
formatValue={(v) => String(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="card">
|
||||
<QuotaBar
|
||||
label="Stockage"
|
||||
used={storageMbUsed}
|
||||
total={client.quota_storage_mb}
|
||||
formatValue={(v) => formatMB(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="card">
|
||||
<QuotaBar
|
||||
label="Tokens AI"
|
||||
used={0}
|
||||
total={client.quota_ai_tokens}
|
||||
formatValue={(v) => formatTokens(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div className="card mb-8">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-3">Scopes accordés</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{client.scopes.map((scope) => (
|
||||
<span key={scope} className="badge bg-brand-500/10 text-brand-400 font-mono text-xs">
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent images */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-content-secondary">Images récentes</h3>
|
||||
<Link
|
||||
to={`/gallery/client/${client.id}`}
|
||||
className="text-sm text-brand-400 hover:text-brand-300 flex items-center gap-1"
|
||||
>
|
||||
Voir toutes <ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{clientImages?.items?.length ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{clientImages.items.map((img) => (
|
||||
<Link
|
||||
key={img.id}
|
||||
to={`/gallery/image/${img.id}`}
|
||||
className="group card-compact p-2 hover:border-border-light transition-colors"
|
||||
>
|
||||
<div className="aspect-square bg-surface-overlay rounded-lg mb-2 flex items-center justify-center overflow-hidden">
|
||||
<Images size={24} className="text-content-muted group-hover:text-brand-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-xs text-content-primary truncate">{img.original_name}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<StatusBadge status={img.processing_status} />
|
||||
</div>
|
||||
{img.ai_tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{img.ai_tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="text-[10px] text-content-muted bg-surface-overlay px-1 rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-8 text-content-muted">
|
||||
Aucune image pour ce client
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { PlanBadge } from '@/components/shared/PlanBadge';
|
||||
import { QuotaBar } from '@/components/shared/QuotaBar';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { ConfirmDialog } from '@/components/shared/ConfirmDialog';
|
||||
import { CopyButton } from '@/components/shared/CopyButton';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { TableSkeleton } from '@/components/shared/Skeleton';
|
||||
import { toast } from '@/components/shared/Toast';
|
||||
import { useClients, useCreateClient, useDeleteClient, useRotateClientKey } from '@/hooks/useClients';
|
||||
import { formatTokens, formatMB } from '@/utils/formatters';
|
||||
import { Plus, MoreVertical, Eye, Images, KeyRound, UserMinus, Users, X, Loader2 } from 'lucide-react';
|
||||
import type { APIClientCreate, Plan } from '@/types/api.types';
|
||||
import { PLANS, SCOPES } from '@/utils/constants';
|
||||
|
||||
export default function Clients() {
|
||||
const { data: clients, isLoading, error, refetch } = useClients();
|
||||
const createClientMut = useCreateClient();
|
||||
const deleteClientMut = useDeleteClient();
|
||||
const rotateKeyMut = useRotateClientKey();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterPlan, setFilterPlan] = useState<string>('');
|
||||
const [filterActive, setFilterActive] = useState<string>('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
const [newApiKey, setNewApiKey] = useState<string | null>(null);
|
||||
const [keyCopied, setKeyCopied] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formPlan, setFormPlan] = useState<Plan>('free');
|
||||
const [formScopes, setFormScopes] = useState<string[]>(['images:read', 'images:write']);
|
||||
|
||||
const filtered = clients?.filter((c) => {
|
||||
if (search && !c.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (filterPlan && c.plan !== filterPlan) return false;
|
||||
if (filterActive === 'active' && !c.is_active) return false;
|
||||
if (filterActive === 'inactive' && c.is_active) return false;
|
||||
return true;
|
||||
}) ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName.trim()) return;
|
||||
const data: APIClientCreate = {
|
||||
name: formName.trim(),
|
||||
plan: formPlan,
|
||||
scopes: formScopes,
|
||||
};
|
||||
try {
|
||||
const result = await createClientMut.mutateAsync(data);
|
||||
setNewApiKey(result.api_key);
|
||||
setShowForm(false);
|
||||
setFormName('');
|
||||
toast('success', `Client "${data.name}" créé avec succès`);
|
||||
} catch {
|
||||
toast('error', 'Erreur lors de la création du client');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteClientMut.mutateAsync(id);
|
||||
toast('success', 'Client désactivé');
|
||||
setConfirmDelete(null);
|
||||
} catch {
|
||||
toast('error', 'Erreur lors de la désactivation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateKey = async (id: string) => {
|
||||
try {
|
||||
const result = await rotateKeyMut.mutateAsync(id);
|
||||
setNewApiKey(result.api_key);
|
||||
setActiveMenu(null);
|
||||
toast('success', 'Clé API régénérée');
|
||||
} catch {
|
||||
toast('error', 'Erreur lors de la régénération de la clé');
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout title="Clients & Applications">
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-status-error mb-4">Erreur de chargement des clients</p>
|
||||
<button onClick={() => refetch()} className="btn-primary">Réessayer</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Clients & Applications"
|
||||
description={`${clients?.length ?? 0} clients enregistrés`}
|
||||
actions={
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
Nouveau client
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Rechercher un client…" className="w-64" />
|
||||
<select
|
||||
value={filterPlan}
|
||||
onChange={(e) => setFilterPlan(e.target.value)}
|
||||
className="input-field text-sm"
|
||||
aria-label="Filtrer par plan"
|
||||
>
|
||||
<option value="">Tous les plans</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="premium">Premium</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterActive}
|
||||
onChange={(e) => setFilterActive(e.target.value)}
|
||||
className="input-field text-sm"
|
||||
aria-label="Filtrer par statut"
|
||||
>
|
||||
<option value="">Tous les statuts</option>
|
||||
<option value="active">Actif</option>
|
||||
<option value="inactive">Inactif</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun client trouvé"
|
||||
description={search ? 'Essayez avec d\'autres termes de recherche' : 'Créez votre premier client pour commencer'}
|
||||
icon={<Users size={32} className="text-content-muted" />}
|
||||
action={
|
||||
!search && (
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2 mt-2">
|
||||
<Plus size={16} />
|
||||
Créer un client
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Nom</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Plan</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Images</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Stockage</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Tokens AI</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Statut</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((client) => (
|
||||
<tr key={client.id} className="border-b border-border/50 hover:bg-surface-overlay/50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/admin/clients/${client.id}`} className="font-medium text-content-primary hover:text-brand-400 transition-colors">
|
||||
{client.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<PlanBadge plan={client.plan} />
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-secondary">
|
||||
<QuotaBar
|
||||
used={0}
|
||||
total={client.quota_images}
|
||||
formatValue={(v) => String(v)}
|
||||
className="w-32"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaBar
|
||||
used={client.storage_used_bytes / (1024 * 1024)}
|
||||
total={client.quota_storage_mb}
|
||||
formatValue={(v) => formatMB(v)}
|
||||
className="w-32"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaBar
|
||||
used={0}
|
||||
total={client.quota_ai_tokens}
|
||||
formatValue={(v) => formatTokens(v)}
|
||||
className="w-32"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`badge ${client.is_active ? 'bg-status-ok/20 text-status-ok' : 'bg-content-muted/20 text-content-muted'}`}>
|
||||
{client.is_active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setActiveMenu(activeMenu === client.id ? null : client.id)}
|
||||
className="p-1.5 rounded hover:bg-surface-overlay text-content-muted"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{activeMenu === client.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setActiveMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 bg-surface-raised border border-border rounded-lg shadow-xl py-1 w-48 animate-slide-in">
|
||||
<Link to={`/admin/clients/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => setActiveMenu(null)}>
|
||||
<Eye size={14} /> Voir détail
|
||||
</Link>
|
||||
<Link to={`/gallery/client/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => setActiveMenu(null)}>
|
||||
<Images size={14} /> Voir galerie
|
||||
</Link>
|
||||
<button onClick={() => { handleRotateKey(client.id); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary w-full text-left">
|
||||
<KeyRound size={14} /> Régénérer clé
|
||||
</button>
|
||||
<hr className="my-1 border-border" />
|
||||
<button onClick={() => { setConfirmDelete(client.id); setActiveMenu(null); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-status-error/10 text-status-error w-full text-left">
|
||||
<UserMinus size={14} /> Désactiver
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create client drawer */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowForm(false)} />
|
||||
<div className="relative w-full max-w-md bg-surface-raised border-l border-border h-full overflow-y-auto animate-slide-in">
|
||||
<div className="sticky top-0 bg-surface-raised border-b border-border p-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Nouveau client</h2>
|
||||
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-surface-overlay text-content-muted"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">Nom *</label>
|
||||
<input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} className="input-field w-full" placeholder="Mon application" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">Plan</label>
|
||||
<select value={formPlan} onChange={(e) => setFormPlan(e.target.value as Plan)} className="input-field w-full">
|
||||
<option value="free">Free</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="premium">Premium</option>
|
||||
</select>
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{PLANS[formPlan].images === Infinity ? '∞' : PLANS[formPlan].images} images · {formatMB(PLANS[formPlan].storageMb)} · {formatTokens(PLANS[formPlan].aiTokens)} tokens
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">Scopes</label>
|
||||
<div className="space-y-2">
|
||||
{SCOPES.map((scope) => (
|
||||
<label key={scope} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formScopes.includes(scope)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setFormScopes([...formScopes, scope]);
|
||||
else setFormScopes(formScopes.filter((s) => s !== scope));
|
||||
}}
|
||||
className="rounded border-border bg-surface text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-content-secondary font-mono">{scope}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!formName.trim() || createClientMut.isPending}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{createClientMut.isPending ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
|
||||
Créer le client
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Modal */}
|
||||
{newApiKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative bg-surface-raised border border-border rounded-xl shadow-2xl max-w-lg w-full animate-slide-in p-6">
|
||||
<h3 className="text-lg font-semibold text-content-primary mb-4">🔐 Clé API générée</h3>
|
||||
<div className="bg-surface border border-border rounded-lg p-4 font-mono text-sm text-content-primary break-all mb-4">
|
||||
{newApiKey}
|
||||
</div>
|
||||
<CopyButton text={newApiKey} label="Copier la clé" className="mb-4" />
|
||||
<div className="bg-status-error/10 border border-status-error/20 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-status-error font-medium">
|
||||
⚠️ Cette clé ne sera plus jamais affichée. Copiez-la maintenant.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 mb-4 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyCopied}
|
||||
onChange={(e) => setKeyCopied(e.target.checked)}
|
||||
className="rounded border-border bg-surface text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-content-secondary">J'ai bien copié la clé</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => { setNewApiKey(null); setKeyCopied(false); }}
|
||||
disabled={!keyCopied}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmDelete}
|
||||
title="Désactiver ce client ?"
|
||||
message="Le client sera désactivé et ne pourra plus accéder à l'API. Ses données seront conservées."
|
||||
confirmLabel="Désactiver"
|
||||
onConfirm={() => confirmDelete && handleDelete(confirmDelete)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { Settings, Lock, Info } from 'lucide-react';
|
||||
import { PLANS } from '@/utils/constants';
|
||||
import { formatTokens, formatMB } from '@/utils/formatters';
|
||||
|
||||
export default function Config() {
|
||||
const { data: health, isLoading } = useHealth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Configuration">
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Configuration"
|
||||
description="Paramètres de votre instance Imago Hub"
|
||||
>
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 p-4 bg-brand-500/5 border border-brand-500/20 rounded-xl mb-8">
|
||||
<Info size={16} className="text-brand-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-content-secondary">
|
||||
La plupart des paramètres sont configurés via les variables d'environnement du backend.
|
||||
Les modifications nécessitent un redémarrage du service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* General */}
|
||||
<section className="card mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings size={16} className="text-content-muted" />
|
||||
<h3 className="font-semibold text-content-primary">Général</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ConfigRow label="Nom de l'application" value="Imago Hub" readOnly />
|
||||
<ConfigRow label="Version API" value={health?.version || '—'} readOnly />
|
||||
<ConfigRow label="Statut" value={health?.status === 'healthy' ? 'Sain' : 'Dégradé'} readOnly />
|
||||
<ConfigRow label="Dernière vérification" value={health?.checked_at || '—'} readOnly />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans & Quotas */}
|
||||
<section className="card mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lock size={16} className="text-content-muted" />
|
||||
<h3 className="font-semibold text-content-primary">Plans et quotas par défaut</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Plan</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Images</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Stockage</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Tokens AI / mois</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(PLANS).map(([key, plan]) => (
|
||||
<tr key={key} className="border-b border-border/50">
|
||||
<td className="py-2 px-3 font-medium text-content-primary capitalize">{plan.label}</td>
|
||||
<td className="py-2 px-3 text-content-secondary">{plan.images === Infinity ? '∞' : plan.images}</td>
|
||||
<td className="py-2 px-3 text-content-secondary">{formatMB(plan.storageMb)}</td>
|
||||
<td className="py-2 px-3 text-content-secondary">{formatTokens(plan.aiTokens)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<section className="card mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lock size={16} className="text-content-muted" />
|
||||
<h3 className="font-semibold text-content-primary">Rate Limiting (par heure)</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Plan</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Uploads</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Requêtes AI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 px-3 font-medium text-content-primary">Free</td>
|
||||
<td className="py-2 px-3 text-content-secondary">20/h</td>
|
||||
<td className="py-2 px-3 text-content-secondary">50/h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 px-3 font-medium text-content-primary">Standard</td>
|
||||
<td className="py-2 px-3 text-content-secondary">100/h</td>
|
||||
<td className="py-2 px-3 text-content-secondary">200/h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 px-3 font-medium text-content-primary">Premium</td>
|
||||
<td className="py-2 px-3 text-content-secondary">500/h</td>
|
||||
<td className="py-2 px-3 text-content-secondary">1000/h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Components */}
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings size={16} className="text-content-muted" />
|
||||
<h3 className="font-semibold text-content-primary">Composants détectés</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ConfigRow label="Base de données" value={health?.checks?.database?.status === 'ok' ? 'PostgreSQL — Connecté' : 'Non connecté'} readOnly />
|
||||
<ConfigRow label="Redis" value={health?.checks?.redis?.status === 'ok' ? 'Redis 7 — Connecté' : 'Non connecté'} readOnly />
|
||||
<ConfigRow label="OCR" value={health?.checks?.tesseract?.status === 'ok' ? 'Tesseract — Actif' : 'Non disponible'} readOnly />
|
||||
<ConfigRow label="AI Vision" value={health?.checks?.anthropic?.status === 'ok' ? 'Configuré' : 'Non configuré'} readOnly />
|
||||
</div>
|
||||
</section>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, readOnly = false }: { label: string; value: string; readOnly?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-surface/50">
|
||||
<span className="text-sm text-content-secondary">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content-primary font-medium">{value}</span>
|
||||
{readOnly && <Lock size={12} className="text-content-muted" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { MetricCard } from '@/components/shared/MetricCard';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { HealthDot } from '@/components/shared/HealthDot';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useMetrics } from '@/hooks/useMetrics';
|
||||
import { useImages } from '@/hooks/useImages';
|
||||
import { formatBytes, formatTokens, formatNumber, formatTimeAgo, estimateTokenCost, truncate } from '@/utils/formatters';
|
||||
import { sumMetric, getMetricValues } from '@/utils/prometheus';
|
||||
import { getClientColor } from '@/utils/colors';
|
||||
import { Images, Users, Coins, HardDrive, ListOrdered, CheckCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ResponsiveContainer, Tooltip, PieChart, Pie, Cell } from 'recharts';
|
||||
import { CHART_COLORS } from '@/utils/colors';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: health, isLoading: healthLoading } = useHealth();
|
||||
const { data: clients, isLoading: clientsLoading } = useClients();
|
||||
const { data: metrics } = useMetrics();
|
||||
const { data: recentImages } = useImages({ page_size: 10, sort_by: 'uploaded_at', sort_order: 'desc' });
|
||||
|
||||
const isLoading = healthLoading || clientsLoading;
|
||||
|
||||
// Compute KPIs
|
||||
const activeClients = clients?.filter((c) => c.is_active).length ?? 0;
|
||||
const totalClients = clients?.length ?? 0;
|
||||
const totalImages = metrics ? sumMetric(metrics, 'hub_images_uploaded_total') : 0;
|
||||
const totalTokens = metrics ? sumMetric(metrics, 'hub_ai_tokens_consumed_total') : 0;
|
||||
const totalStorage = clients?.reduce((acc, c) => acc + c.storage_used_bytes, 0) ?? 0;
|
||||
const pendingJobs = health?.checks?.queue?.pending_jobs ?? 0;
|
||||
|
||||
// Pipeline success rate
|
||||
const totalPipelineErrors = metrics ? sumMetric(metrics, 'hub_pipeline_errors_total') : 0;
|
||||
const successRate = totalImages > 0 ? ((totalImages - totalPipelineErrors) / totalImages * 100).toFixed(1) : '100.0';
|
||||
|
||||
// Storage chart data
|
||||
const storageData = clients?.filter((c) => c.storage_used_bytes > 0).map((c) => ({
|
||||
name: c.name,
|
||||
value: c.storage_used_bytes,
|
||||
})) ?? [];
|
||||
|
||||
// Uploads by client for donut
|
||||
const uploadsByClient = metrics
|
||||
? Object.entries(
|
||||
getMetricValues(metrics, 'hub_images_uploaded_total').reduce<Record<string, number>>((acc, m) => {
|
||||
const key = m.labels.client_id || 'unknown';
|
||||
acc[key] = (acc[key] || 0) + m.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
).map(([id, count]) => ({ name: id.slice(0, 8), value: count }))
|
||||
: [];
|
||||
|
||||
const healthChecks = health?.checks
|
||||
? Object.entries(health.checks).map(([name, check]) => ({
|
||||
name,
|
||||
status: check.status,
|
||||
latency: 'latency_ms' in check ? (check as { latency_ms?: number }).latency_ms : undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const componentLabels: Record<string, string> = {
|
||||
database: 'Base de données',
|
||||
redis: 'Redis',
|
||||
queue: 'File ARQ',
|
||||
tesseract: 'Tesseract',
|
||||
anthropic: 'API AI',
|
||||
disk: 'Espace disque',
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Tableau de bord">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Tableau de bord"
|
||||
description="Vue d'ensemble de votre instance Imago Hub"
|
||||
actions={
|
||||
health && (
|
||||
<StatusBadge
|
||||
status={health.status === 'healthy' ? 'done' : 'error'}
|
||||
label={health.status === 'healthy' ? 'Sain' : 'Dégradé'}
|
||||
size="md"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
<MetricCard
|
||||
title="Total images"
|
||||
value={formatNumber(totalImages || (recentImages?.total ?? 0))}
|
||||
icon={Images}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Clients actifs"
|
||||
value={`${activeClients} / ${totalClients}`}
|
||||
icon={Users}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Tokens AI"
|
||||
value={formatTokens(totalTokens)}
|
||||
subtitle={estimateTokenCost(totalTokens)}
|
||||
icon={Coins}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Stockage total"
|
||||
value={formatBytes(totalStorage)}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Jobs en attente"
|
||||
value={String(pendingJobs)}
|
||||
icon={ListOrdered}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Taux succès AI"
|
||||
value={`${successRate}%`}
|
||||
icon={CheckCircle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Uploads chart placeholder */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Uploads par client</h3>
|
||||
{uploadsByClient.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={uploadsByClient}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={((props: { name?: string; percent?: number }) => `${props.name ?? ''} (${((props.percent ?? 0) * 100).toFixed(0)}%)`) as never}
|
||||
>
|
||||
{uploadsByClient.map((_, i) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#F1F5F9' }}
|
||||
itemStyle={{ color: '#94A3B8' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[220px] text-content-muted text-sm">
|
||||
Aucune donnée disponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage chart */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Répartition stockage</h3>
|
||||
{storageData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={storageData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={((props: { name?: string }) => props.name ?? '') as never}
|
||||
>
|
||||
{storageData.map((_, i) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={((value: number | string) => typeof value === 'number' ? formatBytes(value) : value) as never}
|
||||
contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#F1F5F9' }}
|
||||
itemStyle={{ color: '#94A3B8' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[220px] text-content-muted text-sm">
|
||||
Aucune donnée de stockage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health checks */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Santé des composants</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{healthChecks.map((check) => (
|
||||
<Link
|
||||
key={check.name}
|
||||
to="/admin/health"
|
||||
className="card-compact flex items-center gap-3 hover:border-border-light transition-colors"
|
||||
>
|
||||
<HealthDot status={check.status} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-content-primary truncate">
|
||||
{componentLabels[check.name] || check.name}
|
||||
</p>
|
||||
{check.latency !== undefined && (
|
||||
<p className="text-xs text-content-muted">{check.latency}ms</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent activity */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Activité récente</h3>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Image</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Client</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Statut</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentImages?.items?.length ? (
|
||||
recentImages.items.map((img) => (
|
||||
<tr key={img.id} className="border-b border-border/50 hover:bg-surface-overlay/50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/gallery/image/${img.id}`} className="flex items-center gap-3 hover:text-brand-400">
|
||||
<div className="w-8 h-8 rounded bg-surface-overlay flex items-center justify-center shrink-0">
|
||||
<Images size={14} className="text-content-muted" />
|
||||
</div>
|
||||
<span className="truncate max-w-[200px]">{truncate(img.original_name, 30)}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className="badge text-xs"
|
||||
style={{
|
||||
backgroundColor: getClientColor(img.client_id) + '20',
|
||||
color: getClientColor(img.client_id),
|
||||
}}
|
||||
>
|
||||
{img.client_name || img.client_id?.slice(0, 8) || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<StatusBadge status={img.processing_status} />
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-muted text-xs">
|
||||
{formatTimeAgo(img.uploaded_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-content-muted">
|
||||
Aucune image uploadée récemment
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Search, Loader2, AlertCircle, BookOpen } from 'lucide-react';
|
||||
import { MarkdownViewer } from '@/components/docs/MarkdownViewer';
|
||||
import { getDocsList, getDocContent } from '@/api/docs';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export default function DocsPage() {
|
||||
const [selectedDoc, setSelectedDoc] = useState<string>('README.md');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data: docsList, isLoading: docsListLoading, error: docsListError } = useQuery({
|
||||
queryKey: ['docsList'],
|
||||
queryFn: getDocsList,
|
||||
});
|
||||
|
||||
const { data: docContent, isLoading: docContentLoading, error: docContentError } = useQuery({
|
||||
queryKey: ['docContent', selectedDoc],
|
||||
queryFn: () => getDocContent(selectedDoc),
|
||||
enabled: !!selectedDoc,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content-primary flex items-center gap-2">
|
||||
<BookOpen size={28} className="text-brand-500" />
|
||||
Aides & Documentation
|
||||
</h1>
|
||||
<p className="text-content-secondary mt-1">
|
||||
Consultez la documentation technique et les guides du projet Imago.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-raised border border-border rounded-xl flex overflow-hidden min-h-[600px] shadow-sm">
|
||||
|
||||
{/* Sidebar with document list */}
|
||||
<div className="w-64 border-r border-border bg-surface flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="font-semibold text-content-primary text-sm uppercase tracking-wider">
|
||||
Sommaire
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{docsListLoading && (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="animate-spin text-brand-500" size={20} />
|
||||
</div>
|
||||
)}
|
||||
{docsListError && (
|
||||
<div className="p-4 text-sm text-status-error flex items-start gap-2 bg-status-error/10 rounded-lg m-2">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>Erreur lors du chargement de la liste.</span>
|
||||
</div>
|
||||
)}
|
||||
{docsList?.map((doc) => (
|
||||
<button
|
||||
key={doc.name}
|
||||
onClick={() => setSelectedDoc(doc.name)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-left transition-colors',
|
||||
selectedDoc === doc.name
|
||||
? 'bg-brand-500/10 text-brand-400 font-medium'
|
||||
: 'text-content-secondary hover:bg-surface-overlay hover:text-content-primary'
|
||||
)}
|
||||
>
|
||||
<FileText size={16} className={selectedDoc === doc.name ? 'text-brand-400' : 'text-content-muted'} />
|
||||
<span className="truncate">{doc.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col bg-surface min-w-0">
|
||||
{/* Toolbar with Search */}
|
||||
<div className="h-14 border-b border-border flex items-center px-6 justify-between shrink-0 bg-surface-raised/50">
|
||||
<div className="font-medium text-content-primary flex items-center gap-2">
|
||||
<FileText size={18} className="text-content-muted" />
|
||||
{docsList?.find(d => d.name === selectedDoc)?.title || selectedDoc}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 pr-4 py-1.5 bg-surface border border-border rounded-lg text-sm text-content-primary placeholder-content-muted focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 w-64 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Viewer */}
|
||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||
{docContentLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface/80 backdrop-blur-sm z-10">
|
||||
<Loader2 className="animate-spin text-brand-500" size={32} />
|
||||
</div>
|
||||
)}
|
||||
{docContentError && (
|
||||
<div className="flex items-center gap-3 p-4 bg-status-error/10 text-status-error rounded-xl max-w-lg mx-auto mt-10">
|
||||
<AlertCircle size={24} />
|
||||
<p>Impossible de charger le document. Le fichier est peut-être manquant.</p>
|
||||
</div>
|
||||
)}
|
||||
{!docContentError && docContent && (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<MarkdownViewer content={docContent.content} searchTerm={searchTerm} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { HealthDot } from '@/components/shared/HealthDot';
|
||||
import { QuotaBar } from '@/components/shared/QuotaBar';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { formatTimeAgo } from '@/utils/formatters';
|
||||
import {
|
||||
Database, Server, ListOrdered, FileText, Bot,
|
||||
HardDrive, RefreshCw, Zap, Cpu
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface ComponentInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const COMPONENTS: ComponentInfo[] = [
|
||||
{ key: 'backend', name: 'Backend API', icon: Server },
|
||||
{ key: 'database', name: 'Base de données', icon: Database },
|
||||
{ key: 'redis', name: 'Redis', icon: Zap },
|
||||
{ key: 'minio', name: 'Stockage Minio', icon: HardDrive },
|
||||
{ key: 'worker', name: 'Worker ARQ', icon: Cpu },
|
||||
{ key: 'queue', name: 'File de tâches', icon: ListOrdered },
|
||||
{ key: 'tesseract', name: 'Tesseract OCR', icon: FileText },
|
||||
{ key: 'ai', name: 'Intelligence Artificielle', icon: Bot },
|
||||
];
|
||||
|
||||
export default function Health() {
|
||||
const { data: health, isLoading, dataUpdatedAt } = useHealth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Santé des composants">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const checks = health?.checks;
|
||||
const lastCheck = dataUpdatedAt ? new Date(dataUpdatedAt).toISOString() : null;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Santé des composants"
|
||||
description={`Statut global : ${health?.status === 'healthy' ? '✅ Sain' : '⚠️ Dégradé'}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
{lastCheck && (
|
||||
<span className="text-xs text-content-muted">
|
||||
Dernière vérification : {formatTimeAgo(lastCheck)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['health'] })}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw size={14} /> Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COMPONENTS.map((comp) => {
|
||||
const check = checks?.[comp.key as keyof typeof checks];
|
||||
if (!check) return null;
|
||||
|
||||
const status = check.status;
|
||||
const Icon = comp.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.key}
|
||||
className={clsx(
|
||||
'card hover:border-border-light transition-colors',
|
||||
status === 'error' && 'border-status-error/30',
|
||||
status === 'warning' && 'border-status-warning/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-surface-overlay rounded-lg">
|
||||
<Icon size={18} className="text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-content-primary">{comp.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<HealthDot status={status} size="md" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Statut</span>
|
||||
<span className={clsx(
|
||||
'font-medium',
|
||||
status === 'ok' && 'text-status-ok',
|
||||
status === 'warning' && 'text-status-warning',
|
||||
status === 'error' && 'text-status-error',
|
||||
)}>
|
||||
{status === 'ok' ? 'OK' : status === 'warning' ? 'Attention' : 'Erreur'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{'latency_ms' in check && check.latency_ms !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Latence</span>
|
||||
<span className="text-content-primary font-mono">{check.latency_ms}ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'pending_jobs' in check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Jobs en attente</span>
|
||||
<span className="text-content-primary font-mono">{(check as { pending_jobs: number }).pending_jobs}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'configured' in check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Configuré</span>
|
||||
<span className="text-content-primary">
|
||||
{(check as { configured: boolean }).configured ? '✅ Oui' : '❌ Non'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'version' in check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Version</span>
|
||||
<span className="text-content-primary font-mono">{(check as { version: string }).version}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'active_workers' in check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Workers actifs</span>
|
||||
<span className="text-content-primary font-mono">{(check as { active_workers: number }).active_workers}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'bucket' in check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-content-muted">Bucket</span>
|
||||
<span className="text-content-primary font-mono">{(check as { bucket: string }).bucket}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{'used_pct' in check && (
|
||||
<div className="mt-2">
|
||||
<QuotaBar
|
||||
label="Espace utilisé"
|
||||
used={(check as { used_pct: number }).used_pct}
|
||||
total={100}
|
||||
formatValue={(v) => `${v}%`}
|
||||
/>
|
||||
{'free_gb' in check && (
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{(check as { free_gb: number }).free_gb} Go libres
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.detail && (
|
||||
<p className="text-xs text-content-muted mt-2 break-all">{check.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { Pause, Play, Trash2, Download } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
||||
event: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
DEBUG: 'text-content-muted',
|
||||
INFO: 'text-content-secondary',
|
||||
WARNING: 'text-status-warning',
|
||||
ERROR: 'text-status-error',
|
||||
};
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('INFO');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const counterRef = useRef(0);
|
||||
|
||||
// Simulate log entries since the backend WS endpoint might not exist
|
||||
const addFakeLog = useCallback(() => {
|
||||
const events = [
|
||||
{ level: 'INFO', event: 'image.uploaded', data: { image_id: Math.floor(Math.random() * 10000), client: 'Shaarli' } },
|
||||
{ level: 'INFO', event: 'pipeline.started', data: { step: 'exif', image_id: Math.floor(Math.random() * 10000) } },
|
||||
{ level: 'INFO', event: 'pipeline.step_complete', data: { step: 'ocr', duration_ms: Math.floor(Math.random() * 2000) } },
|
||||
{ level: 'WARNING', event: 'rate_limit.approaching', data: { client: 'App Mobile', usage: '80%' } },
|
||||
{ level: 'ERROR', event: 'pipeline.step_error', data: { step: 'ai', error: 'API timeout after 120s' } },
|
||||
{ level: 'INFO', event: 'pipeline.completed', data: { image_id: Math.floor(Math.random() * 10000), total_ms: Math.floor(Math.random() * 10000) } },
|
||||
{ level: 'DEBUG', event: 'auth.validated', data: { client_id: 'abc-123' } },
|
||||
] as const;
|
||||
|
||||
const entry = events[Math.floor(Math.random() * events.length)];
|
||||
counterRef.current++;
|
||||
|
||||
return {
|
||||
id: counterRef.current,
|
||||
timestamp: new Date().toISOString(),
|
||||
level: entry.level as LogEntry['level'],
|
||||
event: entry.event,
|
||||
data: entry.data as Record<string, unknown>,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setLogs((prev) => {
|
||||
const newLog = addFakeLog();
|
||||
const updated = [...prev, newLog];
|
||||
return updated.slice(-500); // Keep last 500
|
||||
});
|
||||
}, 2000 + Math.random() * 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, addFakeLog]);
|
||||
|
||||
// Auto scroll
|
||||
useEffect(() => {
|
||||
if (!paused && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, paused]);
|
||||
|
||||
const levelOrder: Record<string, number> = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3 };
|
||||
|
||||
const filtered = logs.filter((log) => {
|
||||
if (levelOrder[log.level] < levelOrder[levelFilter]) return false;
|
||||
if (search && !log.event.toLowerCase().includes(search.toLowerCase()) && !JSON.stringify(log.data || {}).toLowerCase().includes(search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(filtered, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `imago-logs-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Logs temps réel"
|
||||
description="Flux d'événements simulé (connectez un endpoint WebSocket pour les vrais logs)"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPaused(!paused)}
|
||||
className={clsx('btn-secondary text-sm flex items-center gap-2', paused && 'bg-status-warning/20 text-status-warning')}
|
||||
>
|
||||
{paused ? <Play size={14} /> : <Pause size={14} />}
|
||||
{paused ? 'Reprendre' : 'Pause'}
|
||||
</button>
|
||||
<button onClick={() => setLogs([])} className="btn-secondary text-sm flex items-center gap-2">
|
||||
<Trash2 size={14} /> Vider
|
||||
</button>
|
||||
<button onClick={handleExport} className="btn-secondary text-sm flex items-center gap-2">
|
||||
<Download size={14} /> Exporter
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Filtrer les logs…" className="w-64" />
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="input-field text-sm"
|
||||
aria-label="Niveau minimum"
|
||||
>
|
||||
<option value="DEBUG">DEBUG+</option>
|
||||
<option value="INFO">INFO+</option>
|
||||
<option value="WARNING">WARNING+</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<span className="text-xs text-content-muted">
|
||||
{filtered.length} / {logs.length} entrées
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Log viewer */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-[#0a0f1a] border border-border rounded-xl p-4 h-[calc(100vh-320px)] overflow-y-auto font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-content-muted text-center py-8">En attente de logs…</p>
|
||||
) : (
|
||||
filtered.map((log) => (
|
||||
<div key={log.id} className="flex gap-3 py-0.5 hover:bg-white/5 px-2 -mx-2 rounded">
|
||||
<span className="text-content-muted shrink-0 w-[140px]">
|
||||
{formatDateTime(log.timestamp).replace(' à ', ' ')}
|
||||
</span>
|
||||
<span className={clsx('w-[60px] shrink-0 font-semibold', LEVEL_COLORS[log.level])}>
|
||||
{log.level}
|
||||
</span>
|
||||
<span className="text-brand-400 shrink-0">{log.event}</span>
|
||||
{log.data && (
|
||||
<span className="text-content-muted truncate">
|
||||
{JSON.stringify(log.data)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useMetrics } from '@/hooks/useMetrics';
|
||||
import { getMetricValues, sumMetric, groupByLabel } from '@/utils/prometheus';
|
||||
import { formatNumber, formatBytes } from '@/utils/formatters';
|
||||
import { CHART_COLORS } from '@/utils/colors';
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function Metrics() {
|
||||
const { data: metrics, isLoading, error, refetch } = useMetrics();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Métriques">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metrics) {
|
||||
return (
|
||||
<PageLayout title="Métriques">
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-status-error mb-4">Impossible de charger les métriques</p>
|
||||
<button onClick={() => refetch()} className="btn-primary">Réessayer</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Uploads by client
|
||||
const uploadsByClient = Object.entries(groupByLabel(metrics, 'hub_images_uploaded_total', 'client_id'))
|
||||
.map(([id, count]) => ({ name: id.slice(0, 8), uploads: count }))
|
||||
.sort((a, b) => b.uploads - a.uploads)
|
||||
.slice(0, 10);
|
||||
|
||||
// Tokens by client
|
||||
const tokensByClient = Object.entries(groupByLabel(metrics, 'hub_ai_tokens_consumed_total', 'client_id'))
|
||||
.map(([id, tokens]) => ({ name: id.slice(0, 8), tokens }))
|
||||
.sort((a, b) => b.tokens - a.tokens)
|
||||
.slice(0, 10);
|
||||
|
||||
// Storage by client
|
||||
const storageByClient = Object.entries(groupByLabel(metrics, 'hub_storage_used_bytes', 'client_id'))
|
||||
.map(([id, bytes]) => ({ name: id.slice(0, 8), storage: bytes }))
|
||||
.sort((a, b) => b.storage - a.storage);
|
||||
|
||||
// Pipeline errors by step
|
||||
const errorsByStep = Object.entries(groupByLabel(metrics, 'hub_pipeline_errors_total', 'step'))
|
||||
.map(([step, count]) => ({ step, errors: count }));
|
||||
|
||||
// HTTP requests
|
||||
const httpRequests = getMetricValues(metrics, 'http_requests_total');
|
||||
const topEndpoints = Object.entries(
|
||||
httpRequests.reduce<Record<string, number>>((acc, m) => {
|
||||
const key = `${m.labels.method} ${m.labels.path || '/'}`;
|
||||
acc[key] = (acc[key] || 0) + m.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
)
|
||||
.map(([endpoint, count]) => ({ endpoint, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const activeWebSockets = sumMetric(metrics, 'hub_active_websockets');
|
||||
|
||||
const tooltipStyle = {
|
||||
contentStyle: { background: '#1E293B', border: '1px solid #334155', borderRadius: '8px' },
|
||||
labelStyle: { color: '#F1F5F9' },
|
||||
itemStyle: { color: '#94A3B8' },
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Métriques"
|
||||
description="Données Prometheus de votre instance Imago Hub"
|
||||
actions={
|
||||
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-2 text-sm">
|
||||
<RefreshCw size={14} /> Rafraîchir
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="card-compact text-center">
|
||||
<p className="text-xs text-content-muted">Total uploads</p>
|
||||
<p className="text-xl font-bold text-content-primary">{formatNumber(sumMetric(metrics, 'hub_images_uploaded_total'))}</p>
|
||||
</div>
|
||||
<div className="card-compact text-center">
|
||||
<p className="text-xs text-content-muted">Tokens AI</p>
|
||||
<p className="text-xl font-bold text-content-primary">{formatNumber(sumMetric(metrics, 'hub_ai_tokens_consumed_total'))}</p>
|
||||
</div>
|
||||
<div className="card-compact text-center">
|
||||
<p className="text-xs text-content-muted">Erreurs pipeline</p>
|
||||
<p className="text-xl font-bold text-content-primary">{formatNumber(sumMetric(metrics, 'hub_pipeline_errors_total'))}</p>
|
||||
</div>
|
||||
<div className="card-compact text-center">
|
||||
<p className="text-xs text-content-muted">WebSockets actifs</p>
|
||||
<p className="text-xl font-bold text-content-primary">{activeWebSockets}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Uploads by client */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Uploads par client</h3>
|
||||
{uploadsByClient.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={uploadsByClient}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#94A3B8', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#94A3B8', fontSize: 11 }} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Bar dataKey="uploads" fill="#3B82F6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[250px] flex items-center justify-center text-content-muted text-sm">Aucune donnée</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tokens by client */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Tokens AI par client</h3>
|
||||
{tokensByClient.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={tokensByClient}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#94A3B8', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#94A3B8', fontSize: 11 }} tickFormatter={(v) => formatNumber(v)} />
|
||||
<Tooltip {...tooltipStyle} formatter={((value: number) => formatNumber(value)) as never} />
|
||||
<Bar dataKey="tokens" fill="#F59E0B" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[250px] flex items-center justify-center text-content-muted text-sm">Aucune donnée</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage by client */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Stockage par client</h3>
|
||||
{storageByClient.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={storageByClient} dataKey="storage" nameKey="name" cx="50%" cy="50%" innerRadius={50} outerRadius={80} label={((props: { name?: string }) => props.name ?? '') as never}>
|
||||
{storageByClient.map((_, i) => <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip {...tooltipStyle} formatter={((value: number) => formatBytes(value)) as never} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[250px] flex items-center justify-center text-content-muted text-sm">Aucune donnée</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipeline errors */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Erreurs pipeline par étape</h3>
|
||||
{errorsByStep.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={errorsByStep}>
|
||||
<XAxis dataKey="step" tick={{ fill: '#94A3B8', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#94A3B8', fontSize: 11 }} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Bar dataKey="errors" fill="#EF4444" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[250px] flex items-center justify-center text-content-muted text-sm">Aucune erreur</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HTTP Endpoints */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Top endpoints HTTP</h3>
|
||||
{topEndpoints.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 px-3 font-medium text-content-muted">Endpoint</th>
|
||||
<th className="py-2 px-3 font-medium text-content-muted text-right">Requêtes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topEndpoints.map((ep) => (
|
||||
<tr key={ep.endpoint} className="border-b border-border/50">
|
||||
<td className="py-2 px-3 font-mono text-xs text-content-secondary">{ep.endpoint}</td>
|
||||
<td className="py-2 px-3 text-right text-content-primary">{formatNumber(ep.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-content-muted text-sm text-center py-4">Aucune donnée HTTP</p>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { MetricCard } from '@/components/shared/MetricCard';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { toast } from '@/components/shared/Toast';
|
||||
import { useHealth } from '@/hooks/useHealth';
|
||||
import { useImages, useReprocessImage } from '@/hooks/useImages';
|
||||
import { formatTimeAgo } from '@/utils/formatters';
|
||||
import { Play, Clock, CheckCircle, XCircle, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Queue() {
|
||||
const { data: health, isLoading } = useHealth();
|
||||
const { data: errorImages } = useImages({ status: ['error'], page_size: 20, sort_order: 'desc' });
|
||||
const { data: processingImages } = useImages({ status: ['processing'], page_size: 20, sort_order: 'desc' });
|
||||
const reprocessMut = useReprocessImage();
|
||||
|
||||
const pendingJobs = health?.checks?.queue?.pending_jobs ?? 0;
|
||||
const processingCount = processingImages?.items?.length ?? 0;
|
||||
const errorCount = errorImages?.items?.length ?? 0;
|
||||
|
||||
const handleReprocess = async (id: number) => {
|
||||
try {
|
||||
await reprocessMut.mutateAsync(id);
|
||||
toast('success', 'Image remise en file de traitement');
|
||||
} catch {
|
||||
toast('error', 'Erreur lors du retraitement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocessAll = async () => {
|
||||
if (!errorImages?.items?.length) return;
|
||||
for (const img of errorImages.items) {
|
||||
try {
|
||||
await reprocessMut.mutateAsync(img.id);
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
toast('success', `${errorImages.items.length} images remises en file`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="File de tâches ARQ">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="File de tâches ARQ"
|
||||
description="Surveillance du pipeline de traitement d'images"
|
||||
>
|
||||
{/* Counters */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<MetricCard title="En attente" value={String(pendingJobs)} icon={Clock} />
|
||||
<MetricCard title="En cours" value={String(processingCount)} icon={Play} />
|
||||
<MetricCard title="Réussis (récent)" value="—" icon={CheckCircle} />
|
||||
<MetricCard title="Échoués" value={String(errorCount)} icon={XCircle} />
|
||||
</div>
|
||||
|
||||
{/* Processing */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-4">Jobs en cours</h3>
|
||||
{processingCount > 0 ? (
|
||||
<div className="card overflow-hidden p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Image</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Client</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Statut</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Démarré</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processingImages?.items?.map((img) => (
|
||||
<tr key={img.id} className="border-b border-border/50">
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/gallery/image/${img.id}`} className="hover:text-brand-400">
|
||||
#{img.id} — {img.original_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-secondary">{img.client_name}</td>
|
||||
<td className="py-3 px-4"><StatusBadge status="processing" /></td>
|
||||
<td className="py-3 px-4 text-content-muted text-xs">
|
||||
{img.processing_started_at ? formatTimeAgo(img.processing_started_at) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-6 text-content-muted text-sm">
|
||||
Aucun job en cours de traitement
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-content-secondary">Jobs en échec</h3>
|
||||
{errorCount > 0 && (
|
||||
<button onClick={handleReprocessAll} className="btn-secondary text-sm flex items-center gap-2">
|
||||
<RefreshCw size={14} />
|
||||
Relancer tous ({errorCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorCount > 0 ? (
|
||||
<div className="card overflow-hidden p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Image</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Client</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Erreur</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Date</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errorImages?.items?.map((img) => (
|
||||
<tr key={img.id} className="border-b border-border/50">
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/gallery/image/${img.id}`} className="hover:text-brand-400">
|
||||
#{img.id} — {img.original_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-secondary">{img.client_name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-status-error text-xs">{img.processing_error || 'Erreur inconnue'}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-muted text-xs">
|
||||
{img.processing_done_at ? formatTimeAgo(img.processing_done_at) : '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<button
|
||||
onClick={() => handleReprocess(img.id)}
|
||||
disabled={reprocessMut.isPending}
|
||||
className="btn-ghost text-xs px-2 py-1 flex items-center gap-1"
|
||||
>
|
||||
{reprocessMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
|
||||
Relancer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Aucun échec"
|
||||
description="Tous les jobs de traitement ont réussi"
|
||||
icon={<CheckCircle size={32} className="text-status-ok" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { PlanBadge } from '@/components/shared/PlanBadge';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { Pagination } from '@/components/shared/Pagination';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { ImageGridSkeleton } from '@/components/shared/Skeleton';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { ImageLoader } from '@/components/shared/ImageLoader';
|
||||
import { useImages } from '@/hooks/useImages';
|
||||
import { useClient } from '@/hooks/useClients';
|
||||
import { formatDate, formatBytes, truncate } from '@/utils/formatters';
|
||||
import { ArrowLeft, ExternalLink, Images, Camera } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export default function ClientGallery() {
|
||||
const { clientId } = useParams<{ clientId: string }>();
|
||||
const { data: client } = useClient(clientId);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data, isLoading } = useImages({
|
||||
client_id: clientId,
|
||||
search: search || undefined,
|
||||
page,
|
||||
page_size: 20,
|
||||
sort_by: 'uploaded_at',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
|
||||
const images = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = data?.pages ?? 1;
|
||||
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={client ? `Images de "${client.name}"` : 'Galerie client'}
|
||||
description={
|
||||
client
|
||||
? `${total} image${total !== 1 ? 's' : ''} · ${formatBytes(client.storage_used_bytes)} utilisés`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{client && <PlanBadge plan={client.plan} />}
|
||||
{client && (
|
||||
<Link to={`/admin/clients/${client.id}`} className="btn-ghost text-sm flex items-center gap-1">
|
||||
Détail client <ExternalLink size={12} />
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/gallery" className="btn-ghost text-sm flex items-center gap-1">
|
||||
<ArrowLeft size={14} /> Toutes les images
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
placeholder="Rechercher dans les images de ce client…"
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<ImageGridSkeleton count={8} />
|
||||
) : images.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune image"
|
||||
description={search ? 'Aucun résultat pour cette recherche' : 'Ce client n\'a pas encore uploadé d\'images'}
|
||||
icon={<Images size={32} className="text-content-muted" />}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{images.map((img) => (
|
||||
<Link
|
||||
key={img.id}
|
||||
to={`/gallery/image/${img.id}`}
|
||||
className="card-compact group hover:border-border-light transition-all duration-200 hover:-translate-y-0.5"
|
||||
>
|
||||
<ImageLoader
|
||||
imageId={img.id}
|
||||
alt={img.original_name}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-content-primary truncate">{truncate(img.original_name, 25)}</p>
|
||||
<StatusBadge status={img.processing_status} />
|
||||
</div>
|
||||
{img.ai_tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{img.ai_tags?.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-[10px] bg-surface-overlay text-content-muted px-1.5 py-0.5 rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-content-muted">
|
||||
<span>{formatDate(img.uploaded_at)}</span>
|
||||
{img.exif_make && (
|
||||
<span className="flex items-center gap-1"><Camera size={10} /> {img.exif_make}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { Pagination } from '@/components/shared/Pagination';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { ImageGridSkeleton } from '@/components/shared/Skeleton';
|
||||
import { useImages } from '@/hooks/useImages';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useImagesStore } from '@/store/images.store';
|
||||
import { formatDate, formatBytes, truncate } from '@/utils/formatters';
|
||||
import { getClientColor, getClientBgColor } from '@/utils/colors';
|
||||
import { Grid3X3, List, Camera, Tag, X, Filter, Images as ImagesIcon } from 'lucide-react';
|
||||
import { ImageLoader } from '@/components/shared/ImageLoader';
|
||||
import { clsx } from 'clsx';
|
||||
import type { ImageFilters } from '@/types/api.types';
|
||||
|
||||
export default function Gallery() {
|
||||
const { filters, viewMode, setFilters, resetFilters, setViewMode } = useImagesStore();
|
||||
const { data, isLoading, error, refetch } = useImages(filters);
|
||||
const { data: clients } = useClients();
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const images = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = data?.pages ?? 1;
|
||||
|
||||
const handleSearch = useCallback((search: string) => {
|
||||
setFilters({ search: search || undefined });
|
||||
}, [setFilters]);
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setFilters({ page });
|
||||
}, [setFilters]);
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.client_id,
|
||||
filters.tags?.length,
|
||||
filters.status?.length,
|
||||
filters.has_gps,
|
||||
filters.has_ocr,
|
||||
filters.date_from,
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout title="Galerie">
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-status-error mb-4">Erreur de chargement des images</p>
|
||||
<button onClick={() => refetch()} className="btn-primary">Réessayer</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Galerie"
|
||||
description={`${total} image${total !== 1 ? 's' : ''}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={clsx('p-2 rounded-lg transition-colors', viewMode === 'grid' ? 'bg-brand-500/20 text-brand-400' : 'text-content-muted hover:bg-surface-overlay')}
|
||||
aria-label="Vue grille"
|
||||
>
|
||||
<Grid3X3 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={clsx('p-2 rounded-lg transition-colors', viewMode === 'list' ? 'bg-brand-500/20 text-brand-400' : 'text-content-muted hover:bg-surface-overlay')}
|
||||
aria-label="Vue liste"
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<SearchInput
|
||||
value={filters.search || ''}
|
||||
onChange={handleSearch}
|
||||
placeholder="Rechercher dans description, OCR, tags…"
|
||||
className="flex-1 min-w-[250px]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={clsx('btn-secondary text-sm flex items-center gap-2', activeFilterCount > 0 && 'bg-brand-500/10 text-brand-400')}
|
||||
>
|
||||
<Filter size={14} />
|
||||
Filtres {activeFilterCount > 0 && `(${activeFilterCount})`}
|
||||
</button>
|
||||
{activeFilterCount > 0 && (
|
||||
<button onClick={resetFilters} className="text-xs text-content-muted hover:text-content-primary flex items-center gap-1">
|
||||
<X size={12} /> Effacer tout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter chips */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{filters.client_id && (
|
||||
<span className="badge bg-surface-overlay text-content-secondary">
|
||||
Client: {clients?.find((c) => c.id === filters.client_id)?.name || filters.client_id?.slice(0, 8)}
|
||||
<button onClick={() => setFilters({ client_id: undefined })} className="ml-1"><X size={10} /></button>
|
||||
</span>
|
||||
)}
|
||||
{filters.tags?.map((tag) => (
|
||||
<span key={tag} className="badge bg-surface-overlay text-content-secondary">
|
||||
<Tag size={10} /> {tag}
|
||||
<button onClick={() => setFilters({ tags: filters.tags?.filter((t) => t !== tag) })} className="ml-1"><X size={10} /></button>
|
||||
</span>
|
||||
))}
|
||||
{filters.status?.map((s) => (
|
||||
<span key={s} className="badge bg-surface-overlay text-content-secondary">
|
||||
{s}
|
||||
<button onClick={() => setFilters({ status: filters.status?.filter((st) => st !== s) })} className="ml-1"><X size={10} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="card mb-6 animate-slide-in">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">Application</label>
|
||||
<select
|
||||
value={filters.client_id || ''}
|
||||
onChange={(e) => setFilters({ client_id: e.target.value || undefined })}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
{clients?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">Statut pipeline</label>
|
||||
<select
|
||||
value={filters.status?.[0] || ''}
|
||||
onChange={(e) => setFilters({ status: e.target.value ? [e.target.value as ImageFilters['status'] extends (infer U)[] ? U : never] : undefined })}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="done">Terminé</option>
|
||||
<option value="processing">En cours</option>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="error">Erreur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">Tri par</label>
|
||||
<select
|
||||
value={filters.sort_by || 'uploaded_at'}
|
||||
onChange={(e) => setFilters({ sort_by: e.target.value })}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
<option value="uploaded_at">Date d'upload</option>
|
||||
<option value="file_size">Taille</option>
|
||||
<option value="original_name">Nom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">Ordre</label>
|
||||
<select
|
||||
value={filters.sort_order || 'desc'}
|
||||
onChange={(e) => setFilters({ sort_order: e.target.value as 'asc' | 'desc' })}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
<option value="desc">Plus récent d'abord</option>
|
||||
<option value="asc">Plus ancien d'abord</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.has_gps === true}
|
||||
onChange={(e) => setFilters({ has_gps: e.target.checked ? true : undefined })}
|
||||
className="rounded border-border bg-surface text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-content-secondary">Avec GPS</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.has_ocr === true}
|
||||
onChange={(e) => setFilters({ has_ocr: e.target.checked ? true : undefined })}
|
||||
className="rounded border-border bg-surface text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-content-secondary">Avec texte OCR</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<ImageGridSkeleton count={8} />
|
||||
) : images.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune image trouvée"
|
||||
description={filters.search ? 'Essayez avec d\'autres termes' : 'Aucune image n\'a été uploadée'}
|
||||
icon={<ImagesIcon size={32} className="text-content-muted" />}
|
||||
/>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{images.map((img) => (
|
||||
<Link
|
||||
key={img.id}
|
||||
to={`/gallery/image/${img.id}`}
|
||||
className="card-compact group hover:border-border-light transition-all duration-200 hover:-translate-y-0.5"
|
||||
>
|
||||
<ImageLoader
|
||||
imageId={img.id}
|
||||
alt={img.original_name}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-content-primary truncate">{truncate(img.original_name, 25)}</p>
|
||||
<StatusBadge status={img.processing_status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="badge text-[10px]"
|
||||
style={{ backgroundColor: getClientBgColor(img.client_id), color: getClientColor(img.client_id) }}
|
||||
>
|
||||
{img.client_name || img.client_id?.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
{img.ai_tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{img.ai_tags?.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-[10px] bg-surface-overlay text-content-muted px-1.5 py-0.5 rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-content-muted">
|
||||
<span>{formatDate(img.uploaded_at)}</span>
|
||||
{img.exif_make && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Camera size={10} /> {img.exif_make} {img.exif_model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="card overflow-hidden p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Nom</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Client</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Statut</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Taille</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Tags</th>
|
||||
<th className="py-3 px-4 font-medium text-content-muted">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{images.map((img) => (
|
||||
<tr key={img.id} className="border-b border-border/50 hover:bg-surface-overlay/50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<Link to={`/gallery/image/${img.id}`} className="font-medium hover:text-brand-400">
|
||||
{truncate(img.original_name, 30)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="badge text-xs" style={{ backgroundColor: getClientBgColor(img.client_id), color: getClientColor(img.client_id) }}>
|
||||
{img.client_name || img.client_id?.slice(0, 8)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4"><StatusBadge status={img.processing_status} /></td>
|
||||
<td className="py-3 px-4 text-content-muted">{formatBytes(img.file_size)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{img.ai_tags?.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-[10px] bg-surface-overlay text-content-muted px-1 rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-content-muted text-xs">{formatDate(img.uploaded_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Pagination page={filters.page || 1} totalPages={totalPages} onPageChange={handlePageChange} />
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { PageLayout } from '@/components/layout/PageLayout';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { PlanBadge } from '@/components/shared/PlanBadge';
|
||||
import { ConfirmDialog } from '@/components/shared/ConfirmDialog';
|
||||
import { CardSkeleton } from '@/components/shared/Skeleton';
|
||||
import { toast } from '@/components/shared/Toast';
|
||||
import { useImage, useReprocessImage, useDeleteImage } from '@/hooks/useImages';
|
||||
import { useClient } from '@/hooks/useClients';
|
||||
import { formatDate, formatDateTime, formatBytes, formatNumber } from '@/utils/formatters';
|
||||
import { getClientColor, getClientBgColor } from '@/utils/colors';
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Trash2, Camera, FileText, Bot,
|
||||
Settings, UserCircle, Tag, Loader2, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { ImageLoader } from '@/components/shared/ImageLoader';
|
||||
import { clsx } from 'clsx';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import type { LatLngExpression } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix default marker icon
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
iconUrl: markerIcon,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
|
||||
type TabId = 'ai' | 'exif' | 'ocr' | 'tech' | 'provenance';
|
||||
|
||||
export default function ImageDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: image, isLoading } = useImage(id ? Number(id) : undefined);
|
||||
const { data: client } = useClient(image?.client_id);
|
||||
const reprocessMut = useReprocessImage();
|
||||
const deleteMut = useDeleteImage();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('ai');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const handleReprocess = async () => {
|
||||
if (!image) return;
|
||||
try {
|
||||
await reprocessMut.mutateAsync(image.id);
|
||||
toast('success', 'Image remise en file de traitement');
|
||||
} catch {
|
||||
toast('error', 'Erreur lors du retraitement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!image) return;
|
||||
try {
|
||||
await deleteMut.mutateAsync(image.id);
|
||||
toast('success', 'Image supprimée');
|
||||
navigate('/gallery');
|
||||
} catch {
|
||||
toast('error', 'Erreur lors de la suppression');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout title="Chargement…">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
return (
|
||||
<PageLayout title="Image introuvable">
|
||||
<Link to="/gallery" className="btn-secondary flex items-center gap-2 w-fit">
|
||||
<ArrowLeft size={16} /> Retour à la galerie
|
||||
</Link>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const hasGps = image.exif_gps_lat != null && image.exif_gps_lon != null;
|
||||
const position: LatLngExpression | null = hasGps ? [image.exif_gps_lat!, image.exif_gps_lon!] : null;
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: typeof Bot }[] = [
|
||||
{ id: 'ai', label: 'Vision AI', icon: Bot },
|
||||
{ id: 'exif', label: 'EXIF', icon: Camera },
|
||||
{ id: 'ocr', label: 'OCR', icon: FileText },
|
||||
{ id: 'tech', label: 'Technique', icon: Settings },
|
||||
{ id: 'provenance', label: 'Provenance', icon: UserCircle },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={image.original_name}
|
||||
description={`Uploadé le ${formatDateTime(image.uploaded_at)}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={image.processing_status} size="md" />
|
||||
<button onClick={handleReprocess} disabled={reprocessMut.isPending} className="btn-secondary text-sm flex items-center gap-2">
|
||||
{reprocessMut.isPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
Reprocesser
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(true)} className="btn-danger text-sm flex items-center gap-2">
|
||||
<Trash2 size={14} /> Supprimer
|
||||
</button>
|
||||
<Link to="/gallery" className="btn-ghost text-sm flex items-center gap-1">
|
||||
<ArrowLeft size={14} /> Retour
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Image */}
|
||||
<div className="card flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center space-y-4 w-full">
|
||||
<ImageLoader
|
||||
imageId={image.id}
|
||||
alt={image.original_name}
|
||||
aspectRatio="aspect-square"
|
||||
className="max-w-xs mx-auto shadow-lg"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-content-secondary text-sm font-medium">{image.original_name}</p>
|
||||
<p className="text-content-muted text-xs mt-1">
|
||||
{image.width && image.height ? `${image.width} × ${image.height}px · ` : ''}{formatBytes(image.file_size)} · {image.mime_type}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex badge text-xs"
|
||||
style={{ backgroundColor: getClientBgColor(image.client_id), color: getClientColor(image.client_id) }}
|
||||
>
|
||||
{image.client_name || image.client_id?.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Tabs */}
|
||||
<div>
|
||||
<div className="flex gap-1 mb-4 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap',
|
||||
activeTab === tab.id
|
||||
? 'bg-brand-500/10 text-brand-400'
|
||||
: 'text-content-secondary hover:bg-surface-overlay',
|
||||
)}
|
||||
>
|
||||
<tab.icon size={14} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card animate-fade-in">
|
||||
{/* AI Tab */}
|
||||
{activeTab === 'ai' && (
|
||||
<div className="space-y-4">
|
||||
{image.ai_description ? (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-content-muted mb-2">Description</h4>
|
||||
<p className="text-sm text-content-primary leading-relaxed">{image.ai_description}</p>
|
||||
</div>
|
||||
{image.ai_tags?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-content-muted mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{image.ai_tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/gallery?tags=${tag}`}
|
||||
className="badge bg-brand-500/10 text-brand-400 hover:bg-brand-500/20 transition-colors"
|
||||
>
|
||||
<Tag size={10} /> {tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{image.ai_confidence != null && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-content-muted mb-2">Confiance</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-2 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div className="h-full bg-brand-500 rounded-full" style={{ width: `${image.ai_confidence * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-mono text-content-primary">{(image.ai_confidence * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-border">
|
||||
<div className="text-xs"><span className="text-content-muted">Modèle:</span> <span className="text-content-primary">{image.ai_model_used || '—'}</span></div>
|
||||
<div className="text-xs"><span className="text-content-muted">Tokens:</span> <span className="text-content-primary">{image.ai_prompt_tokens ? `${formatNumber(image.ai_prompt_tokens)} prompt / ${formatNumber(image.ai_output_tokens || 0)} output` : '—'}</span></div>
|
||||
<div className="text-xs"><span className="text-content-muted">Date:</span> <span className="text-content-primary">{image.ai_processed_at ? formatDateTime(image.ai_processed_at) : '—'}</span></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-content-muted text-sm text-center py-4">Analyse AI non disponible</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXIF Tab */}
|
||||
{activeTab === 'exif' && (
|
||||
<div className="space-y-3">
|
||||
<ExifRow icon="📷" label="Appareil" value={[image.exif_make, image.exif_model].filter(Boolean).join(' ') || null} />
|
||||
<ExifRow icon="🔭" label="Objectif" value={image.exif_lens} />
|
||||
<ExifRow icon="📅" label="Prise de vue" value={image.exif_taken_at ? formatDateTime(image.exif_taken_at) : null} />
|
||||
<ExifRow icon="⚙️" label="Paramètres" value={
|
||||
[
|
||||
image.exif_iso && `ISO ${image.exif_iso}`,
|
||||
image.exif_aperture && `f/${image.exif_aperture}`,
|
||||
image.exif_shutter,
|
||||
image.exif_focal && `${image.exif_focal}mm`,
|
||||
].filter(Boolean).join(' · ') || null
|
||||
} />
|
||||
<ExifRow icon="📍" label="Localisation" value={hasGps ? `${image.exif_gps_lat!.toFixed(4)}°, ${image.exif_gps_lon!.toFixed(4)}°` : null} />
|
||||
<ExifRow icon="🧭" label="Altitude" value={image.exif_altitude != null ? `${image.exif_altitude}m` : null} />
|
||||
|
||||
{position && (
|
||||
<div className="mt-4 rounded-lg overflow-hidden border border-border" style={{ height: '250px' }}>
|
||||
<MapContainer center={position} zoom={13} style={{ height: '100%', width: '100%' }} scrollWheelZoom={false}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://osm.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={position}>
|
||||
<Popup>{image.original_name}</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
<div className="flex gap-2 p-2 bg-surface-raised text-xs">
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${image.exif_gps_lat},${image.exif_gps_lon}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Google Maps <ExternalLink size={10} />
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${image.exif_gps_lat}&mlon=${image.exif_gps_lon}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
OpenStreetMap <ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR Tab */}
|
||||
{activeTab === 'ocr' && (
|
||||
<div>
|
||||
{image.ocr_has_text && image.ocr_text ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-content-muted">
|
||||
<span>Langue : {image.ocr_language || 'inconnue'}</span>
|
||||
{image.ocr_confidence != null && <span>· Confiance : {(image.ocr_confidence * 100).toFixed(0)}%</span>}
|
||||
</div>
|
||||
<pre className="bg-surface rounded-lg p-4 text-sm text-content-primary font-mono whitespace-pre-wrap max-h-[400px] overflow-y-auto">
|
||||
{image.ocr_text}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-content-muted text-sm text-center py-8">Aucun texte détecté dans cette image</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tech Tab */}
|
||||
{activeTab === 'tech' && (
|
||||
<div className="space-y-3">
|
||||
<TechRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height} px` : '—'} />
|
||||
<TechRow label="Taille" value={formatBytes(image.file_size)} />
|
||||
<TechRow label="Format" value={image.mime_type} />
|
||||
<TechRow label="UUID" value={image.uuid} mono />
|
||||
<TechRow label="ID" value={String(image.id)} />
|
||||
<TechRow label="Fichier" value={image.filename} mono />
|
||||
<TechRow label="Statut pipeline" value={image.processing_status} />
|
||||
{image.processing_error && <TechRow label="Erreur" value={image.processing_error} error />}
|
||||
{image.processing_started_at && <TechRow label="Début traitement" value={formatDateTime(image.processing_started_at)} />}
|
||||
{image.processing_done_at && <TechRow label="Fin traitement" value={formatDateTime(image.processing_done_at)} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provenance Tab */}
|
||||
{activeTab === 'provenance' && (
|
||||
<div className="space-y-4">
|
||||
{client ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: getClientBgColor(client.id) }}>
|
||||
<UserCircle size={20} style={{ color: getClientColor(client.id) }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-content-primary">{client.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<PlanBadge plan={client.plan} />
|
||||
<span className="text-xs text-content-muted">Créé le {formatDate(client.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 space-y-2">
|
||||
<Link
|
||||
to={`/gallery/client/${client.id}`}
|
||||
className="text-sm text-brand-400 hover:text-brand-300 flex items-center gap-1"
|
||||
>
|
||||
Voir toutes les images de cette application <ExternalLink size={12} />
|
||||
</Link>
|
||||
<Link
|
||||
to={`/admin/clients/${client.id}`}
|
||||
className="text-sm text-brand-400 hover:text-brand-300 flex items-center gap-1"
|
||||
>
|
||||
Voir les détails du client <ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-content-muted text-sm">
|
||||
Client: {image.client_name || image.client_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
title="Supprimer cette image ?"
|
||||
message="L'image sera définitivement supprimée ainsi que toutes ses métadonnées."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ExifRow({ icon, label, value }: { icon: string; label: string; value: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm">{icon}</span>
|
||||
<span className="text-sm text-content-muted w-28 shrink-0">{label}</span>
|
||||
<span className="text-sm text-content-primary">{value || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechRow({ label, value, mono, error }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<span className="text-sm text-content-muted w-32 shrink-0">{label}</span>
|
||||
<span className={clsx('text-sm break-all', mono && 'font-mono text-xs', error ? 'text-status-error' : 'text-content-primary')}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import { apiGet } from '@/api/client';
|
||||
import { ENDPOINTS } from '@/utils/constants';
|
||||
|
||||
interface AuthStore {
|
||||
apiKey: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (key: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
apiKey: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (key: string) => {
|
||||
// Temporarily set the key so the apiGet will use it
|
||||
set({ apiKey: key });
|
||||
|
||||
try {
|
||||
// Validate by calling clients endpoint (requires admin scope)
|
||||
await apiGet(ENDPOINTS.CLIENTS);
|
||||
set({ isAuthenticated: true });
|
||||
return true;
|
||||
} catch {
|
||||
// Reset on failure
|
||||
set({ apiKey: null, isAuthenticated: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ apiKey: null, isAuthenticated: false });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import type { APIClient } from '@/types/api.types';
|
||||
|
||||
interface ClientsStore {
|
||||
clients: APIClient[];
|
||||
selectedClientId: string | null;
|
||||
setClients: (clients: APIClient[]) => void;
|
||||
setSelectedClient: (id: string | null) => void;
|
||||
updateClient: (id: string, data: Partial<APIClient>) => void;
|
||||
removeClient: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useClientsStore = create<ClientsStore>((set) => ({
|
||||
clients: [],
|
||||
selectedClientId: null,
|
||||
|
||||
setClients: (clients) => set({ clients }),
|
||||
|
||||
setSelectedClient: (id) => set({ selectedClientId: id }),
|
||||
|
||||
updateClient: (id, data) =>
|
||||
set((state) => ({
|
||||
clients: state.clients.map((c) =>
|
||||
c.id === id ? { ...c, ...data } : c,
|
||||
),
|
||||
})),
|
||||
|
||||
removeClient: (id) =>
|
||||
set((state) => ({
|
||||
clients: state.clients.filter((c) => c.id !== id),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ImageFilters } from '@/types/api.types';
|
||||
|
||||
interface ImagesStore {
|
||||
filters: ImageFilters;
|
||||
viewMode: 'grid' | 'list';
|
||||
setFilters: (filters: Partial<ImageFilters>) => void;
|
||||
resetFilters: () => void;
|
||||
setViewMode: (mode: 'grid' | 'list') => void;
|
||||
}
|
||||
|
||||
const defaultFilters: ImageFilters = {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_by: 'uploaded_at',
|
||||
sort_order: 'desc',
|
||||
};
|
||||
|
||||
export const useImagesStore = create<ImagesStore>((set) => ({
|
||||
filters: { ...defaultFilters },
|
||||
viewMode: 'grid',
|
||||
|
||||
setFilters: (newFilters) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, ...newFilters, page: newFilters.page ?? 1 },
|
||||
})),
|
||||
|
||||
resetFilters: () => set({ filters: { ...defaultFilters } }),
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useAuthStore } from './auth.store';
|
||||
export { useClientsStore } from './clients.store';
|
||||
export { useImagesStore } from './images.store';
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,153 @@
|
||||
// ── Client (APIClient) ─────────────────────────────────────
|
||||
export interface APIClient {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: 'free' | 'standard' | 'premium';
|
||||
scopes: string[];
|
||||
is_active: boolean;
|
||||
quota_images: number;
|
||||
quota_storage_mb: number;
|
||||
quota_ai_tokens: number;
|
||||
storage_used_bytes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface APIClientCreate {
|
||||
name: string;
|
||||
plan: 'free' | 'standard' | 'premium';
|
||||
scopes?: string[];
|
||||
quota_images?: number;
|
||||
quota_storage_mb?: number;
|
||||
quota_ai_tokens?: number;
|
||||
}
|
||||
|
||||
export interface APIClientUpdate {
|
||||
name?: string;
|
||||
plan?: 'free' | 'standard' | 'premium';
|
||||
scopes?: string[];
|
||||
is_active?: boolean;
|
||||
quota_images?: number;
|
||||
quota_storage_mb?: number;
|
||||
quota_ai_tokens?: number;
|
||||
}
|
||||
|
||||
export interface APIClientCreateResponse extends APIClient {
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
// ── Image ──────────────────────────────────────────────────
|
||||
export interface Image {
|
||||
id: number;
|
||||
uuid: string;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
original_name: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
uploaded_at: string;
|
||||
|
||||
// Pipeline
|
||||
processing_status: ProcessingStatus;
|
||||
processing_error: string | null;
|
||||
processing_started_at: string | null;
|
||||
processing_done_at: string | null;
|
||||
|
||||
// EXIF
|
||||
exif_make: string | null;
|
||||
exif_model: string | null;
|
||||
exif_lens: string | null;
|
||||
exif_taken_at: string | null;
|
||||
exif_gps_lat: number | null;
|
||||
exif_gps_lon: number | null;
|
||||
exif_altitude: number | null;
|
||||
exif_iso: number | null;
|
||||
exif_aperture: string | null;
|
||||
exif_shutter: string | null;
|
||||
exif_focal: number | null;
|
||||
|
||||
// OCR
|
||||
ocr_text: string | null;
|
||||
ocr_language: string | null;
|
||||
ocr_has_text: boolean;
|
||||
ocr_confidence: number | null;
|
||||
|
||||
// Vision AI
|
||||
ai_description: string | null;
|
||||
ai_tags: string[];
|
||||
ai_confidence: number | null;
|
||||
ai_model_used: string | null;
|
||||
ai_processed_at: string | null;
|
||||
ai_prompt_tokens: number | null;
|
||||
ai_output_tokens: number | null;
|
||||
}
|
||||
|
||||
export type ProcessingStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||
|
||||
export type Plan = 'free' | 'standard' | 'premium';
|
||||
|
||||
// ── Health Check ───────────────────────────────────────────
|
||||
export interface HealthCheck {
|
||||
status: 'healthy' | 'degraded';
|
||||
version: string;
|
||||
checked_at: string;
|
||||
checks: {
|
||||
database: ComponentHealth;
|
||||
redis: ComponentHealth;
|
||||
queue: ComponentHealth & { pending_jobs: number };
|
||||
tesseract: ComponentHealth;
|
||||
anthropic: ComponentHealth & { configured: boolean };
|
||||
disk: ComponentHealth & { used_pct: number; free_gb: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentHealth {
|
||||
status: 'ok' | 'error' | 'warning';
|
||||
latency_ms?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// ── Pipeline WebSocket ─────────────────────────────────────
|
||||
export interface PipelineEvent {
|
||||
type: 'progress' | 'complete' | 'error';
|
||||
step: 'exif' | 'ocr' | 'ai';
|
||||
status: string;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// ── Image Filters ──────────────────────────────────────────
|
||||
export interface ImageFilters {
|
||||
client_id?: string;
|
||||
tags?: string[];
|
||||
status?: ProcessingStatus[];
|
||||
search?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
exif_date_from?: string;
|
||||
exif_date_to?: string;
|
||||
has_gps?: boolean;
|
||||
has_ocr?: boolean;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
// ── Simple Health ──────────────────────────────────────────
|
||||
export interface SimpleHealth {
|
||||
status: string;
|
||||
version?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface PrometheusMetric {
|
||||
name: string;
|
||||
labels: Record<string, string>;
|
||||
value: number;
|
||||
type?: string;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
export interface MetricFamily {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
metrics: PrometheusMetric[];
|
||||
}
|
||||
|
||||
export interface ParsedMetrics {
|
||||
families: MetricFamily[];
|
||||
metrics: PrometheusMetric[];
|
||||
}
|
||||
|
||||
// Derived metric views for charts
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ClientMetricSummary {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
imagesUploaded: number;
|
||||
tokensConsumed: number;
|
||||
storageUsed: number;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export interface PipelineMetrics {
|
||||
step: string;
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface HttpMetrics {
|
||||
method: string;
|
||||
path: string;
|
||||
requestCount: number;
|
||||
avgDuration: number;
|
||||
errorCount: number;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Plan, ProcessingStatus } from '@/types/api.types';
|
||||
|
||||
// ── Plan Colors ────────────────────────────────────────────
|
||||
const PLAN_COLORS: Record<Plan, { bg: string; text: string; dot: string }> = {
|
||||
free: { bg: 'bg-plan-free/20', text: 'text-plan-free', dot: 'bg-plan-free' },
|
||||
standard: { bg: 'bg-plan-standard/20', text: 'text-plan-standard', dot: 'bg-plan-standard' },
|
||||
premium: { bg: 'bg-plan-premium/20', text: 'text-plan-premium', dot: 'bg-plan-premium' },
|
||||
};
|
||||
|
||||
export function getPlanColor(plan: Plan) {
|
||||
return PLAN_COLORS[plan] ?? PLAN_COLORS.free;
|
||||
}
|
||||
|
||||
// ── Status Colors ──────────────────────────────────────────
|
||||
const STATUS_COLORS: Record<ProcessingStatus, { bg: string; text: string; dot: string }> = {
|
||||
done: { bg: 'bg-status-ok/20', text: 'text-status-ok', dot: 'bg-status-ok' },
|
||||
processing: { bg: 'bg-status-processing/20', text: 'text-status-processing', dot: 'bg-status-processing' },
|
||||
pending: { bg: 'bg-status-pending/20', text: 'text-status-pending', dot: 'bg-status-pending' },
|
||||
error: { bg: 'bg-status-error/20', text: 'text-status-error', dot: 'bg-status-error' },
|
||||
};
|
||||
|
||||
export function getStatusColor(status: ProcessingStatus) {
|
||||
return STATUS_COLORS[status] ?? STATUS_COLORS.pending;
|
||||
}
|
||||
|
||||
// ── Health Colors ──────────────────────────────────────────
|
||||
export function getHealthColor(status: string) {
|
||||
switch (status) {
|
||||
case 'ok': return { bg: 'bg-status-ok/20', text: 'text-status-ok', dot: 'bg-status-ok' };
|
||||
case 'warning': return { bg: 'bg-status-warning/20', text: 'text-status-warning', dot: 'bg-status-warning' };
|
||||
case 'error': return { bg: 'bg-status-error/20', text: 'text-status-error', dot: 'bg-status-error' };
|
||||
default: return { bg: 'bg-content-muted/20', text: 'text-content-muted', dot: 'bg-content-muted' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quota Colors ───────────────────────────────────────────
|
||||
export function getQuotaColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-status-error';
|
||||
if (percent >= 70) return 'bg-status-warning';
|
||||
return 'bg-status-ok';
|
||||
}
|
||||
|
||||
export function getQuotaTextColor(percent: number): string {
|
||||
if (percent >= 90) return 'text-status-error';
|
||||
if (percent >= 70) return 'text-status-warning';
|
||||
return 'text-status-ok';
|
||||
}
|
||||
|
||||
// ── Deterministic Client Color ─────────────────────────────
|
||||
// Generate a consistent HSL color from a client ID
|
||||
export function getClientColor(clientId: string | undefined | null): string {
|
||||
if (!clientId) return 'hsl(215, 16%, 47%)'; // Slate-500 fallback
|
||||
let hash = 0;
|
||||
const idStr = String(clientId);
|
||||
for (let i = 0; i < idStr.length; i++) {
|
||||
hash = idStr.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash % 360);
|
||||
return `hsl(${hue}, 70%, 60%)`;
|
||||
}
|
||||
|
||||
export function getClientBgColor(clientId: string | undefined | null): string {
|
||||
if (!clientId) return 'hsla(215, 16%, 47%, 0.15)';
|
||||
let hash = 0;
|
||||
const idStr = String(clientId);
|
||||
for (let i = 0; i < idStr.length; i++) {
|
||||
hash = idStr.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash % 360);
|
||||
return `hsla(${hue}, 70%, 60%, 0.15)`;
|
||||
}
|
||||
|
||||
// ── Chart Colors ───────────────────────────────────────────
|
||||
export const CHART_COLORS = [
|
||||
'#3B82F6', '#22C55E', '#F59E0B', '#EF4444', '#6366F1',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#8B5CF6', '#06B6D4',
|
||||
];
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
export const APP_TITLE = import.meta.env.VITE_APP_TITLE || 'Imago Hub Admin';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
// Auth
|
||||
CLIENTS: '/api/v1/auth/clients',
|
||||
CLIENT: (id: string) => `/api/v1/auth/clients/${id}`,
|
||||
CLIENT_ROTATE_KEY: (id: string) => `/api/v1/auth/clients/${id}/rotate-key`,
|
||||
|
||||
// Images
|
||||
IMAGES: '/api/v1/images',
|
||||
IMAGE: (id: number | string) => `/api/v1/images/${id}`,
|
||||
IMAGE_STATUS: (id: number | string) => `/api/v1/images/${id}/status`,
|
||||
IMAGE_DOWNLOAD: (id: number | string) => `/api/v1/images/${id}/download-url`,
|
||||
IMAGE_THUMBNAIL: (id: number | string) => `/api/v1/images/${id}/thumbnail-url`,
|
||||
IMAGE_EXIF: (id: number | string) => `/api/v1/images/${id}/exif`,
|
||||
IMAGE_OCR: (id: number | string) => `/api/v1/images/${id}/ocr`,
|
||||
IMAGE_AI: (id: number | string) => `/api/v1/images/${id}/ai`,
|
||||
IMAGE_REPROCESS: (id: number | string) => `/api/v1/images/${id}/reprocess`,
|
||||
IMAGE_UPLOAD: '/api/v1/images/upload',
|
||||
IMAGE_TAGS: '/api/v1/images/tags/all',
|
||||
|
||||
// AI
|
||||
AI_SUMMARIZE: '/api/v1/ai/summarize',
|
||||
AI_DRAFT_TASK: '/api/v1/ai/draft-task',
|
||||
|
||||
// Infrastructure
|
||||
HEALTH: '/health',
|
||||
HEALTH_DETAILED: '/health/detailed',
|
||||
METRICS: '/metrics',
|
||||
|
||||
// WebSocket
|
||||
WS_PIPELINE: (imageId: number | string, token: string) => {
|
||||
if (!API_BASE_URL) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws/pipeline/${imageId}?token=${token}`;
|
||||
}
|
||||
return `${API_BASE_URL.replace('http', 'ws')}/ws/pipeline/${imageId}?token=${token}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const PLANS = {
|
||||
free: { label: 'Free', images: 100, storageMb: 2048, aiTokens: 100000 },
|
||||
standard: { label: 'Standard', images: 1000, storageMb: 20480, aiTokens: 2000000 },
|
||||
premium: { label: 'Premium', images: Infinity, storageMb: 512000, aiTokens: 20000000 },
|
||||
} as const;
|
||||
|
||||
export const SCOPES = [
|
||||
'images:read',
|
||||
'images:write',
|
||||
'ai:use',
|
||||
'admin',
|
||||
] as const;
|
||||
|
||||
export const POLLING_INTERVALS = {
|
||||
HEALTH: 30_000,
|
||||
METRICS: 60_000,
|
||||
QUEUE: 5_000,
|
||||
LOGS: 2_000,
|
||||
} as const;
|
||||
|
||||
export const PAGE_SIZES = [20, 50, 100] as const;
|
||||
|
||||
export const PROCESSING_STATUSES = ['pending', 'processing', 'done', 'error'] as const;
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
formatBytes,
|
||||
formatMB,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatTokens,
|
||||
estimateTokenCost,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatTimeAgo,
|
||||
formatDuration,
|
||||
formatDurationSeconds,
|
||||
truncate,
|
||||
pluralize,
|
||||
} from '@/utils/formatters';
|
||||
|
||||
// ── formatBytes ────────────────────────────────────────────
|
||||
describe('formatBytes', () => {
|
||||
it('returns "0 B" for zero', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatBytes(1024)).toBe('1 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatBytes(1048576)).toBe('1 MB');
|
||||
expect(formatBytes(1572864)).toBe('1.5 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1 GB');
|
||||
});
|
||||
|
||||
it('respects decimal precision', () => {
|
||||
expect(formatBytes(1536, 2)).toBe('1.5 KB');
|
||||
expect(formatBytes(1536, 0)).toBe('2 KB');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatMB ───────────────────────────────────────────────
|
||||
describe('formatMB', () => {
|
||||
it('formats as MB when under 1 GB', () => {
|
||||
expect(formatMB(500)).toBe('500 MB');
|
||||
});
|
||||
|
||||
it('formats as GB when >= 1024 MB', () => {
|
||||
expect(formatMB(1024)).toBe('1.0 GB');
|
||||
expect(formatMB(2560)).toBe('2.5 GB');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatNumber ───────────────────────────────────────────
|
||||
describe('formatNumber', () => {
|
||||
it('formats small numbers with locale', () => {
|
||||
const result = formatNumber(42);
|
||||
expect(result).toMatch(/42/);
|
||||
});
|
||||
|
||||
it('formats thousands as k', () => {
|
||||
expect(formatNumber(1500)).toBe('1.5k');
|
||||
expect(formatNumber(10000)).toBe('10.0k');
|
||||
});
|
||||
|
||||
it('formats millions as M', () => {
|
||||
expect(formatNumber(1500000)).toBe('1.5M');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatPercent ──────────────────────────────────────────
|
||||
describe('formatPercent', () => {
|
||||
it('calculates percent with one decimal', () => {
|
||||
expect(formatPercent(25, 100)).toBe(25);
|
||||
expect(formatPercent(1, 3)).toBeCloseTo(33.3, 0);
|
||||
});
|
||||
|
||||
it('returns 0 when total is 0', () => {
|
||||
expect(formatPercent(5, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles full 100%', () => {
|
||||
expect(formatPercent(100, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatTokens ───────────────────────────────────────────
|
||||
describe('formatTokens', () => {
|
||||
it('formats small token counts as-is', () => {
|
||||
expect(formatTokens(500)).toBe('500');
|
||||
});
|
||||
|
||||
it('formats thousands as k', () => {
|
||||
expect(formatTokens(5000)).toBe('5k');
|
||||
expect(formatTokens(12500)).toBe('13k');
|
||||
});
|
||||
|
||||
it('formats millions as M', () => {
|
||||
expect(formatTokens(1500000)).toBe('1.5M');
|
||||
});
|
||||
});
|
||||
|
||||
// ── estimateTokenCost ──────────────────────────────────────
|
||||
describe('estimateTokenCost', () => {
|
||||
it('formats cost under $1 as cents', () => {
|
||||
const result = estimateTokenCost(50000);
|
||||
expect(result).toMatch(/≈.*¢/);
|
||||
});
|
||||
|
||||
it('formats cost over $1 as dollars', () => {
|
||||
const result = estimateTokenCost(5000000);
|
||||
expect(result).toMatch(/≈.*\$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatDate ─────────────────────────────────────────────
|
||||
describe('formatDate', () => {
|
||||
it('formats ISO date string', () => {
|
||||
const result = formatDate('2025-01-15T10:30:00Z');
|
||||
expect(result).toMatch(/15/);
|
||||
expect(result).toMatch(/2025/);
|
||||
});
|
||||
|
||||
it('returns original string on parse error', () => {
|
||||
expect(formatDate('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatDateTime ─────────────────────────────────────────
|
||||
describe('formatDateTime', () => {
|
||||
it('formats date with time', () => {
|
||||
const result = formatDateTime('2025-06-15T14:30:00Z');
|
||||
expect(result).toMatch(/15/);
|
||||
expect(result).toMatch(/2025/);
|
||||
});
|
||||
|
||||
it('returns original string on parse error', () => {
|
||||
expect(formatDateTime('invalid')).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatTimeAgo ──────────────────────────────────────────
|
||||
describe('formatTimeAgo', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('formats recent time as relative', () => {
|
||||
const result = formatTimeAgo('2025-06-15T11:00:00Z');
|
||||
// Should say something like "il y a environ 1 heure"
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('returns original string on parse error', () => {
|
||||
expect(formatTimeAgo('bad')).toBe('bad');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatDuration ─────────────────────────────────────────
|
||||
describe('formatDuration', () => {
|
||||
it('formats milliseconds', () => {
|
||||
expect(formatDuration(150)).toBe('150ms');
|
||||
expect(formatDuration(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('formats seconds', () => {
|
||||
expect(formatDuration(1500)).toBe('1.5s');
|
||||
expect(formatDuration(30000)).toBe('30.0s');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatDuration(90000)).toBe('1m 30s');
|
||||
expect(formatDuration(125000)).toBe('2m 5s');
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatDurationSeconds ──────────────────────────────────
|
||||
describe('formatDurationSeconds', () => {
|
||||
it('converts seconds to formatted duration', () => {
|
||||
expect(formatDurationSeconds(0.5)).toBe('500ms');
|
||||
expect(formatDurationSeconds(5)).toBe('5.0s');
|
||||
expect(formatDurationSeconds(90)).toBe('1m 30s');
|
||||
});
|
||||
});
|
||||
|
||||
// ── truncate ───────────────────────────────────────────────
|
||||
describe('truncate', () => {
|
||||
it('does not truncate short strings', () => {
|
||||
expect(truncate('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates long strings with ellipsis', () => {
|
||||
expect(truncate('hello world this is long', 10)).toBe('hello wor…');
|
||||
});
|
||||
|
||||
it('handles exact length', () => {
|
||||
expect(truncate('hello', 5)).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pluralize ──────────────────────────────────────────────
|
||||
describe('pluralize', () => {
|
||||
it('returns singular for count 1', () => {
|
||||
expect(pluralize(1, 'image')).toBe('image');
|
||||
});
|
||||
|
||||
it('returns plural by adding s', () => {
|
||||
expect(pluralize(0, 'image')).toBe('images');
|
||||
expect(pluralize(5, 'image')).toBe('images');
|
||||
});
|
||||
|
||||
it('uses custom plural form', () => {
|
||||
expect(pluralize(5, 'souris', 'souris')).toBe('souris');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
// ── Bytes ──────────────────────────────────────────────────
|
||||
export function formatBytes(bytes: number | undefined | null, decimals = 1): string {
|
||||
const b = Number(bytes || 0);
|
||||
if (b === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return `${parseFloat((b / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatMB(mb: number | undefined | null): string {
|
||||
const val = Number(mb || 0);
|
||||
if (val >= 1024) return `${(val / 1024).toFixed(1)} GB`;
|
||||
return `${val} MB`;
|
||||
}
|
||||
|
||||
// ── Numbers ────────────────────────────────────────────────
|
||||
export function formatNumber(n: number | undefined | null): string {
|
||||
const val = Number(n || 0);
|
||||
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`;
|
||||
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}k`;
|
||||
return val.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | undefined | null, total: number | undefined | null): number {
|
||||
const v = Number(value || 0);
|
||||
const t = Number(total || 0);
|
||||
if (t === 0) return 0;
|
||||
return Math.round((v / t) * 1000) / 10;
|
||||
}
|
||||
|
||||
// ── Tokens ─────────────────────────────────────────────────
|
||||
export function formatTokens(tokens: number | undefined | null): string {
|
||||
const t = Number(tokens || 0);
|
||||
if (t >= 1_000_000) return `${(t / 1_000_000).toFixed(1)}M`;
|
||||
if (t >= 1_000) return `${(t / 1_000).toFixed(0)}k`;
|
||||
return t.toString();
|
||||
}
|
||||
|
||||
export function estimateTokenCost(tokens: number | undefined | null): string {
|
||||
const t = Number(tokens || 0);
|
||||
// Approximate cost: $3/M input, $15/M output (Claude Sonnet)
|
||||
const cost = (t / 1_000_000) * 9; // avg
|
||||
if (cost < 1) return `≈ ${(cost * 100).toFixed(0)}¢`;
|
||||
return `≈ ${cost.toFixed(0)}$`;
|
||||
}
|
||||
|
||||
// ── Dates ──────────────────────────────────────────────────
|
||||
export function formatDate(dateStr: string): string {
|
||||
try {
|
||||
return format(parseISO(dateStr), 'd MMM yyyy', { locale: fr });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
try {
|
||||
return format(parseISO(dateStr), 'd MMM yyyy à HH:mm', { locale: fr });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
try {
|
||||
return formatDistanceToNow(parseISO(dateStr), {
|
||||
addSuffix: true,
|
||||
locale: fr,
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duration ───────────────────────────────────────────────
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatDurationSeconds(seconds: number): string {
|
||||
return formatDuration(seconds * 1000);
|
||||
}
|
||||
|
||||
// ── Misc ───────────────────────────────────────────────────
|
||||
export function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string, plural?: string): string {
|
||||
return count === 1 ? singular : (plural ?? singular + 's');
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parsePrometheus,
|
||||
getMetricValues,
|
||||
getMetricByLabels,
|
||||
sumMetric,
|
||||
groupByLabel,
|
||||
} from '@/utils/prometheus';
|
||||
|
||||
const SAMPLE_METRICS = `
|
||||
# HELP hub_images_uploaded_total Total images uploaded
|
||||
# TYPE hub_images_uploaded_total counter
|
||||
hub_images_uploaded_total{client_id="abc123",plan="premium"} 4821
|
||||
hub_images_uploaded_total{client_id="def456",plan="free"} 120
|
||||
hub_images_uploaded_total{client_id="ghi789",plan="standard"} 905
|
||||
|
||||
# HELP hub_ai_tokens_consumed_total Total AI tokens consumed
|
||||
# TYPE hub_ai_tokens_consumed_total counter
|
||||
hub_ai_tokens_consumed_total{client_id="abc123"} 1500000
|
||||
hub_ai_tokens_consumed_total{client_id="def456"} 25000
|
||||
|
||||
# HELP hub_storage_used_bytes Storage used in bytes
|
||||
# TYPE hub_storage_used_bytes gauge
|
||||
hub_storage_used_bytes{client_id="abc123"} 1073741824
|
||||
hub_storage_used_bytes{client_id="def456"} 52428800
|
||||
|
||||
# HELP hub_pipeline_errors_total Pipeline errors
|
||||
# TYPE hub_pipeline_errors_total counter
|
||||
hub_pipeline_errors_total{step="exif"} 3
|
||||
hub_pipeline_errors_total{step="ocr"} 7
|
||||
hub_pipeline_errors_total{step="ai_vision"} 12
|
||||
|
||||
# HELP hub_active_websockets Active WebSocket connections
|
||||
# TYPE hub_active_websockets gauge
|
||||
hub_active_websockets 5
|
||||
`.trim();
|
||||
|
||||
describe('parsePrometheus', () => {
|
||||
it('parses all metrics from sample text', () => {
|
||||
const result = parsePrometheus(SAMPLE_METRICS);
|
||||
expect(result.length).toBe(11);
|
||||
});
|
||||
|
||||
it('extracts metric names correctly', () => {
|
||||
const result = parsePrometheus(SAMPLE_METRICS);
|
||||
const names = [...new Set(result.map((m) => m.name))];
|
||||
expect(names).toContain('hub_images_uploaded_total');
|
||||
expect(names).toContain('hub_ai_tokens_consumed_total');
|
||||
expect(names).toContain('hub_storage_used_bytes');
|
||||
expect(names).toContain('hub_pipeline_errors_total');
|
||||
expect(names).toContain('hub_active_websockets');
|
||||
});
|
||||
|
||||
it('parses labels from metric with labels', () => {
|
||||
const result = parsePrometheus(SAMPLE_METRICS);
|
||||
const first = result[0];
|
||||
expect(first.name).toBe('hub_images_uploaded_total');
|
||||
expect(first.labels.client_id).toBe('abc123');
|
||||
expect(first.labels.plan).toBe('premium');
|
||||
expect(first.value).toBe(4821);
|
||||
});
|
||||
|
||||
it('parses metric without labels', () => {
|
||||
const result = parsePrometheus(SAMPLE_METRICS);
|
||||
const ws = result.find((m) => m.name === 'hub_active_websockets');
|
||||
expect(ws).toBeDefined();
|
||||
expect(ws!.value).toBe(5);
|
||||
expect(Object.keys(ws!.labels)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(parsePrometheus('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles only comments', () => {
|
||||
const result = parsePrometheus('# HELP foo bar\n# TYPE foo counter\n');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores malformed lines', () => {
|
||||
const result = parsePrometheus('this is not a metric\nfoo{bar="baz"} 42\n');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe(42);
|
||||
});
|
||||
|
||||
it('handles NaN values gracefully', () => {
|
||||
const result = parsePrometheus('test_metric NaN');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses special float values', () => {
|
||||
const result = parsePrometheus('test_metric 1.5e3');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe(1500);
|
||||
});
|
||||
|
||||
it('parses labels with escaped quotes', () => {
|
||||
const result = parsePrometheus('test{msg="hello \\"world\\""} 1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels.msg).toBe('hello "world"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetricValues', () => {
|
||||
const metrics = parsePrometheus(SAMPLE_METRICS);
|
||||
|
||||
it('returns all metrics with a given name', () => {
|
||||
const uploads = getMetricValues(metrics, 'hub_images_uploaded_total');
|
||||
expect(uploads).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns empty array for unknown metric', () => {
|
||||
expect(getMetricValues(metrics, 'nonexistent')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetricByLabels', () => {
|
||||
const metrics = parsePrometheus(SAMPLE_METRICS);
|
||||
|
||||
it('finds metric matching all labels', () => {
|
||||
const val = getMetricByLabels(metrics, 'hub_images_uploaded_total', {
|
||||
client_id: 'abc123',
|
||||
plan: 'premium',
|
||||
});
|
||||
expect(val).toBe(4821);
|
||||
});
|
||||
|
||||
it('returns null for non-matching labels', () => {
|
||||
const val = getMetricByLabels(metrics, 'hub_images_uploaded_total', {
|
||||
client_id: 'unknown',
|
||||
});
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown metric name', () => {
|
||||
const val = getMetricByLabels(metrics, 'nonexistent', {});
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumMetric', () => {
|
||||
const metrics = parsePrometheus(SAMPLE_METRICS);
|
||||
|
||||
it('sums all values for a metric name', () => {
|
||||
const total = sumMetric(metrics, 'hub_images_uploaded_total');
|
||||
expect(total).toBe(4821 + 120 + 905);
|
||||
});
|
||||
|
||||
it('sums pipeline errors', () => {
|
||||
expect(sumMetric(metrics, 'hub_pipeline_errors_total')).toBe(3 + 7 + 12);
|
||||
});
|
||||
|
||||
it('returns 0 for nonexistent metric', () => {
|
||||
expect(sumMetric(metrics, 'nonexistent')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns single value for metrics without grouping', () => {
|
||||
expect(sumMetric(metrics, 'hub_active_websockets')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByLabel', () => {
|
||||
const metrics = parsePrometheus(SAMPLE_METRICS);
|
||||
|
||||
it('groups uploads by client_id', () => {
|
||||
const grouped = groupByLabel(metrics, 'hub_images_uploaded_total', 'client_id');
|
||||
expect(grouped).toEqual({
|
||||
abc123: 4821,
|
||||
def456: 120,
|
||||
ghi789: 905,
|
||||
});
|
||||
});
|
||||
|
||||
it('groups pipeline errors by step', () => {
|
||||
const grouped = groupByLabel(metrics, 'hub_pipeline_errors_total', 'step');
|
||||
expect(grouped).toEqual({
|
||||
exif: 3,
|
||||
ocr: 7,
|
||||
ai_vision: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses "unknown" for missing labels', () => {
|
||||
const grouped = groupByLabel(metrics, 'hub_active_websockets', 'client_id');
|
||||
expect(grouped).toEqual({ unknown: 5 });
|
||||
});
|
||||
|
||||
it('returns empty object for nonexistent metric', () => {
|
||||
expect(groupByLabel(metrics, 'nonexistent', 'x')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PrometheusMetric } from '@/types/metrics.types';
|
||||
|
||||
/**
|
||||
* Parse Prometheus exposition format text into structured metric objects.
|
||||
*
|
||||
* Format example:
|
||||
* # HELP hub_images_uploaded_total Total images uploaded
|
||||
* # TYPE hub_images_uploaded_total counter
|
||||
* hub_images_uploaded_total{client_id="abc",plan="premium"} 4821
|
||||
*/
|
||||
export function parsePrometheus(text: string): PrometheusMetric[] {
|
||||
const metrics: PrometheusMetric[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
let currentHelp = '';
|
||||
let currentType = '';
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
if (line.startsWith('# HELP')) {
|
||||
currentHelp = line.slice(7).trim();
|
||||
} else if (line.startsWith('# TYPE')) {
|
||||
const parts = line.slice(7).trim().split(/\s+/);
|
||||
currentType = parts[1] || '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseMetricLine(line);
|
||||
if (parsed) {
|
||||
parsed.help = currentHelp;
|
||||
parsed.type = currentType;
|
||||
metrics.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function parseMetricLine(line: string): PrometheusMetric | null {
|
||||
// Match: metric_name{label="value",...} number [timestamp]
|
||||
// Or: metric_name number [timestamp]
|
||||
const match = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s*(\{[^}]*\})?\s+([0-9eE.+\-NnIifnaty]+)(?:\s+(\d+))?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const name = match[1];
|
||||
const labelsStr = match[2] || '';
|
||||
const valueStr = match[3];
|
||||
|
||||
const value = parseFloat(valueStr);
|
||||
if (isNaN(value)) return null;
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
if (labelsStr) {
|
||||
// Parse {key="value",key2="value2"}
|
||||
const labelRegex = /([a-zA-Z_][a-zA-Z0-9_]*)="([^"\\]*(?:\\.[^"\\]*)*)"/g;
|
||||
let m;
|
||||
while ((m = labelRegex.exec(labelsStr)) !== null) {
|
||||
labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
return { name, labels, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics with a specific name
|
||||
*/
|
||||
export function getMetricValues(
|
||||
metrics: PrometheusMetric[],
|
||||
name: string,
|
||||
): PrometheusMetric[] {
|
||||
return metrics.filter((m) => m.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single metric value by name and matching labels
|
||||
*/
|
||||
export function getMetricByLabels(
|
||||
metrics: PrometheusMetric[],
|
||||
name: string,
|
||||
labels: Record<string, string>,
|
||||
): number | null {
|
||||
const found = metrics.find(
|
||||
(m) =>
|
||||
m.name === name &&
|
||||
Object.entries(labels).every(([k, v]) => m.labels[k] === v),
|
||||
);
|
||||
return found?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum all metric values with a given name
|
||||
*/
|
||||
export function sumMetric(metrics: PrometheusMetric[], name: string): number {
|
||||
return getMetricValues(metrics, name).reduce((acc, m) => acc + m.value, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group metrics by a specific label
|
||||
*/
|
||||
export function groupByLabel(
|
||||
metrics: PrometheusMetric[],
|
||||
name: string,
|
||||
label: string,
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const m of getMetricValues(metrics, name)) {
|
||||
const key = m.labels[label] || 'unknown';
|
||||
result[key] = (result[key] || 0) + m.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_APP_TITLE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
declare const config: Config;
|
||||
export default config;
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
import forms from '@tailwindcss/forms';
|
||||
import typography from '@tailwindcss/typography';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3B82F6',
|
||||
600: '#2563EB',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: '#0F172A',
|
||||
raised: '#1E293B',
|
||||
overlay: '#334155',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#334155',
|
||||
light: '#475569',
|
||||
},
|
||||
content: {
|
||||
primary: '#F1F5F9',
|
||||
secondary: '#94A3B8',
|
||||
muted: '#64748B',
|
||||
},
|
||||
status: {
|
||||
ok: '#22C55E',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
pending: '#6366F1',
|
||||
processing: '#3B82F6',
|
||||
},
|
||||
plan: {
|
||||
free: '#64748B',
|
||||
standard: '#3B82F6',
|
||||
premium: '#F59E0B',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.15s ease-out',
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
'spin-slow': 'spin 2s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(-4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [forms, typography],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite-env.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"tailwind.config.ts",
|
||||
"postcss.config.js"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/health.ts","./src/api/images.ts","./src/api/metrics.ts","./src/components/layout/pagelayout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/shared/confirmdialog.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/healthdot.tsx","./src/components/shared/imageloader.tsx","./src/components/shared/metriccard.tsx","./src/components/shared/pagination.tsx","./src/components/shared/planbadge.tsx","./src/components/shared/quotabar.tsx","./src/components/shared/searchinput.tsx","./src/components/shared/skeleton.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/toast.tsx","./src/hooks/useapi.ts","./src/hooks/useclients.ts","./src/hooks/usehealth.ts","./src/hooks/useimages.ts","./src/hooks/usemetrics.ts","./src/hooks/usewebsocket.ts","./src/pages/login.tsx","./src/pages/admin/clientdetail.tsx","./src/pages/admin/clients.tsx","./src/pages/admin/config.tsx","./src/pages/admin/dashboard.tsx","./src/pages/admin/health.tsx","./src/pages/admin/logs.tsx","./src/pages/admin/metrics.tsx","./src/pages/admin/queue.tsx","./src/pages/gallery/clientgallery.tsx","./src/pages/gallery/gallery.tsx","./src/pages/gallery/imagedetail.tsx","./src/store/auth.store.ts","./src/store/clients.store.ts","./src/store/images.store.ts","./src/store/index.ts","./src/test/setup.ts","./src/types/api.types.ts","./src/types/metrics.types.ts","./src/utils/colors.ts","./src/utils/constants.ts","./src/utils/formatters.test.ts","./src/utils/formatters.ts","./src/utils/prometheus.test.ts","./src/utils/prometheus.ts"],"errors":true,"version":"5.9.3"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/auth': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/images': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ai': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/metrics': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user