Add admin panel, WebSocket support, and API versioning
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

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:
2026-06-22 11:25:22 -04:00
parent d68deb9c74
commit 2f2a5e9b4a
158 changed files with 64811 additions and 503 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.md
*.test.ts
*.test.tsx
.env
.env.*
.vscode
.idea
coverage
+5
View File
@@ -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"
+24
View File
@@ -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?
+16
View File
@@ -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;"]
+115
View File
@@ -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.
+104
View File
@@ -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:
+23
View File
@@ -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,
},
},
])
+50
View File
@@ -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)
+18
View File
@@ -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>
+48
View File
@@ -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";
}
}
+7692
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+10
View File
@@ -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

+1
View File
@@ -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

+42
View File
@@ -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;
}
+105
View File
@@ -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>
);
}
+27
View File
@@ -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));
}
+122
View File
@@ -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 };
+19
View File
@@ -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)}`);
}
+11
View File
@@ -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);
}
+156
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
import { apiGet } from './client';
import { ENDPOINTS } from '@/utils/constants';
export async function fetchMetricsRaw(): Promise<string> {
return apiGet<string>(ENDPOINTS.METRICS);
}
+1
View File
@@ -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>
);
}
+23
View File
@@ -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,
});
}
+70
View File
@@ -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),
});
}
+14
View File
@@ -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,
});
}
+49
View File
@@ -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'] });
},
});
}
+29
View File
@@ -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,
});
}
+82
View File
@@ -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 };
}
+109
View File
@@ -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;
}
}
+10
View File
@@ -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>,
);
+139
View File
@@ -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>
);
}
+346
View File
@@ -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>
);
}
+142
View File
@@ -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>
);
}
+284
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+165
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+311
View File
@@ -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='&copy; <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>
);
}
+35
View File
@@ -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 });
},
}));
+32
View File
@@ -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),
})),
}));
+31
View File
@@ -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 }),
}));
+3
View File
@@ -0,0 +1,3 @@
export { useAuthStore } from './auth.store';
export { useClientsStore } from './clients.store';
export { useImagesStore } from './images.store';
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+153
View File
@@ -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;
}
+51
View File
@@ -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;
}
+81
View File
@@ -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];
}
+64
View File
@@ -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;
+227
View File
@@ -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');
});
});
+100
View File
@@ -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');
}
+191
View File
@@ -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({});
});
});
+115
View File
@@ -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;
}
+10
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
import type { Config } from 'tailwindcss';
declare const config: Config;
export default config;
+75
View File
@@ -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;
+28
View File
@@ -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"]
}
+39
View File
@@ -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"
}
]
}
+26
View File
@@ -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
+1
View File
@@ -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"}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+42
View File
@@ -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,
},
},
},
});
+20
View File
@@ -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'),
},
},
});