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
+33
View File
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added (Phase 3: DX)
- **WebSockets / Real-Time**:
- Subscriptions on per-image workflows (`/ws/pipeline/{image_id}`).
- Redis 60-second Event Buffering for reconnections.
- Admin Monitor WebSocket feed (`/ws/admin/monitor`).
- **REST API Versioning**:
- Global `/api/v1/` prefix for structural stability.
- Added strict API Sunset Date tracking middleware.
- **Python SDK (`imago-client`)**:
- Publishable PyPI-ready package located inside `sdk/`.
- Automatic `httpx` HTTP reconnects, WebSockets streaming natively wrapped, and robust generic Error classes (`ImagoError`, `AuthError`, etc.).
- **Admin Backend APIs**:
- Complete JSON statistics (`/admin/api/stats`), global client configurations (`/admin/api/clients/*`) and Queue observability natively piped to the existing React Interface.
- **Shaarli Integrations**:
- Skeleton integration middleware (`integration/shaarli`) capable of bridging Shaarli's events right into Imago using the official SDK.
- **Extensive Documentation**:
- Modular guides provided inside `docs/` detailing WebSockets, Integrations, and SDK consumption.
### Changed
- All API routing endpoints have been moved behind the `/api/v1` namespace. Existing API Clients must update base URLs from `/` to `/api/v1/`.
### Fixed
- Fixed unawaited coroutines in async mocks causing `TypeError` on Pytest environments.
- Fixed an incorrect payload key mapping in `APIClient` responses causing 500s (`AttributeError`).
- Mocked S3 and ARQ correctly globally to ensure local automated tests pass completely isolated from network logic.
+59 -377
View File
@@ -1,403 +1,85 @@
# Imago
# Imago Hub
Backend FastAPI pour la gestion d'images et fonctionnalités AI, conçu comme complément à l'interface Web-thème **Shaarli-Pro** et toute autre applications qui voudrait l'utiliser.
Imago est un hub Backend & Traitement IA multi-tenant open-source dédié à la gestion d'images. Conçu pour propulser des applications frontend riches (comme Shaarli), il offre un stockage média hautement concurrent, des traitements asynchrones planifiés, une observabilité complète du pipeline et des intégrations poussées avec l'Intelligence Artificielle.
---
## ✨ Fonctionnalités Principales
## Fonctionnalités
- 📸 **API d'Upload & Stockage :** Stockage d'images isolé par client (multi-tenant), génération automatique de miniatures, support S3 (MinIO) ou stockage local.
- 🔍 **Extraction de Métadonnées :** Découverte en temps réel des données EXIF, géolocalisation GPS, résolution et caractéristiques des fichiers.
- 📝 **Pipelines OCR :** Extraction de texte hors-ligne intégrée aux images via le moteur Tesseract.
- 🤖 **Vision IA Intégrée :** Résumé automatique de contenu, description de scènes, et classification par tags (Propulsé par les API Gemini & OpenRouter).
- ⚡ **Flux de Données en Temps Réel :** Streaming d'événements WebSocket encapsulant les opérations lourdes de la file d'attente (ARQ / Redis).
- 🔐 **Sécurité & Multi-tenancy :** Authentification par clé API, gestion de quotas (Rate Limiting) basés sur des plans, isolation totale des données entre les clients.
- 💻 **Portail d'Administration :** Interface React complète pour gérer les clients API, surveiller la file d'attente et consulter les métriques.
| Feature | Description |
|---|---|
| 📸 Upload d'images | Stockage + thumbnails, isolés par client (multi-tenant) |
| 🛡️ Sécurité | Authentification par API Key + scopes granulaires + Rate Limiting |
| 🔍 Extraction EXIF | Appareil photo, GPS, ISO, ouverture, date de prise de vue |
| 📝 OCR | Extraction du texte visible dans l'image (Tesseract + fallback AI) |
| 🤖 Vision AI | Description naturelle + classification par tags (Gemini / OpenRouter) |
| 🔗 Résumé URL | Scraping d'une page web + résumé AI |
| ✅ Rédaction de tâches | Génération structurée d'une tâche à partir d'une description |
| 📋 File de tâches ARQ | Pipeline persistant avec retry automatique (Redis) |
| 📊 Métriques Prometheus | Endpoint `/metrics` + custom counters/histograms |
| 💾 Storage abstrait | Local (HMAC signed URLs) ou S3/MinIO (presigned URLs) |
| 📈 Quota tracking | Suivi du stockage par client avec enforcement |
| 🔧 Logging structuré | JSON (production) / Console colorée (dev) via structlog |
## 📚 Documentation Complète
---
Pour une compréhension approfondie du projet, veuillez consulter les documents suivants dans le répertoire `doc/` :
## Stack technique
- 📖 **[Guide d'Utilisateur (USER_GUIDE.md)](doc/USER_GUIDE.md) :** Explications détaillées sur l'utilisation des fonctionnalités, le portail admin et les concepts clés.
- 🏗️ **[Architecture (ARCHITECTURE.md)](doc/ARCHITECTURE.md) :** Plongée technique dans la stack (FastAPI, React, Redis, ARQ, MinIO) et le fonctionnement du pipeline asynchrone.
- 📡 **[Guide de l'API (API_GUIDE.md)](doc/API_GUIDE.md) :** Référence complète des endpoints REST, de l'authentification et des exemples de requêtes.
- 🔌 **[Guide SDK Python](docs/sdk.md) & [WebSockets](docs/websocket.md) :** (Dans le dossier `docs/`) Pour l'intégration programmatique bas-niveau.
```
FastAPI + Uvicorn — Framework web async
SQLAlchemy (async) — ORM + SQLite/Postgres
ARQ + Redis — File de tâches persistante
Pillow + piexif — Traitement d'images + EXIF
pytesseract — OCR (Tesseract)
Google GenAI SDK — Vision AI (Gemini)
aioboto3 — Stockage S3/MinIO
structlog — Logging structuré JSON
Prometheus — Métriques et monitoring
slowapi — Rate limiting par client
ruff — Linting + formatting
```
*Note: La spécification OpenAPI pour l'écosystème `/api/v1/` peut être consultée dynamiquement sur `http://localhost:8000/docs` (une fois l'application lancée).*
---
## 🚀 Installation via Docker (Recommandé)
## Installation
Le projet fournit une configuration `docker-compose.yml` complète qui orchestre tous les services nécessaires :
1. **admin** : Portail d'administration (React + Nginx) sur le port `3000`
2. **backend** : API Imago (FastAPI) sur le port `8000`
3. **db** : Base de données PostgreSQL
4. **redis** : Serveur Redis (Broker & Cache)
5. **worker** : Worker ARQ pour les tâches IA & OCR en arrière-plan
6. **minio** : Serveur de stockage objet compatible S3 sur les ports `9000` (API) et `9001` (Console)
### Prérequis
- Python 3.12+ (3.14 supporté)
- Tesseract OCR installé sur le système
- Redis (optionnel, pour ARQ worker)
### Étapes de déploiement
```bash
# Ubuntu/Debian
sudo apt install tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng
# macOS
brew install tesseract tesseract-lang
# Windows
scoop install main/tesseract
```
### Setup
> [!NOTE]
> Les fichiers `requirements.txt` appliquent automatiquement des versions compatibles Python 3.14 (notamment pour Pillow et Pydantic).
```bash
# 1. Cloner et installer
git clone <repo>
# 1. Cloner le dépôt
git clone <repo_url>
cd imago
# Production
# 2. Configuration de l'environnement
cp .env.example .env
# (Éditez le fichier .env si nécessaire, notamment pour ajouter vos clés API IA : GEMINI_API_KEY ou OPENROUTER_API_KEY)
# 3. Lancer toute la stack
docker-compose up -d --build
# 4. Appliquer les migrations de base de données (si non faites automatiquement)
docker-compose exec backend alembic upgrade head
```
L'API sera disponible sur [http://localhost:8000](http://localhost:8000) et le panel admin sur [http://localhost:3000](http://localhost:3000).
---
## 💻 Installation Locale (Développement)
Si vous souhaitez exécuter le projet sans Docker (nécessite Python 3.10+, PostgreSQL ou SQLite, Redis et Tesseract OCR) :
```bash
# 1. Setup Venv
pip install -r requirements.txt
# Développement (inclut linting, tests, pre-commit)
pip install -r requirements-dev.txt
pre-commit install
# 2. Configuration
# 2. Base de données & Migrations
cp .env.example .env
# Éditer .env — voir la section Configuration ci-dessous
# 3. Migrations
alembic upgrade head
# La migration crée un client "default" et affiche sa clé API une seule fois.
# 4. Démarrage
python run.py # API sur http://localhost:8000
python worker.py # Worker ARQ (requiert Redis)
# 3. Démarrage
python run.py # API Endpoint -> http://localhost:8000
python worker.py # Required ARQ Redis Jobs
```
### Avec Docker
## 🧪 Tests
Nous exécutons une suite de tests automatisée résiliente (80+ tests) simulant nativement des buckets S3 et l'infrastructure ARQ sans dépendance réseau externe :
```bash
docker-compose up -d # API + Redis + Worker + PostgreSQL
```
### Commandes utiles (Makefile)
```bash
make help # Affiche toutes les commandes
make install-dev # Dépendances dev + pre-commit
make lint # Ruff + Mypy
make format # Auto-format du code
make test # Tests rapides
make test-cov # Tests + couverture HTML
make serve # Serveur dev (reload)
make worker # Worker ARQ
make docker-up # Docker compose up
make clean # Nettoyage __pycache__, .pytest_cache, etc.
```
---
## Pipeline de traitement AI
Chaque image uploadée déclenche un pipeline via ARQ (Redis) :
```
Upload → Sauvegarde fichier + thumbnail → BDD (PENDING)
│
▼ (ARQ Worker — persistant, avec retry)
┌────────────────────────────────────────┐
│ Étape 1 — Extraction EXIF │
│ Étape 2 — OCR (Tesseract + AI) │
│ Étape 3 — Vision AI (Gemini) │
└────────────────────────────────────────┘
│
▼
BDD (DONE) + événements Redis pub/sub
```
> [!IMPORTANT]
> **Isolation multi-tenant** : fichiers et données sont compartimentés par `client_id`. Un client ne peut ni voir, ni modifier, ni supprimer les images d'un autre.
Chaque étape est **indépendante** : un échec partiel n'arrête pas le pipeline.
**Priority queues** : les clients `premium` ont une file dédiée.
**Retry automatique** avec backoff exponentiel. Dead-letter après max retries.
---
## Endpoints API
### Images (Auth requise)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/images/upload` | Uploader une image | `images:write` |
| `GET` | `/images` | Lister (paginé, filtrable, quota info) | `images:read` |
| `GET` | `/images/{id}` | Détail complet | `images:read` |
| `GET` | `/images/{id}/status` | Statut du pipeline | `images:read` |
| `GET` | `/images/{id}/exif` | Métadonnées EXIF | `images:read` |
| `GET` | `/images/{id}/ocr` | Texte OCR extrait | `images:read` |
| `GET` | `/images/{id}/ai` | Description + tags AI | `images:read` |
| `GET` | `/images/{id}/download-url` | URL signée de téléchargement | `images:read` |
| `GET` | `/images/{id}/thumbnail-url` | URL signée du thumbnail | `images:read` |
| `GET` | `/images/tags/all` | Tous les tags du client | `images:read` |
| `POST` | `/images/{id}/reprocess` | Relancer le pipeline | `images:write` |
| `DELETE` | `/images/{id}` | Supprimer image | `images:delete` |
### Intelligence Artificielle (Auth requise)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/ai/summarize` | Résumé AI d'une URL | `ai:use` |
| `POST` | `/ai/draft-task` | Rédaction de tâche | `ai:use` |
### Administration & Clients (Admin only)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/auth/clients` | Créer un client API | `admin` |
| `GET` | `/auth/clients` | Lister les clients | `admin` |
| `GET` | `/auth/clients/{id}` | Détail d'un client | `admin` |
| `PATCH` | `/auth/clients/{id}` | Modifier client (plan, scopes) | `admin` |
| `POST` | `/auth/clients/{id}/rotate-key` | Régénérer clé API | `admin` |
| `DELETE` | `/auth/clients/{id}` | Soft delete (désactivation) | `admin` |
### Fichiers signés
| Méthode | URL | Description | Auth |
|---|---|---|---|
| `GET` | `/files/signed/{token}` | Télécharger via URL signée | Token HMAC |
### Observabilité & Santé
| Méthode | URL | Description | Auth |
|---|---|---|---|
| `GET` | `/` | Info application | Non |
| `GET` | `/health` | Santé du service | Non |
| `GET` | `/health/detailed` | Santé détaillée (DB, Redis, ARQ, OCR) | Non |
| `GET` | `/metrics` | Métriques Prometheus | Non |
| `GET` | `/docs` | Documentation Swagger | Non |
---
## Exemples d'utilisation
> [!TIP]
> Tous les appels (sauf `/health` et `/metrics`) nécessitent une clé API valide passée dans le header `X-API-Key`.
### Upload d'une image
### api_key=emH92l92LD4L7cLhl2imidMZANsIUb9x_AlGWiYpVSA client_id=925463e0-27a4-4993-aa3a-f1cb31c19d32 warning=Notez cette clé ! Elle ne sera plus affichée.
```bash
curl -X POST http://localhost:8000/images/upload \
-H "X-API-Key: rEYQtw3LxJJlcmBq-cgQcdeY74JcpJ45COuFWokmxPg" \
-F "[email protected]"
```
Réponse :
```json
{
"id": 1,
"uuid": "a1b2c3d4-...",
"original_name": "photo.jpg",
"status": "pending",
"message": "Image uploadée — traitement AI en cours"
}
```
### Polling du statut
```bash
curl http://localhost:8000/images/1/status -H "X-API-Key: rEYQtw3LxJJlcmBq-cgQcdeY74JcpJ45COuFWokmxPg"
```
```json
{
"id": 1,
"status": "done",
"started_at": "2024-01-15T10:30:00",
"done_at": "2024-01-15T10:30:08"
}
```
### Détail complet
```bash
curl http://localhost:8000/images/1 -H "X-API-Key: rEYQtw3LxJJlcmBq-cgQcdeY74JcpJ45COuFWokmxPg"
```
```json
{
"id": 1,
"original_name": "photo.jpg",
"processing_status": "done",
"exif": {
"camera": {
"make": "Canon",
"model": "EOS R5",
"iso": 400,
"aperture": "f/2.8",
"shutter_speed": "1/250",
"focal_length": "50mm",
"taken_at": "2024-06-15T14:30:00"
},
"gps": {
"latitude": 48.8566,
"longitude": 2.3522,
"has_gps": true,
"maps_url": "https://maps.google.com/?q=48.8566,2.3522"
}
},
"ocr": {
"has_text": true,
"text": "Café de Flore",
"language": "fr",
"confidence": 0.94
},
"ai": {
"description": "Une terrasse de café parisien animée en fin d'après-midi. Les tables en osier sont disposées sur le trottoir boulevard Saint-Germain, avec une clientèle détendue profitant du soleil. L'enseigne emblématique du Café de Flore est visible en arrière-plan.",
"tags": ["café", "paris", "terrasse", "france", "bistrot", "extérieur", "urbain"],
"confidence": 0.97,
"model_used": "gemini-1.5-pro"
}
}
```
### Résumé d'URL
```bash
curl -X POST http://localhost:8000/ai/summarize \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/article", "language": "français"}'
```
### Rédaction de tâche
```bash
curl -X POST http://localhost:8000/ai/draft-task \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"description": "Mettre à jour la documentation technique du projet backend",
"context": "Projet Imago, backend FastAPI"
}'
```
---
## Configuration
| Variable | Défaut | Description |
|---|---|---|
| `ADMIN_API_KEY` | — | Clé maîtresse pour gérer les clients API |
| `JWT_SECRET_KEY` | — | Secret pour la signature des tokens |
| `AI_PROVIDER` | `gemini` | `gemini` ou `openrouter` |
| `GEMINI_API_KEY` | — | Clé API Gemini |
| `DATABASE_URL` | PostgreSQL (Docker) / SQLite (Local) | URL de connexion (Postgres recommandé) |
| `REDIS_URL` | `redis://localhost:6379/0` | URL Redis pour ARQ |
| `STORAGE_BACKEND` | `local` | `local` ou `s3` |
| `S3_BUCKET` | — | Bucket S3/MinIO |
| `S3_ENDPOINT_URL` | — | Endpoint S3 custom (MinIO/R2) |
| `S3_ACCESS_KEY` | — | Clé d'accès S3 |
| `S3_SECRET_KEY` | — | Secret S3 |
| `SIGNED_URL_SECRET` | — | Secret HMAC pour URLs signées locales |
| `MAX_UPLOAD_SIZE_MB` | `50` | Taille max par upload |
| `OCR_ENABLED` | `true` | Activer/désactiver l'OCR |
| `WORKER_MAX_JOBS` | `10` | Concurrence du worker ARQ |
| `WORKER_MAX_TRIES` | `3` | Retries max avant dead-letter |
| `DEBUG` | `false` | Mode debug (console logging) |
---
## Tests
```bash
# Tests rapides
make test
# Tests avec couverture
make test-cov
# Directement
python -m pytest tests/ -v --cov=app
# Ou directement :
pytest tests/ -v
```
76 tests couvrant : auth, isolation multi-tenant, rate limiting, pipeline, EXIF, OCR, AI, stockage.
---
## CI/CD
Le pipeline GitHub Actions (`.github/workflows/ci.yml`) exécute :
1. **Lint** — ruff check + format + mypy
2. **Tests** — pytest + couverture
3. **Sécurité** — bandit via ruff (règles S)
4. **Docker** — build + smoke test (branche main uniquement)
---
## Structure du projet
```
imago/
├── app/
│ ├── main.py # FastAPI + lifespan + Prometheus
│ ├── config.py # Settings Pydantic
│ ├── database.py # Engine SQLAlchemy async
│ ├── logging_config.py # structlog JSON/Console
│ ├── metrics.py # Prometheus custom metrics
│ ├── models/
│ │ ├── image.py # Modèle Image
│ │ └── client.py # APIClient + quotas
│ ├── schemas/
│ │ ├── __init__.py # Schémas images + quota
│ │ └── auth.py # Schémas Auth/Clients
│ ├── routers/
│ │ ├── images.py # CRUD images + signed URLs
│ │ ├── ai.py # Endpoints AI
│ │ ├── auth.py # Gestion clients
│ │ └── files.py # Serving signed local files
│ ├── dependencies/
│ │ └── auth.py # verify_api_key, require_scope
│ ├── middleware/
│ │ ├── rate_limit.py # slowapi configuration
│ │ └── logging_middleware.py # HTTP request logger
│ ├── services/
│ │ ├── storage.py # Sauvegarde isolée par client
│ │ ├── storage_backend.py # ABC + Local/S3 backends
│ │ ├── pipeline.py # Pipeline EXIF → OCR → AI
│ │ ├── ai_vision.py # Gemini/OpenRouter
│ │ ├── exif_service.py # Extraction EXIF
│ │ ├── ocr_service.py # Tesseract OCR
│ │ └── scraper.py # Scraping web
│ └── workers/
│ ├── redis_client.py # Redis pool partagé
│ └── image_worker.py # ARQ worker + retries
├── tests/ # 76 tests
├── .github/workflows/ci.yml # CI/CD pipeline
├── pyproject.toml # ruff, mypy, coverage config
├── Makefile # Commandes utiles
├── docker-compose.yml # API + Redis + Worker + PostgreSQL
├── Dockerfile
├── requirements.txt # Production deps
├── requirements-dev.txt # Dev deps (lint, test)
├── .pre-commit-config.yaml # Pre-commit hooks
├── worker.py # ARQ worker entrypoint
└── .env.example
```
Veuillez consulter le `CHANGELOG.md` pour suivre les développements majeurs.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
# Application
APP_NAME: str = "Imago"
APP_VERSION: str = "1.0.0"
API_V1_SUNSET_DATE: str = ""
DEBUG: bool = False
SECRET_KEY: str = "changez-moi"
@@ -84,6 +85,7 @@ class Settings(BaseSettings):
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ENDPOINT_PUBLIC: str = "" # URL utilisée pour les liens présignés (ex: http://localhost:9000)
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago"
+11
View File
@@ -62,6 +62,17 @@ async def verify_api_key(
headers={"WWW-Authenticate": "Bearer"},
)
# ── 1.5. Vérification Master Key (ADMIN_API_KEY) ───────
from app.config import settings
if settings.ADMIN_API_KEY and raw_key == settings.ADMIN_API_KEY:
# Retourne un client virtuel avec tous les droits
return APIClient(
id="admin-master",
name="Imago Master Admin",
scopes=["admin", "images:read", "images:write", "ai:use"],
plan="premium"
)
# ── Lookup par hash ───────────────────────────────────────
key_hash = hash_api_key(raw_key)
result = await db.execute(
+99 -19
View File
@@ -12,9 +12,10 @@ from slowapi.errors import RateLimitExceeded
from app.config import settings
from app.database import init_db
from app.logging_config import configure_logging
from app.routers import images_router, ai_router, auth_router, files_router
from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router
from app.middleware import limiter
from app.middleware.logging_middleware import LoggingMiddleware
from app.middleware.versioning import APIVersioningMiddleware
from app.workers.redis_client import get_redis_pool, close_redis_pool
# Configure le logging structuré dès l'import
@@ -144,13 +145,15 @@ class _FallbackArqPool:
# ─────────────────────────────────────────────────────────────
app = FastAPI(
title=settings.APP_NAME,
title=f"{settings.APP_NAME} API",
version=settings.APP_VERSION,
description="""
## Imago
## Imago API (v1)
Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli.
> **Note de versionnement :** L'API principale est désormais servie sous le préfixe `/api/v1/`. Les anciennes routes sans ce préfixe sont dépréciées.
### Fonctionnalités
- 📸 **Upload et stockage d'images** avec génération de thumbnails
- 🔍 **Extraction EXIF** automatique (appareil, GPS, paramètres de prise de vue)
@@ -182,6 +185,12 @@ app.add_middleware(
allow_headers=["*"],
)
# ─────────────────────────────────────────────────────────────
# Middleware de Versionnement API
# ─────────────────────────────────────────────────────────────
app.add_middleware(APIVersioningMiddleware)
# ─────────────────────────────────────────────────────────────
# Middleware Logging HTTP
# ─────────────────────────────────────────────────────────────
@@ -216,12 +225,24 @@ if settings.STORAGE_BACKEND == "local":
app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails")
# ─────────────────────────────────────────────────────────────
# Routers
# Routers Versionnés (/api/v1/)
# ─────────────────────────────────────────────────────────────
from fastapi import APIRouter
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(images_router)
api_v1_router.include_router(ai_router)
api_v1_router.include_router(auth_router)
# Mount the versioned router on the app
app.include_router(api_v1_router)
# ─────────────────────────────────────────────────────────────
# Routers Non-Versionnés (WebSockets, Fichiers, Santé, Metrics)
# ─────────────────────────────────────────────────────────────
app.include_router(images_router)
app.include_router(ai_router)
app.include_router(auth_router)
app.include_router(ws_router)
app.include_router(admin_router)
# ─────────────────────────────────────────────────────────────
@@ -261,7 +282,14 @@ async def health_detailed(request: Request):
"""Endpoint de santé détaillé pour monitoring avancé."""
checks = {}
# DB check
# 1. Backend check
checks["backend"] = {
"status": "ok",
"version": settings.APP_VERSION,
"uptime": "N/A" # Optionnel: ajouter un tracker d'uptime si nécessaire
}
# 2. Database check
try:
from app.database import AsyncSessionLocal
from sqlalchemy import text
@@ -271,7 +299,7 @@ async def health_detailed(request: Request):
except Exception as e:
checks["database"] = {"status": "error", "error": str(e)}
# Redis check
# 3. Redis check
redis = getattr(request.app.state, "redis", None)
if redis:
try:
@@ -282,25 +310,77 @@ async def health_detailed(request: Request):
else:
checks["redis"] = {"status": "not_configured"}
# ARQ check
# 4. Worker & Queue check (ARQ)
arq_pool = getattr(request.app.state, "arq_pool", None)
if arq_pool and not isinstance(arq_pool, _FallbackArqPool):
checks["arq"] = {"status": "ok"}
try:
# On essaye de voir le nombre de jobs en attente
queued_jobs = await arq_pool.zcard("arq:queue")
checks["queue"] = {
"status": "ok",
"pending_jobs": queued_jobs
}
# Pour le worker, on vérifie la clé de health check déposée par ARQ
# Par défaut c'est <queue_name>:health-check
hc_key = f"{settings.S3_PREFIX or 'arq'}:queue:health-check"
# On ré-essaye avec la queue par défaut si le préfixe est différent
if not await redis.exists(hc_key):
hc_key = "arq:queue:health-check"
worker_active = await redis.exists(hc_key)
checks["worker"] = {
"status": "ok" if worker_active else "error",
"active_workers": 1 if worker_active else 0,
"detail": "Worker opérationnel" if worker_active else "Aucun worker détecté"
}
except Exception as e:
checks["queue"] = {"status": "error", "error": str(e)}
checks["worker"] = {"status": "error", "error": str(e)}
else:
checks["arq"] = {"status": "fallback"}
checks["queue"] = {"status": "fallback"}
checks["worker"] = {"status": "error", "detail": "ARQ non disponible"}
# OCR check
checks["ocr"] = {"status": "enabled" if settings.OCR_ENABLED else "disabled"}
# 5. MinIO / Storage check
from app.services.storage_backend import get_storage_backend, S3Storage
storage = get_storage_backend()
try:
if isinstance(storage, S3Storage):
# On vérifie la connexion réelle à MinIO en listant les objets (ou head bucket)
# Pour faire simple/rapide on vérifie juste si on peut accéder au bucket
# via l'interface du storage qui a déjà les sessions asynchrones
exists = await storage.exists(".health-check") # path arbitraire
checks["minio"] = {
"status": "ok",
"backend": "s3",
"bucket": settings.S3_BUCKET
}
else:
checks["minio"] = {
"status": "ok",
"backend": "local",
"path": settings.UPLOAD_DIR
}
except Exception as e:
checks["minio"] = {"status": "error", "error": str(e)}
# Storage check
checks["storage"] = {
"backend": settings.STORAGE_BACKEND,
"status": "ok",
# 6. OCR check (Tesseract)
checks["tesseract"] = {"status": "ok" if settings.OCR_ENABLED else "disabled"}
# 7. AI Provider check
ai_configured = (
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
)
checks["ai"] = {
"status": "ok" if ai_configured else "warning",
"provider": settings.AI_PROVIDER,
"configured": ai_configured
}
overall = "healthy" if all(
c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback")
for c in checks.values()
for k, c in checks.items() if k != "ai" # On tolère AI warning
) else "degraded"
return {
+15 -1
View File
@@ -9,12 +9,20 @@ from prometheus_client import Counter, Histogram, Gauge
hub_images_uploaded = Counter(
"hub_images_uploaded_total",
"Nombre total d'images uploadées",
["client_plan"],
["client_id"],
)
hub_images_deleted = Counter(
"hub_images_deleted_total",
"Nombre total d'images supprimées",
["client_id"],
)
# ── AI ────────────────────────────────────────────────────────
hub_ai_tokens_consumed = Counter(
"hub_ai_tokens_consumed_total",
"Nombre total de tokens consommés",
["client_id", "model"],
)
# ── Pipeline ──────────────────────────────────────────────────
@@ -44,6 +52,12 @@ hub_storage_used_bytes = Gauge(
["client_id"],
)
# ── WebSockets ────────────────────────────────────────────────
hub_active_websockets = Gauge(
"hub_active_websockets",
"Nombre de connexions WebSocket actives",
)
# ── ARQ ───────────────────────────────────────────────────────
hub_arq_jobs_enqueued = Counter(
"hub_arq_jobs_enqueued_total",
+39
View File
@@ -0,0 +1,39 @@
"""
Middleware de versionnement d'API.
Ajoute l'en-tête `X-API-Version` aux réponses des routes sous `/api/v1/`
et gère un éventuel avertissement de dépréciation via Sunset.
"""
import logging
from typing import Awaitable, Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
logger = logging.getLogger(__name__)
class APIVersioningMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response = await call_next(request)
# Appliquer X-API-Version seulement aux routes versionnées (ex: /api/v1/)
path = request.url.path
if path.startswith("/api/v1/"):
response.headers["X-API-Version"] = "v1"
# Si une date de fin de support est configurée, l'ajouter en en-tête
if settings.API_V1_SUNSET_DATE:
response.headers["Sunset"] = settings.API_V1_SUNSET_DATE
# Logger un avertissement si on l'utilise
logger.warning("api.v1_sunset_warning", extra={
"client": request.client.host if request.client else "unknown",
"path": path,
"sunset": settings.API_V1_SUNSET_DATE
})
return response
+3 -1
View File
@@ -2,5 +2,7 @@ from app.routers.images import router as images_router
from app.routers.ai import router as ai_router
from app.routers.auth import router as auth_router
from app.routers.files import router as files_router
from app.routers.websocket import router as ws_router
from app.routers.admin import router as admin_router
__all__ = ["images_router", "ai_router", "auth_router", "files_router"]
__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router"]
+165
View File
@@ -0,0 +1,165 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict, Any, List
from pathlib import Path
from app.database import get_db
from app.models.image import Image
from app.models.client import APIClient
from app.dependencies.auth import require_scope
router = APIRouter(prefix="/admin/api", tags=["Admin"])
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
ALLOWED_DOCS = {
"README.md": PROJECT_ROOT / "README.md",
"USER_GUIDE.md": PROJECT_ROOT / "docs" / "USER_GUIDE.md",
"ARCHITECTURE.md": PROJECT_ROOT / "docs" / "ARCHITECTURE.md",
"API_GUIDE.md": PROJECT_ROOT / "docs" / "API_GUIDE.md",
"SDK.md": PROJECT_ROOT / "docs" / "SDK.md",
"SHAARLI-INTEGRATION.md": PROJECT_ROOT / "docs" / "SHAARLI-INTEGRATION.md",
"WEBSOCKET.md": PROJECT_ROOT / "docs" / "WEBSOCKET.md",
}
@router.get("/stats")
async def get_stats(
_: APIClient = Depends(require_scope("admin")),
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""Get global system statistics."""
# Count images
total_images_query = await db.execute(select(func.count(Image.id)))
total_images = total_images_query.scalar() or 0
# Storage used (sum of file_size)
storage_query = await db.execute(select(func.sum(Image.file_size)))
storage_bytes = storage_query.scalar() or 0
# Total AI tokens (sum of prompt + output tokens)
tokens_query = await db.execute(
select(func.sum(Image.ai_prompt_tokens) + func.sum(Image.ai_output_tokens))
.where(
Image.ai_prompt_tokens.isnot(None) | Image.ai_output_tokens.isnot(None)
)
)
total_tokens = tokens_query.scalar() or 0
# Total clients
clients_query = await db.execute(select(func.count(APIClient.id)))
total_clients = clients_query.scalar() or 0
return {
"total_images": total_images,
"total_storage_bytes": storage_bytes,
"total_ai_tokens": total_tokens,
"total_clients": total_clients
}
@router.get("/clients")
async def get_clients(
_: APIClient = Depends(require_scope("admin")),
db: AsyncSession = Depends(get_db)
) -> List[Dict[str, Any]]:
"""List all clients with metrics."""
result = await db.execute(select(APIClient).order_by(APIClient.created_at.desc()))
clients = result.scalars().all()
return [
{
"id": c.id,
"name": c.name,
"is_active": c.is_active,
"is_premium": c.plan == "premium",
"storage_used": c.storage_used_bytes,
"storage_quota": c.quota_storage_mb,
"created_at": c.created_at
} for c in clients
]
@router.get("/queue/status")
async def get_queue_status(
request: Request,
_: APIClient = Depends(require_scope("admin"))
) -> Dict[str, Any]:
"""Get ARQ queue status using the redis connection."""
redis = request.app.state.redis
# Note: A real implementation would parse the ARQ keys here.
# For now, we return basic statistics by probing redis directly with arq known queues.
# Count pending jobs in arq:queue
pending_count = await redis.llen("arq:queue") if hasattr(redis, "llen") else 0
# In ARQ, active jobs are harder to count without querying the worker sets,
# so we return pending count.
return {
"pending_jobs": pending_count,
"status": "active"
}
@router.post("/clients/{client_id}/toggle")
async def toggle_client(
client_id: str,
_: APIClient = Depends(require_scope("admin")),
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""Activate or deactivate a client."""
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.is_active = not client.is_active
await db.commit()
return {"id": client.id, "is_active": client.is_active}
@router.post("/clients/{client_id}/reset-quota")
async def reset_client_quota(
client_id: str,
_: APIClient = Depends(require_scope("admin")),
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""Reset the quota counters for a client."""
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.storage_used_bytes = 0
await db.commit()
return {"id": client.id, "storage_used": 0}
@router.get("/docs")
async def list_docs(
_: APIClient = Depends(require_scope("admin"))
) -> List[Dict[str, str]]:
"""List available documentation files."""
docs = []
for name, path in ALLOWED_DOCS.items():
if path.exists():
docs.append({"name": name, "title": name.replace(".md", "").replace("-", " ").title()})
else:
docs.append({"name": name, "title": name.replace(".md", "").replace("-", " ").title()}) # we might still want to list them, or omit
return docs
@router.get("/docs/{filename}")
async def get_doc(
filename: str,
_: APIClient = Depends(require_scope("admin"))
) -> Dict[str, str]:
"""Get the content of a specific documentation file."""
if filename not in ALLOWED_DOCS:
raise HTTPException(status_code=404, detail="Document not found or access denied")
path = ALLOWED_DOCS[filename]
if not path.exists():
raise HTTPException(status_code=404, detail=f"Document file {filename} not found on server")
try:
content = path.read_text(encoding="utf-8")
return {"filename": filename, "content": content}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
+14 -1
View File
@@ -14,6 +14,7 @@ from app.services.scraper import fetch_page_content
from app.services.ai_vision import summarize_url, draft_task
from app.config import settings
from app.middleware import limiter
from app.metrics import hub_ai_tokens_consumed
router = APIRouter(prefix="/ai", tags=["Intelligence Artificielle"])
@@ -62,7 +63,7 @@ async def summarize_link(
language=body.language,
)
return SummarizeResponse(
resp = SummarizeResponse(
url=body.url,
title=page.get("title"),
summary=result["summary"],
@@ -70,6 +71,13 @@ async def summarize_link(
model=result["model"],
)
# Metrics
if result.get("prompt_tokens") or result.get("output_tokens"):
total = (result.get("prompt_tokens") or 0) + (result.get("output_tokens") or 0)
hub_ai_tokens_consumed.labels(client_id=client.id, model=result["model"]).inc(total)
return resp
@router.post(
"/draft-task",
@@ -99,4 +107,9 @@ async def generate_task(
if not result.get("title"):
raise HTTPException(status_code=500, detail="Échec de la génération de la tâche")
# Metrics
if result.get("prompt_tokens") or result.get("output_tokens"):
total = (result.get("prompt_tokens") or 0) + (result.get("output_tokens") or 0)
hub_ai_tokens_consumed.labels(client_id=client.id, model=result.get("model", "unknown")).inc(total)
return DraftTaskResponse(**result)
+16 -6
View File
@@ -25,6 +25,9 @@ from app.schemas import (
from app.services import storage
from app.middleware import limiter, get_upload_rate_limit
from app.workers.image_worker import QUEUE_STANDARD, QUEUE_PREMIUM
from app.metrics import (
hub_images_uploaded, hub_images_deleted, hub_storage_used_bytes, hub_arq_jobs_enqueued
)
logger = logging.getLogger(__name__)
@@ -102,13 +105,14 @@ async def upload_image(
await db.commit()
await db.refresh(image)
# Enqueue dans ARQ (persistant, avec retry)
# Enqueue dans ARQ
arq_pool = request.app.state.arq_pool
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id)
)
await arq_pool.enqueue_job("process_image_task", image.id, str(client.id))
# Metrics
hub_images_uploaded.labels(client_id=client.id).inc()
hub_storage_used_bytes.labels(client_id=client.id).set(client.storage_used_bytes)
hub_arq_jobs_enqueued.labels(queue="default").inc()
return UploadResponse(
id=image.id,
@@ -190,6 +194,8 @@ async def list_images(
height=img.height,
uploaded_at=img.uploaded_at,
processing_status=img.processing_status,
client_id=img.client_id,
client_name=client.name,
ai_tags=img.ai_tags,
ai_description=img.ai_description,
thumbnail_path=img.thumbnail_path,
@@ -444,6 +450,10 @@ async def delete_image(
await db.delete(image)
await db.commit()
# Metrics
hub_images_deleted.labels(client_id=client.id).inc()
hub_storage_used_bytes.labels(client_id=client.id).set(client.storage_used_bytes)
return DeleteResponse(deleted_id=image_id)
+251
View File
@@ -0,0 +1,251 @@
"""
Router WebSocket — suivi temps réel du pipeline de traitement d'images.
Endpoints :
- WS /ws/pipeline/{image_id}?token=<api_key> → événements d'un pipeline
- WS /ws/admin/monitor?token=<admin_api_key> → monitoring admin global
"""
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal, get_db
from app.dependencies.auth import hash_api_key
from app.metrics import hub_active_websockets
from app.models.client import APIClient
from app.models.image import Image, ProcessingStatus
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket"])
# ─────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────
async def _authenticate_ws(websocket: WebSocket, db: AsyncSession) -> APIClient | None:
"""
Authentifie une connexion WebSocket via le query param `token`.
Retourne le client ou None si invalide.
"""
token = websocket.query_params.get("token")
if not token:
return None
# Vérification Master Key
from app.config import settings
if settings.ADMIN_API_KEY and token == settings.ADMIN_API_KEY:
return APIClient(
id="admin-master",
name="Imago Master Admin",
scopes=["admin", "images:read", "images:write", "ai:use"],
plan="premium",
)
key_hash = hash_api_key(token)
result = await db.execute(
select(APIClient).where(APIClient.api_key_hash == key_hash)
)
client = result.scalar_one_or_none()
if client and client.is_active:
return client
return None
async def _get_image(image_id: int, db: AsyncSession) -> Image | None:
"""Charge une image depuis la BDD."""
result = await db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none()
# ─────────────────────────────────────────────────────────────
# WS /ws/pipeline/{image_id} — suivi d'un pipeline
# ─────────────────────────────────────────────────────────────
from fastapi import Depends
@router.websocket("/ws/pipeline/{image_id}")
async def ws_pipeline(
websocket: WebSocket,
image_id: int,
db: AsyncSession = Depends(get_db)
) -> None:
"""
WebSocket de suivi temps réel d'un pipeline image.
- Authentification via query param `token`
- Vérifie que l'image appartient au client
- Envoie le buffer de reconnexion puis les événements live
- Ferme après pipeline.done ou pipeline.error
"""
# ── Authentification ──────────────────────────────────────
client = await _authenticate_ws(websocket, db)
if client is None:
await websocket.close(code=4001, reason="Token manquant ou invalide")
return
# ── Vérification propriété de l'image ─────────────────────
image = await _get_image(image_id, db)
if image is None:
await websocket.accept()
await websocket.close(code=4004, reason="Image introuvable")
return
# Admin peut voir toutes les images, sinon vérifier ownership
if not client.has_scope("admin") and image.client_id != client.id:
await websocket.close(code=4003, reason="Accès interdit")
return
# ── Accepter la connexion ─────────────────────────────────
await websocket.accept()
hub_active_websockets.inc()
try:
# ── Image déjà terminée → message synthétique ─────────
if image.processing_status == ProcessingStatus.DONE:
await websocket.send_json({
"event": "pipeline.done",
"image_id": image_id,
"status": "done",
"synthetic": True,
})
return
if image.processing_status == ProcessingStatus.ERROR:
await websocket.send_json({
"event": "pipeline.error",
"image_id": image_id,
"error": image.processing_error or "Erreur inconnue",
"synthetic": True,
})
return
# ── Récupérer le buffer de reconnexion depuis Redis ───
redis = getattr(websocket.app.state, "redis", None)
if redis is not None:
try:
buffer_key = f"pipeline:buffer:{image_id}"
buffered = await redis.lrange(buffer_key, 0, -1)
for raw_event in buffered:
try:
event_data = json.loads(raw_event)
await websocket.send_json(event_data)
except (json.JSONDecodeError, Exception):
pass
except Exception as e:
logger.warning("ws.buffer_read_error", extra={"error": str(e)})
# ── S'abonner au channel Redis et écouter les événements
if redis is not None:
pubsub = redis.pubsub()
try:
await pubsub.subscribe(f"pipeline:{image_id}")
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
data = json.loads(message["data"])
except (json.JSONDecodeError, TypeError):
continue
await websocket.send_json(data)
# Fermer après pipeline.done ou pipeline.error
event_type = data.get("event", "")
if event_type in ("pipeline.done", "pipeline.error"):
break
finally:
await pubsub.unsubscribe(f"pipeline:{image_id}")
await pubsub.close()
else:
# Pas de Redis — envoyer un message d'info et fermer
await websocket.send_json({
"event": "error",
"message": "Redis indisponible — utilisez le polling GET /images/{id}/status",
})
except WebSocketDisconnect:
logger.info("ws.client_disconnected", extra={
"image_id": image_id,
"client_id": client.id,
})
except Exception as e:
logger.error("ws.unexpected_error", extra={
"image_id": image_id,
"error": str(e),
})
finally:
hub_active_websockets.dec()
# ─────────────────────────────────────────────────────────────
# WS /ws/admin/monitor — monitoring admin global
# ─────────────────────────────────────────────────────────────
@router.websocket("/ws/admin/monitor")
async def ws_admin_monitor(
websocket: WebSocket,
db: AsyncSession = Depends(get_db)
) -> None:
"""
WebSocket admin pour surveiller tous les pipelines en temps réel.
Nécessite le scope `admin`. Pousse un événement à chaque démarrage
ou fin de pipeline sur n'importe quelle image.
"""
# ── Authentification ──────────────────────────────────────
client = await _authenticate_ws(websocket, db)
if client is None:
await websocket.close(code=4001, reason="Token manquant ou invalide")
return
if not client.has_scope("admin"):
await websocket.close(code=4003, reason="Scope admin requis")
return
# ── Accepter la connexion ─────────────────────────────────
await websocket.accept()
hub_active_websockets.inc()
try:
redis = getattr(websocket.app.state, "redis", None)
if redis is None:
await websocket.send_json({
"event": "error",
"message": "Redis indisponible",
})
return
pubsub = redis.pubsub()
try:
await pubsub.subscribe("pipeline:admin")
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
data = json.loads(message["data"])
except (json.JSONDecodeError, TypeError):
continue
await websocket.send_json(data)
finally:
await pubsub.unsubscribe("pipeline:admin")
await pubsub.close()
except WebSocketDisconnect:
logger.info("ws.admin_disconnected", extra={"client_id": client.id})
except Exception as e:
logger.error("ws.admin_error", extra={"error": str(e)})
finally:
hub_active_websockets.dec()
+4
View File
@@ -79,6 +79,8 @@ class ImageBase(BaseModel):
height: Optional[int] = None
uploaded_at: Optional[datetime] = None
processing_status: ProcessingStatus
client_id: str
client_name: Optional[str] = None
class ImageSummary(ImageBase):
@@ -107,6 +109,8 @@ class ImageDetail(ImageBase):
height=img.height,
uploaded_at=img.uploaded_at,
processing_status=img.processing_status,
client_id=img.client_id,
client_name=img.client.name if img.client else None,
thumbnail_path=img.thumbnail_path,
exif=ExifData(
camera=ExifCamera(
+7
View File
@@ -338,6 +338,8 @@ async def summarize_url(url: str, content: str, language: str = "français") ->
"summary": "",
"tags": [],
"model": settings.AI_PROVIDER,
"prompt_tokens": None,
"output_tokens": None,
}
if not settings.AI_ENABLED:
@@ -370,6 +372,7 @@ Retourne UNIQUEMENT ce JSON :
if parsed:
result["summary"] = parsed.get("summary", "")
result["tags"] = parsed.get("tags", [])
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e:
logger.error("ai.summarize_url.error", extra={"error": str(e)})
@@ -385,6 +388,9 @@ async def draft_task(description: str, context: Optional[str], language: str = "
"steps": [],
"estimated_time": None,
"priority": None,
"model": settings.AI_PROVIDER,
"prompt_tokens": None,
"output_tokens": None,
}
if not settings.AI_ENABLED:
@@ -416,6 +422,7 @@ Retourne UNIQUEMENT ce JSON :
parsed = _extract_json(text)
if parsed:
result.update(parsed)
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e:
logger.error("ai.draft_task.error", extra={"error": str(e)})
+45 -2
View File
@@ -17,6 +17,10 @@ from app.models.image import Image, ProcessingStatus
from app.services.exif_service import extract_exif
from app.services.ocr_service import extract_text
from app.services.ai_vision import analyze_image, extract_text_with_ai
from app.metrics import (
hub_pipeline_duration, hub_pipeline_step_duration, hub_pipeline_errors,
hub_ai_tokens_consumed
)
import asyncio
logger = logging.getLogger(__name__)
@@ -25,14 +29,33 @@ logger = logging.getLogger(__name__)
async def _publish_event(
redis: Any, image_id: int, event: str, data: dict | None = None
) -> None:
"""Publie un événement sur le channel Redis pipeline:{image_id}."""
"""
Publie un événement sur le channel Redis pipeline:{image_id}.
Stocke aussi les 10 derniers événements dans un buffer Redis
(clé pipeline:buffer:{image_id}, TTL 60s) pour la reconnexion WebSocket.
Publie sur pipeline:admin pour le monitoring admin.
"""
if redis is None:
return
try:
payload = {"event": event, "image_id": image_id, "timestamp": time.time()}
if data:
payload["data"] = data
await redis.publish(f"pipeline:{image_id}", json.dumps(payload))
raw = json.dumps(payload)
# Publier sur le channel dédié à l'image
await redis.publish(f"pipeline:{image_id}", raw)
# Buffer de reconnexion (10 derniers événements, TTL 60s)
buffer_key = f"pipeline:buffer:{image_id}"
await redis.rpush(buffer_key, raw)
await redis.ltrim(buffer_key, -10, -1)
await redis.expire(buffer_key, 60)
# Publier sur le channel admin pour le monitoring global
if event in ("pipeline.started", "pipeline.done", "pipeline.error"):
await redis.publish("pipeline:admin", raw)
except Exception:
pass # Pub/Sub non critique — ne doit pas bloquer le pipeline
@@ -61,6 +84,7 @@ async def process_image_pipeline(
# ── Démarrage ─────────────────────────────────────────────
image.processing_status = ProcessingStatus.PROCESSING
image.processing_started_at = datetime.now(timezone.utc)
t_start = time.time()
await db.commit()
await db.refresh(image)
@@ -97,6 +121,8 @@ async def process_image_pipeline(
await db.commit()
elapsed = int((time.time() - t0) * 1000)
hub_pipeline_step_duration.labels(step="exif").observe(time.time() - t0)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "exif", "duration_ms": elapsed, "camera": image.exif_make})
await _publish_event(redis, image_id, "step.completed", {
"step": "exif", "duration_ms": elapsed, "camera": image.exif_make,
@@ -105,6 +131,7 @@ async def process_image_pipeline(
except Exception as e:
msg = f"EXIF : {str(e)}"
errors.append(msg)
hub_pipeline_errors.labels(step="exif").inc()
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "exif", "error": str(e)})
# ════════════════════════════════════════════════════════════
@@ -135,6 +162,8 @@ async def process_image_pipeline(
await db.commit()
elapsed = int((time.time() - t0) * 1000)
hub_pipeline_step_duration.labels(step="ocr").observe(time.time() - t0)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "ocr", "duration_ms": elapsed, "has_text": image.ocr_has_text})
await _publish_event(redis, image_id, "step.completed", {
"step": "ocr", "duration_ms": elapsed, "has_text": image.ocr_has_text,
@@ -143,6 +172,7 @@ async def process_image_pipeline(
except Exception as e:
msg = f"OCR : {str(e)}"
errors.append(msg)
hub_pipeline_errors.labels(step="ocr").inc()
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "ocr", "error": str(e)})
# ════════════════════════════════════════════════════════════
@@ -166,6 +196,16 @@ async def process_image_pipeline(
await db.commit()
elapsed = int((time.time() - t0) * 1000)
hub_pipeline_step_duration.labels(step="ai").observe(time.time() - t0)
# Token metrics
if image.ai_prompt_tokens or image.ai_output_tokens:
total_tokens = (image.ai_prompt_tokens or 0) + (image.ai_output_tokens or 0)
hub_ai_tokens_consumed.labels(
client_id=image.client_id,
model=image.ai_model_used or "unknown"
).inc(total_tokens)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "ai", "duration_ms": elapsed, "tags_count": len(image.ai_tags or [])})
await _publish_event(redis, image_id, "step.completed", {
"step": "ai", "duration_ms": elapsed, "tags_count": len(image.ai_tags or []),
@@ -174,6 +214,7 @@ async def process_image_pipeline(
except Exception as e:
msg = f"AI Vision : {str(e)}"
errors.append(msg)
hub_pipeline_errors.labels(step="ai").inc()
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "ai", "error": str(e)})
# ════════════════════════════════════════════════════════════
@@ -193,6 +234,8 @@ async def process_image_pipeline(
image.processing_error = None
await db.commit()
hub_pipeline_duration.observe(time.time() - t_start)
logger.info("pipeline.completed", extra={
"image_id": image_id,
"status": image.processing_status.value,
+4 -1
View File
@@ -127,6 +127,7 @@ class S3Storage(StorageBackend):
endpoint_url: str = "",
access_key: str = "",
secret_key: str = "",
endpoint_public: str = "",
) -> None:
self._bucket = bucket
self._prefix = prefix.rstrip("/")
@@ -134,6 +135,7 @@ class S3Storage(StorageBackend):
self._endpoint_url = endpoint_url or None
self._access_key = access_key
self._secret_key = secret_key
self._endpoint_public = endpoint_public or self._endpoint_url
def _s3_key(self, path: str) -> str:
if self._prefix:
@@ -178,7 +180,7 @@ class S3Storage(StorageBackend):
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Génère une URL présignée S3."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
async with session.client("s3", endpoint_url=self._endpoint_public) as client:
url = await client.generate_presigned_url(
"get_object",
Params={"Bucket": self._bucket, "Key": self._s3_key(path)},
@@ -238,6 +240,7 @@ def get_storage_backend() -> StorageBackend:
endpoint_url=settings.S3_ENDPOINT_URL,
access_key=settings.S3_ACCESS_KEY,
secret_key=settings.S3_SECRET_KEY,
endpoint_public=settings.S3_ENDPOINT_PUBLIC,
)
else:
_backend = LocalStorage(
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1,4 +1,23 @@
services:
# ── Portail Admin (React + Nginx) ──────────────────────────
admin:
build:
context: ./imago-admin
args:
VITE_API_BASE_URL: ""
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 5s
retries: 3
# ── Backend Imago Hub (FastAPI + Uvicorn) ──────────────────
backend:
build: .
ports:
+22 -19
View File
@@ -1,7 +1,10 @@
# Guide d'utilisation de l'API Imago
Bienvenue dans la documentation officielle de l'API REST d'Imago.
**Note :** Tous les terminaux (endpoints) principaux présentés ici sont désormais servis sous le préfixe `/api/v1/` dans l'application locale.
## 📍 Sommaire
- [🔐 Authentification](#-authentification)
- [🔐 Authentification](#-Authentification)
- [🛡️ Scopes (Permissions)](#-scopes-permissions)
- [📊 Plans et Rate Limiting](#-plans-et-rate-limiting)
- [👥 Gestion des Clients (Admin uniquement)](#-gestion-des-clients-admin-uniquement)
@@ -114,7 +117,7 @@ L'API est **multi-tenant**. Cela signifie que :
### 📸 Gestion des Images
#### Lister les images
`GET /images`
`GET /api/v1/images`
- **Scope required** : `images:read`
- **Query Params** :
- `page` : Numéro de page (défaut: 1)
@@ -124,39 +127,39 @@ L'API est **multi-tenant**. Cela signifie que :
- `search` : Recherche textuelle dans le nom, la description AI ou l'OCR.
#### Uploader une image
`POST /images/upload`
`POST /api/v1/images/upload`
- **Scope required** : `images:write`
- **Body** : `multipart/form-data`
- `file` : Le fichier image (JPEG, PNG, WebP, etc.)
- **Note** : Lance automatiquement le pipeline AI en arrière-plan.
#### Détail complet d'une image
`GET /images/{id}`
`GET /api/v1/images/{id}`
- **Scope required** : `images:read`
- **Description** : Retourne toutes les données (Source, EXIF, OCR, AI).
#### Statut du traitement
`GET /images/{id}/status`
`GET /api/v1/images/{id}/status`
- **Scope required** : `images:read`
- **Description** : Pour savoir si l'analyse par l'IA est terminée.
#### Métadonnées spécifiques
- `GET /images/{id}/exif` : Données techniques de l'appareil et GPS.
- `GET /images/{id}/ocr` : Texte extrait de l'image.
- `GET /images/{id}/ai` : Description textuelle et tags générés.
- `GET /api/v1/images/{id}/exif` : Données techniques de l'appareil et GPS.
- `GET /api/v1/images/{id}/ocr` : Texte extrait de l'image.
- `GET /api/v1/images/{id}/ai` : Description textuelle et tags générés.
#### Retraitement
`POST /images/{id}/reprocess`
`POST /api/v1/images/{id}/reprocess`
- **Scope required** : `images:write`
- **Description** : Réinitialise et relance le pipeline d'analyse AI.
#### Suppression
`DELETE /images/{id}`
`DELETE /api/v1/images/{id}`
- **Scope required** : `images:delete`
- **Description** : Supprime l'entrée en base et les fichiers sur le disque.
#### Tags globaux
`GET /images/tags/all`
`GET /api/v1/images/tags/all`
- **Scope required** : `images:read`
- **Description** : Liste tous les tags uniques utilisés par le client.
@@ -165,7 +168,7 @@ L'API est **multi-tenant**. Cela signifie que :
### 🤖 Intelligence Artificielle
#### Résumé d'URL
`POST /ai/summarize`
`POST /api/v1/ai/summarize`
- **Scope required** : `ai:use`
- **Body** (JSON) :
```json
@@ -176,7 +179,7 @@ L'API est **multi-tenant**. Cela signifie que :
```
#### Rédaction de tâche
`POST /ai/draft-task`
`POST /api/v1/ai/draft-task`
- **Scope required** : `ai:use`
- **Body** (JSON) :
```json
@@ -192,12 +195,12 @@ L'API est **multi-tenant**. Cela signifie que :
### 🔑 Administration (Admin uniquement)
*Nécessite le scope `admin`.*
- `POST /auth/clients` : Créer un client (retourne la clé).
- `GET /auth/clients` : Lister tous les clients.
- `GET /auth/clients/{id}` : Voir les détails d'un client.
- `PATCH /auth/clients/{id}` : Modifier un client (nom, scopes, plan).
- `POST /auth/clients/{id}/rotate-key` : Changer la clé API.
- `DELETE /auth/clients/{id}` : Désactiver un client (suspension d'accès).
- `POST /api/v1/auth/clients` : Créer un client (retourne la clé).
- `GET /api/v1/auth/clients` : Lister tous les clients.
- `GET /api/v1/auth/clients/{id}` : Voir les détails d'un client.
- `PATCH /api/v1/auth/clients/{id}` : Modifier un client (nom, scopes, plan).
- `POST /api/v1/auth/clients/{id}/rotate-key` : Changer la clé API.
- `DELETE /api/v1/auth/clients/{id}` : Désactiver un client (suspension d'accès).
---
+60
View File
@@ -0,0 +1,60 @@
# Architecture Technique - Imago Hub
Ce document détaille l'architecture logicielle, les flux de données et les choix technologiques d'Imago Hub. Il s'adresse aux développeurs, architectes et contributeurs du projet.
---
## 🏗️ 1. Vue d'Ensemble des Composants
Imago s'articule autour de plusieurs services distincts orchestrés via Docker, communiquant par des interfaces asynchrones et résilientes.
### La Stack Technique
- **Backend API Layer :** `FastAPI` (Python 3.10+) pour des performances maximales, des endpoints asynchrones (`async/await`) et une validation native (Pydantic). L'API répond vite, elle ne bloque jamais sur l'IO réseau.
- **Frontend Admin :** `React` (JavaScript/TypeScript) propulsé par Vite et servi via Nginx. Interface Single-Page minimaliste, rapide.
- **Worker Queue :** `ARQ` (Async Redis Queue). Une alternative moderne s'appuyant sur l'Event Loop natif d'asyncio Python au lieu de frameworks lourds.
- **Broker & Cache :** `Redis` stocke les jobs temporaires, limite les taux (Rate Limiting) et gère le pub/sub des requêtes websocket.
- **Base de données :** `PostgreSQL` interrogé via `SQLAlchemy` (orm asynchrone : `asyncpg`).
- **Objets & Médias :** `MinIO` implémentant le standard S3 (ou un volume filesystem classique) pour isoler les données binaires massives.
---
## 🔄 2. Flux de Traitement Asynchrone (Le Pipeline)
Toute la valeur d'Imago réside dans sa capacité à dépiler un flux lourd d'images de façon déconnectée des appels HTTP des utilisateurs finaux.
### Chronologie d'un événement `Upload`
1. Le client effectue un `POST /upload`. L'API FastAPI, en streaming mémoire, pousse l'image vers le backend de stockage (S3).
2. L'API enregistre dans PostgreSQL l'image avec un statut `PENDING`.
3. L'API publie un message **enqueue** vers ARQ/Redis : `analyse_image(db_id=124)`.
4. L'API répond immédiatement au client HTTP avec `202 Accepted` et l'UUID généré.
### Boucle du Worker ARQ (processus séparé)
Une fois le message attrapé par le démon `worker.py` :
1. **Verrouillage** : Le statut passe à `PROCESSING`.
2. **Étape EXIF** : (via `exifread` / modules Python) analyse purement metadata, parse le GPS et écrit dans les tables reliées.
3. **Étape OCR** : Tesseract est encapsulé. Il scanne l'image et met à jour l'entité PostgreSQL.
4. **Étape IA Vision** : Un client HTTP asynchrone (`httpx`) effectue un appel API vers Google (Gemini) ou OpenRouter. Une demande de structuration en prompt pur JSON assure des typages clairs.
5. **Clôture** : Le statut passe à `COMPLETED`.
*(En cas d'échec sur une étape isolée comme le réseau IA, le worker utilise la retry-policy asynchrone native d'ARQ).*
---
## 🗄️ 3. Modèle de Données & Multi-tenancy
La BDD relationnelle s'articule pour cloisonner (isolation de locataire) les données de façon logicielle :
- **Table Clients** : Représente le "Tenant". Un Client possède plusieurs clés API hashées (Bcrypt) et un "plan" définissant ses quotas.
- **Table Image** : Clé principale. Chaque Image possède une `client_id` liant la ressource directement au Tenant. C'est l'essence du filtrage (Aucune requête RDBM vers Images ne démarre sans `WHERE client_id = X`).
- **Tables Satellites (Exif, OCR, AITasks)** : Héritent des dépendances (Cascade Deletes) liant l'information technique à l'uuid Image.
### Le Rate Limiting Distribué
Le limiter applicatif s'appuie sur `slowapi` et le backend Redis. L'ID du tenant (Client Object) sert de clé dans Redis pour évaluer le bucket algorithmique :
- Permet de bloquer avant l'appel FastAPI les clients abusifs en respectant strictement l'architecture distribuée.
---
## 🔌 4. Observabilité et Temps Réel
- **FastAPI Websockets Pub/Sub :** L'API monte des sessions WebSockets bidirectionnelles. Lors d'un changement dans PostgreSQL (Via un event déclenché par ARQ en fin de boucle), un hook publie un patch JSON aux websockets écoutants. L'UI (Shaarli / Admin) est ré-invalider sans actualisation HTTP.
- **Prometheus Metrics :** Le endpoint `/metrics` expose via un middleware des données quantitatives brutes de latence et volumétries. Facilement gratté (scraped) par des tableaux Grafana ou Datadog.
+59
View File
@@ -0,0 +1,59 @@
# Imago SDK - `imago-client`
The official Python SDK provides an asynchronous, strongly-typed interface to the Imago API.
## Installation
```bash
cd sdk
pip install -e .
```
## Basic Usage
The SDK provides an asynchronous Context Manager (`HubClient`) which handles connection pooling, retries, and authentication.
```python
import asyncio
from imago_client import HubClient
async def main():
async with HubClient("http://localhost:8000/api/v1", api_key="sk-test-secret-key") as client:
# 1. Upload an image
image = await client.images.upload("photo.jpg")
print(f"Uploaded! UUID: {image.uuid}")
# 2. Get status
details = await client.images.get(image.uuid)
print(f"Status: {details.processing_status}")
# 3. Use AI
summary = await client.ai.draft_task("https://example.com/article", "Summarize this quickly.")
print(summary)
if __name__ == "__main__":
asyncio.run(main())
```
## Advanced Features
### 1. Resilient Retries
By default, the SDK automatically retries requests on timeouts, TCP connection errors, `429 Too Many Requests`, and `5xx Server Errors`. It uses exponential backoff with jitter to ensure safe recovery.
### 2. Real-Time Pipeline Streaming
The SDK natively wraps the WebSockets API. You can `async for` over events until the pipeline finishes:
```python
async with HubClient(url, api_key) as client:
async for event in client.images.stream_pipeline_events("IMAGE_UUID"):
print(event["event"])
if event["event"] == "pipeline.done":
break
```
### 3. Error Handling
All specific cases throw custom exceptions inherited from `ImagoError`:
- `AuthError` (401)
- `QuotaError` (403 or 429 quota exhaustion)
- `NotFoundError` (404)
- `PipelineError` (WebSockets specific)
+45
View File
@@ -0,0 +1,45 @@
# Shaarli Integration
The **Imago Hub** can be integrated directly with **Shaarli** (the PHP-based bookmark manager) to automatically process and enhance your bookmarks when you save them.
## Overview
When you save a new bookmark in Shaarli, you might want to:
1. Automatically fetch the URL and summarize the page content using AI
2. Generate tags automatically based on the page content
3. Archive full-page screenshots to Imago for offline reading
This repository includes a reference implementation for this architecture using the Imago Python SDK.
## Components
The `integration/shaarli/` directory contains:
- `hub_plugin.py` — A Python class (`ShaarliHubPlugin`) that acts as a middleware interacting with the `imago-client` SDK.
- `config_example.env` — Example environment variable file for the plugin layer.
- `example_usage.py` — A standalone script demonstrating how the plugin summarizes URLs.
## Setup Instructions
1. **Install the SDK:** The script requires the Imago Python SDK.
```bash
cd sdk
pip install imago-client
```
2. **Configure Environment variables:**
Copy `integration/shaarli/config_example.env` to `.env` and fill it with your **Imago Admin API Key** or a specific Client API Key provisioned with `ai:use` and `images:write` scopes.
3. **Deploy as a microservice or hook:**
Since Shaarli is written in PHP, to hook python scripts into Shaarli you have a few options:
- **Option A (WebHook):** Deploy a small FastAPI/Flask wrapper around `hub_plugin.py` and configure Shaarli's WebHook plugin to POST data to your wrapper whenever a new link is created.
- **Option B (Cron/Background Job):** Have a Python daemon watch the Shaarli RSS/Atom feed or poll its REST API, grab new entries, process them via `hub_plugin.py`, and PATCH the bookmark back in Shaarli with the newly generated Description and Tags using the Shaarli REST API.
## Example Flow (Option B)
1. You save `https://github.com/imago-project/imago` to Shaarli.
2. The Python daemon detects the new bookmark via Shaarli's API.
3. The daemon calls `plugin.process_bookmark(url)`.
4. Imago leverages OpenRouter/Gemini to fetch and summarize the GitHub page.
5. Imago returns `{"title": "...", "description": "...", "suggested_tags": ["github", "python", "ai"]}`.
6. The daemon PATCHes the Shaarli API to enrich the bookmark.
+79
View File
@@ -0,0 +1,79 @@
# Guide d'Utilisation - Imago Hub
Bienvenue dans le guide d'utilisation d'**Imago Hub**. Ce document vous accompagnera dans l'utilisation des principales fonctionnalités de la plateforme, que vous interagissiez via l'API, le panel d'administration ou les SDKs.
---
## 📸 1. Gestion des Images
### Upload et Traitement Asynchrone
Imago est conçu pour ingérer rapidement des images et déléguer les traitements lourds (redimensionnement, IA, OCR) en arrière-plan (via les workers ARQ).
Lorsqu'une image est uploadée :
1. **Stockage immédiat** : L'image originale est sauvegardée localement (ou sur S3/MinIO).
2. **Création Miniature** : Une version allégée est générée.
3. **Mise en file d'attente** : L'image passe par un pipeline d'analyse :
- Extraction EXIF.
- Passage dans le moteur OCR.
- Analyse par la Vision IA.
### Suivre l'état d'un traitement
Étant donné que les IA (comme Gemini/OpenRouter) et l'OCR prennent du temps, l'upload retourne instantanément un `id` avec un statut `processing` ou `pending`.
Vous pouvez surveiller ce statut via :
- Le polling de l'endpoint : `GET /api/v1/images/{id}/status`
- Le streaming d'événements : Les applications frontend peuvent se connecter aux websockets d'Imago pour être notifiées en temps réel à chaque étape du pipeline. *(Voir [websocket.md](websocket.md))*
---
## 🤖 2. Intelligence Artificielle et OCR
### L'OCR (Reconnaissance Optique de Caractères)
Si activé, le moteur **Tesseract** parcourt chaque image pour en extraire le texte. Cela est particulièrement utile pour :
- Effectuer des recherches en texte intégral sur des captures d'écran, factures ou mémos photographiés.
- Lier ces données textuelles à l'image sans intervention humaine.
### Vision IA (Gemini / OpenRouter)
Imago est "AI-Native". Lorsqu'une image est traitée, elle est envoyée au fournisseur configuré (par exemple : `gemini-1.5-pro` ou `qwen2.5-vl` via OpenRouter).
L'IA réalise deux opérations clés :
1. **Description enrichie** : Elle rédige un résumé détaillé de la scène ou du document pour améliorer l'accessibilité et la compréhension.
2. **Génération de Tags** : Elle attribue entre 5 et 10 mots-clés (tags) pertinents qui seront indexés en base de données, rendant l'image facilement trouvable lors des recherches futures.
---
## 💻 3. Le Portail d'Administration
Le panel Admin (React) est accessible par défaut sur `http://localhost:3000`.
### Tableau de Bord
Le tableau de bord centralise toutes les images importées. Il permet de :
- Naviguer paginer parmi les milliers d'images gérées.
- Voir en un coup d'œil quelles images sont encore en cours de traitement, ont échoué ou sont validées.
- Filtrer par les Tags générés par l'IA.
### Visionneuse Détaillée
En cliquant sur une image, vous avez accès à une vue à 360° :
- L'image originale et sa miniature.
- **L'onglet "Sources & EXIF"** : Données de géolocalisation pour retracer le parcours de la photo, spécificités techniques (ouverture, ISO, modèle d'appareil).
- **L'onglet "Texte & IA"** : Le dictionnaire des termes générés par l'OCR et le résumé humain traduit.
### Gestion des Clients (Multi-tenancy)
*(Si vous possédez la permission `admin`)*
- Vous pouvez gérer les accès API directement depuis l'interface.
- Créer de nouveaux espaces (clients), générer des clés API et imputer des quotas (free, standard, premium) limitant le taux horaire de requêtes par tenant.
---
## 🔗 4. Interagir avec l'API
Tous les endpoints nécessitent une clé API valide (fournie par l'admin) transmise via le header d'Auth :
`Authorization: Bearer <votre_cle>`
Pour le catalogue exhaustif des endpoints (Upload, Recherche, AI standalone), référez-vous au :
👉 **[Guide détaillé de l'API (API_GUIDE.md)](API_GUIDE.md)**
### Résumé rapide des endpoints utiles :
| Action | Endpoint REST | Exemple d'Usage |
|--------|----------------------|-----------------|
| Envoyer | `POST /api/v1/images/upload` | Intégrer depuis votre app mobile. |
| Chercher| `GET /api/v1/images?search=voiture` | Trouver une image via l'OCR ou les tags IA. |
| Santé | `GET /health/detailed` | Surveiller l'état de Redis, DB, et MinIO. |
+33
View File
@@ -0,0 +1,33 @@
# WebSockets & Real-Time Events
Imago uses WebSockets and Redis Pub/Sub to provide real-time updates for asynchronous tasks (e.g. image OCR, EXIF extraction, AI summarization).
## Endpoints
All WebSockets endpoints wait for events from the `arq` queue. They require an authentication token to be passed via the `token` query parameter.
### 1. Per-Image Pipeline Events
**Endpoint:** `ws://localhost:8000/ws/pipeline/{image_id}?token=YOUR_API_KEY`
This endpoint streams events related to a specific image. If the image is already fully processed when you connect, the server will immediately yield an artificial `{"event": "pipeline.done", "status": "completed"}` event and the connection may close, ensuring you never miss the final state.
**Required Scopes:** `images:read` AND the image must belong to the client (unless the client is an Admin).
#### Example Events
```json
{"event": "pipeline.started"}
{"event": "task.exif.started"}
{"event": "task.exif.done"}
{"event": "pipeline.done", "status": "completed"}
```
### 2. Global Admin Monitor
**Endpoint:** `ws://localhost:8000/ws/admin/monitor?token=YOUR_ADMIN_KEY`
This endpoint streams high-level pipeline events (start, done, error) for ALL images across the system. It is meant for admin dashboards to display live activity feeds.
**Required Scopes:** `admin`
## Event Buffering
If your client disconnects and reconnects briefly, it might miss events. Imago buffers the last 10 events for each active image pipeline in Redis with a 60-second TTL. When a client connects to the per-image endpoint, any buffered events that were recorded before the WebSocket fully opened are immediately flushed down the socket, enabling seamless UI updates.
+928
View File
@@ -0,0 +1,928 @@
c:\Users\bruno\scoop\apps\python\3.14.3\Lib\site-packages\pytest_asyncio\plugin.py:208: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the fixture caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to function scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session"
warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-8.3.3, pluggy-1.6.0
rootdir: C:\dev\git\python\imago
configfile: pytest.ini
plugins: anyio-4.12.0, asyncio-0.24.0, cov-5.0.0, mock-3.15.1, respx-0.22.0
asyncio: mode=Mode.AUTO, default_loop_scope=None
collected 7 items
tests\test_services.py F.F...F [100%]
================================== FAILURES ===================================
___________________________ test_exif_missing_file ____________________________
def test_exif_missing_file():
from app.services.exif_service import extract_exif
result = extract_exif("/tmp/non_existant.jpg")
> assert result["make"] is None
E TypeError: 'coroutine' object is not subscriptable
tests\test_services.py:15: TypeError
---------------------------- Captured stdout setup ----------------------------
2026-02-25T15:57:39.485432Z [debug ] Using proactor: IocpProactor [asyncio]
2026-02-25T15:57:39.487781Z [debug ] executing <function connect.<locals>.connector at 0x0000026E92512140> [aiosqlite]
2026-02-25T15:57:39.488325Z [debug ] operation <function connect.<locals>.connector at 0x0000026E92512140> completed [aiosqlite]
2026-02-25T15:57:39.489029Z [debug ] executing functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'regexp', 2, <function SQLiteDialect_pysqlite.on_connect.<locals>.regexp at 0x0000026E923D8510>, deterministic=True) [aiosqlite]
2026-02-25T15:57:39.489748Z [debug ] operation functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'regexp', 2, <function SQLiteDialect_pysqlite.on_connect.<locals>.regexp at 0x0000026E923D8510>, deterministic=True) completed [aiosqlite]
2026-02-25T15:57:39.490128Z [debug ] executing functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'floor', 1, <built-in function floor>, deterministic=True) [aiosqlite]
2026-02-25T15:57:39.490300Z [debug ] operation functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'floor', 1, <built-in function floor>, deterministic=True) completed [aiosqlite]
2026-02-25T15:57:39.490606Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.490744Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.491000Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551940>, 'PRAGMA read_uncommitted', []) [aiosqlite]
2026-02-25T15:57:39.491204Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551940>, 'PRAGMA read_uncommitted', []) completed [aiosqlite]
2026-02-25T15:57:39.491475Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551940>) [aiosqlite]
2026-02-25T15:57:39.491620Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551940>) completed [aiosqlite]
2026-02-25T15:57:39.491863Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551940>) [aiosqlite]
2026-02-25T15:57:39.492016Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551940>) completed [aiosqlite]
2026-02-25T15:57:39.492326Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.492582Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.493265Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.493424Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.493673Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551AC0>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.493968Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551AC0>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.494194Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551AC0>) [aiosqlite]
2026-02-25T15:57:39.494327Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551AC0>) completed [aiosqlite]
2026-02-25T15:57:39.494538Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551AC0>) [aiosqlite]
2026-02-25T15:57:39.494786Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551AC0>) completed [aiosqlite]
2026-02-25T15:57:39.495367Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.495569Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.495865Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551BC0>, 'PRAGMA temp.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.496043Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551BC0>, 'PRAGMA temp.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.496307Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551BC0>) [aiosqlite]
2026-02-25T15:57:39.496439Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551BC0>) completed [aiosqlite]
2026-02-25T15:57:39.496668Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551BC0>) [aiosqlite]
2026-02-25T15:57:39.496807Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551BC0>) completed [aiosqlite]
2026-02-25T15:57:39.497235Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.497386Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.497635Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551CC0>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.497807Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551CC0>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.498038Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551CC0>) [aiosqlite]
2026-02-25T15:57:39.498191Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551CC0>) completed [aiosqlite]
2026-02-25T15:57:39.498471Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551CC0>) [aiosqlite]
2026-02-25T15:57:39.498708Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551CC0>) completed [aiosqlite]
2026-02-25T15:57:39.499082Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.499276Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.499533Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'PRAGMA temp.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.499692Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'PRAGMA temp.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.499962Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551D40>) [aiosqlite]
2026-02-25T15:57:39.500096Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551D40>) completed [aiosqlite]
2026-02-25T15:57:39.500329Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) [aiosqlite]
2026-02-25T15:57:39.500531Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) completed [aiosqlite]
2026-02-25T15:57:39.501477Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.501613Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.501858Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.502175Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.502454Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) [aiosqlite]
2026-02-25T15:57:39.502595Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) completed [aiosqlite]
2026-02-25T15:57:39.503027Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.503275Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.503627Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552FC0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) [aiosqlite]
2026-02-25T15:57:39.504042Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552FC0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed [aiosqlite]
2026-02-25T15:57:39.504463Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552FC0>) [aiosqlite]
2026-02-25T15:57:39.504684Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552FC0>) completed [aiosqlite]
2026-02-25T15:57:39.505114Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.505355Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.505645Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) [aiosqlite]
2026-02-25T15:57:39.506013Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.506286Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) [aiosqlite]
2026-02-25T15:57:39.506440Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed [aiosqlite]
2026-02-25T15:57:39.507240Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.507386Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.507615Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.507985Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.508279Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) [aiosqlite]
2026-02-25T15:57:39.508429Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed [aiosqlite]
2026-02-25T15:57:39.508876Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.509020Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.509267Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) [aiosqlite]
2026-02-25T15:57:39.509554Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed [aiosqlite]
2026-02-25T15:57:39.509826Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) [aiosqlite]
2026-02-25T15:57:39.509964Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed [aiosqlite]
2026-02-25T15:57:39.510327Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.510462Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.510678Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'CREATE INDEX ix_images_id ON images (id)', ()) [aiosqlite]
2026-02-25T15:57:39.510870Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.511090Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) [aiosqlite]
2026-02-25T15:57:39.511223Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed [aiosqlite]
2026-02-25T15:57:39.511502Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.511632Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.511835Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) [aiosqlite]
2026-02-25T15:57:39.512035Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.512287Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) [aiosqlite]
2026-02-25T15:57:39.512529Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) completed [aiosqlite]
2026-02-25T15:57:39.512901Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.513036Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.513259Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925520C0>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) [aiosqlite]
2026-02-25T15:57:39.513450Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925520C0>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed [aiosqlite]
2026-02-25T15:57:39.513759Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925520C0>) [aiosqlite]
2026-02-25T15:57:39.514011Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925520C0>) completed [aiosqlite]
2026-02-25T15:57:39.514429Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.514579Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.514874Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.515006Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
----------------------------- Captured log setup ------------------------------
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
DEBUG aiosqlite:core.py:114 executing <function connect.<locals>.connector at 0x0000026E92512140>
DEBUG aiosqlite:core.py:116 operation <function connect.<locals>.connector at 0x0000026E92512140> completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'regexp', 2, <function SQLiteDialect_pysqlite.on_connect.<locals>.regexp at 0x0000026E923D8510>, deterministic=True)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'regexp', 2, <function SQLiteDialect_pysqlite.on_connect.<locals>.regexp at 0x0000026E923D8510>, deterministic=True) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'floor', 1, <built-in function floor>, deterministic=True)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method create_function of sqlite3.Connection object at 0x0000026E9249F4C0>, 'floor', 1, <built-in function floor>, deterministic=True) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551940>, 'PRAGMA read_uncommitted', [])
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551940>, 'PRAGMA read_uncommitted', []) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551AC0>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551AC0>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551BC0>, 'PRAGMA temp.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551BC0>, 'PRAGMA temp.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551BC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551BC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551BC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551BC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551CC0>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551CC0>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551CC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551CC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551CC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551CC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'PRAGMA temp.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'PRAGMA temp.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552FC0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552FC0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552FC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552FC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'CREATE INDEX ix_images_id ON images (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925520C0>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925520C0>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925520C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925520C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
-------------------------- Captured stdout teardown ---------------------------
2026-02-25T15:57:39.810739Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.811135Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.811538Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3740>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.811843Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3740>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.812178Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3740>) [aiosqlite]
2026-02-25T15:57:39.812474Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3740>) completed [aiosqlite]
2026-02-25T15:57:39.812833Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3740>) [aiosqlite]
2026-02-25T15:57:39.813069Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3740>) completed [aiosqlite]
2026-02-25T15:57:39.813600Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.813825Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.814204Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.814427Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.814655Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3940>) [aiosqlite]
2026-02-25T15:57:39.814794Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3940>) completed [aiosqlite]
2026-02-25T15:57:39.815104Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) [aiosqlite]
2026-02-25T15:57:39.815256Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) completed [aiosqlite]
2026-02-25T15:57:39.815717Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.815863Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.816132Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, '\nDROP TABLE images', ()) [aiosqlite]
2026-02-25T15:57:39.816494Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, '\nDROP TABLE images', ()) completed [aiosqlite]
2026-02-25T15:57:39.816770Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) [aiosqlite]
2026-02-25T15:57:39.816906Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) completed [aiosqlite]
2026-02-25T15:57:39.817272Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.817417Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.817632Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E39C0>, '\nDROP TABLE api_clients', ()) [aiosqlite]
2026-02-25T15:57:39.817897Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E39C0>, '\nDROP TABLE api_clients', ()) completed [aiosqlite]
2026-02-25T15:57:39.818156Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E39C0>) [aiosqlite]
2026-02-25T15:57:39.818289Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E39C0>) completed [aiosqlite]
2026-02-25T15:57:39.818710Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.818846Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.819133Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.819274Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.819819Z [debug ] Using proactor: IocpProactor [asyncio]
---------------------------- Captured log teardown ----------------------------
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3740>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3740>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3740>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3740>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3740>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3740>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, '\nDROP TABLE images', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, '\nDROP TABLE images', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E39C0>, '\nDROP TABLE api_clients', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E39C0>, '\nDROP TABLE api_clients', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E39C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E39C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
______________________________ test_ocr_disabled ______________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000026E924BD810>
def test_ocr_disabled(monkeypatch):
monkeypatch.setattr("app.services.ocr_service.settings.OCR_ENABLED", False)
from app.services.ocr_service import extract_text
result = extract_text("/tmp/test.jpg")
> assert result["has_text"] is False
E TypeError: 'coroutine' object is not subscriptable
tests\test_services.py:39: TypeError
---------------------------- Captured stdout setup ----------------------------
2026-02-25T15:57:39.853582Z [debug ] Using proactor: IocpProactor [asyncio]
2026-02-25T15:57:39.854582Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.854833Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.855073Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3AC0>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.855273Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3AC0>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.855511Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3AC0>) [aiosqlite]
2026-02-25T15:57:39.855662Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3AC0>) completed [aiosqlite]
2026-02-25T15:57:39.855916Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3AC0>) [aiosqlite]
2026-02-25T15:57:39.856173Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3AC0>) completed [aiosqlite]
2026-02-25T15:57:39.856651Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.856796Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.857216Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3840>, 'PRAGMA temp.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.857509Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3840>, 'PRAGMA temp.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.857828Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3840>) [aiosqlite]
2026-02-25T15:57:39.858057Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3840>) completed [aiosqlite]
2026-02-25T15:57:39.858382Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3840>) [aiosqlite]
2026-02-25T15:57:39.858618Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3840>) completed [aiosqlite]
2026-02-25T15:57:39.859044Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.859190Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.859491Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3E40>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.859773Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3E40>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.860110Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3E40>) [aiosqlite]
2026-02-25T15:57:39.860247Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3E40>) completed [aiosqlite]
2026-02-25T15:57:39.860443Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3E40>) [aiosqlite]
2026-02-25T15:57:39.860573Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3E40>) completed [aiosqlite]
2026-02-25T15:57:39.860839Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.860968Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.861171Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3BC0>, 'PRAGMA temp.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.861314Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3BC0>, 'PRAGMA temp.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.861535Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3BC0>) [aiosqlite]
2026-02-25T15:57:39.861681Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3BC0>) completed [aiosqlite]
2026-02-25T15:57:39.861899Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3BC0>) [aiosqlite]
2026-02-25T15:57:39.862047Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3BC0>) completed [aiosqlite]
2026-02-25T15:57:39.862577Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.862714Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.862929Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3F40>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.863197Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3F40>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.863452Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3F40>) [aiosqlite]
2026-02-25T15:57:39.863582Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3F40>) completed [aiosqlite]
2026-02-25T15:57:39.863942Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.864072Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.864323Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) [aiosqlite]
2026-02-25T15:57:39.864526Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed [aiosqlite]
2026-02-25T15:57:39.864787Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) [aiosqlite]
2026-02-25T15:57:39.864922Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed [aiosqlite]
2026-02-25T15:57:39.865257Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.865398Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.865630Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) [aiosqlite]
2026-02-25T15:57:39.865838Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.866062Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) [aiosqlite]
2026-02-25T15:57:39.866190Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) completed [aiosqlite]
2026-02-25T15:57:39.866771Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.866904Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.867118Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.867430Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.867718Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) [aiosqlite]
2026-02-25T15:57:39.867848Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) completed [aiosqlite]
2026-02-25T15:57:39.868158Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.868286Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.868485Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) [aiosqlite]
2026-02-25T15:57:39.868680Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed [aiosqlite]
2026-02-25T15:57:39.868891Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) [aiosqlite]
2026-02-25T15:57:39.869019Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) completed [aiosqlite]
2026-02-25T15:57:39.869282Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.869413Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.869610Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) [aiosqlite]
2026-02-25T15:57:39.869791Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.869993Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) [aiosqlite]
2026-02-25T15:57:39.870121Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed [aiosqlite]
2026-02-25T15:57:39.870385Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.870514Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.870725Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) [aiosqlite]
2026-02-25T15:57:39.870912Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.871117Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) [aiosqlite]
2026-02-25T15:57:39.871245Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed [aiosqlite]
2026-02-25T15:57:39.871479Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.871611Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.871840Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551540>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) [aiosqlite]
2026-02-25T15:57:39.872022Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551540>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed [aiosqlite]
2026-02-25T15:57:39.872250Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551540>) [aiosqlite]
2026-02-25T15:57:39.872379Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551540>) completed [aiosqlite]
2026-02-25T15:57:39.872628Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.872771Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.873043Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.873169Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
----------------------------- Captured log setup ------------------------------
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3AC0>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3AC0>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3840>, 'PRAGMA temp.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3840>, 'PRAGMA temp.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3840>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3840>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3840>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3840>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3E40>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3E40>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3E40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3E40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3E40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3E40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3BC0>, 'PRAGMA temp.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3BC0>, 'PRAGMA temp.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3BC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925E3BC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3BC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3BC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3F40>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3F40>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3F40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3F40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3940>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553740>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553740>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551540>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551540>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551540>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551540>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
-------------------------- Captured stdout teardown ---------------------------
2026-02-25T15:57:39.877391Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.877724Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.878080Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.878238Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.878465Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92553940>) [aiosqlite]
2026-02-25T15:57:39.878627Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92553940>) completed [aiosqlite]
2026-02-25T15:57:39.878824Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) [aiosqlite]
2026-02-25T15:57:39.878949Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) completed [aiosqlite]
2026-02-25T15:57:39.879253Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.879381Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.879585Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552D40>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.879729Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552D40>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.879930Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552D40>) [aiosqlite]
2026-02-25T15:57:39.880067Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552D40>) completed [aiosqlite]
2026-02-25T15:57:39.880254Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552D40>) [aiosqlite]
2026-02-25T15:57:39.880392Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552D40>) completed [aiosqlite]
2026-02-25T15:57:39.880835Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.880969Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.881166Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nDROP TABLE images', ()) [aiosqlite]
2026-02-25T15:57:39.881432Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nDROP TABLE images', ()) completed [aiosqlite]
2026-02-25T15:57:39.881710Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) [aiosqlite]
2026-02-25T15:57:39.881837Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) completed [aiosqlite]
2026-02-25T15:57:39.882207Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.882333Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.882555Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, '\nDROP TABLE api_clients', ()) [aiosqlite]
2026-02-25T15:57:39.882793Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, '\nDROP TABLE api_clients', ()) completed [aiosqlite]
2026-02-25T15:57:39.883030Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) [aiosqlite]
2026-02-25T15:57:39.883157Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed [aiosqlite]
2026-02-25T15:57:39.883590Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.883734Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.884041Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.884165Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.884790Z [debug ] Using proactor: IocpProactor [asyncio]
---------------------------- Captured log teardown ----------------------------
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92553940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92553940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552D40>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552D40>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nDROP TABLE images', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551DC0>, '\nDROP TABLE images', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551DC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, '\nDROP TABLE api_clients', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, '\nDROP TABLE api_clients', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
__________________________ test_image_detail_schema ___________________________
def test_image_detail_schema():
from app.schemas import ImageDetail
from app.models.image import ProcessingStatus
# Simule un objet Image ORM
mock_img = MagicMock()
mock_img.id = 1
mock_img.uuid = "abc-123"
mock_img.original_name = "test.jpg"
mock_img.mime_type = "image/jpeg"
mock_img.file_size = 1024
mock_img.width = 800
mock_img.height = 600
mock_img.uploaded_at = None
mock_img.processing_status = ProcessingStatus.DONE
mock_img.thumbnail_path = None
mock_img.exif_make = "Canon"
mock_img.exif_model = "EOS R5"
mock_img.exif_lens = None
mock_img.exif_iso = 400
mock_img.exif_aperture = "f/2.8"
mock_img.exif_shutter = "1/250"
mock_img.exif_focal = "50mm"
mock_img.exif_flash = False
mock_img.exif_orientation = 1
mock_img.exif_software = None
mock_img.exif_taken_at = None
mock_img.exif_gps_lat = 48.857
mock_img.exif_gps_lon = 2.295
mock_img.exif_altitude = 35.0
mock_img.exif_raw = {}
mock_img.has_gps = True
mock_img.ocr_text = "Bonjour monde"
mock_img.ocr_language = "fr"
mock_img.ocr_confidence = 0.95
mock_img.ocr_has_text = True
mock_img.ai_description = "Une belle photo"
mock_img.ai_tags = ["nature", "paysage"]
mock_img.ai_confidence = 0.92
mock_img.ai_model_used = "gemini-1.5-pro"
mock_img.ai_processed_at = None
mock_img.ai_prompt_tokens = 500
mock_img.ai_output_tokens = 200
mock_img.processing_error = None
mock_img.processing_started_at = None
mock_img.processing_done_at = None
> detail = ImageDetail.from_orm_full(mock_img)
tests\test_services.py:116:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
cls = <class 'app.schemas.ImageDetail'>, img = <MagicMock id='2673924352928'>
@classmethod
def from_orm_full(cls, img) -> "ImageDetail":
> return cls(
id=img.id,
uuid=img.uuid,
original_name=img.original_name,
mime_type=img.mime_type,
file_size=img.file_size,
width=img.width,
height=img.height,
uploaded_at=img.uploaded_at,
processing_status=img.processing_status,
client_id=img.client_id,
client_name=img.client.name if img.client else None,
thumbnail_path=img.thumbnail_path,
exif=ExifData(
camera=ExifCamera(
make=img.exif_make,
model=img.exif_model,
lens=img.exif_lens,
iso=img.exif_iso,
aperture=img.exif_aperture,
shutter_speed=img.exif_shutter,
focal_length=img.exif_focal,
flash=img.exif_flash,
orientation=img.exif_orientation,
software=img.exif_software,
taken_at=img.exif_taken_at,
),
gps=ExifGPS(
latitude=img.exif_gps_lat,
longitude=img.exif_gps_lon,
altitude=img.exif_altitude,
has_gps=img.has_gps,
),
raw=img.exif_raw,
),
ocr=OcrData(
text=img.ocr_text,
language=img.ocr_language,
confidence=img.ocr_confidence,
has_text=img.ocr_has_text or False,
),
ai=AiData(
description=img.ai_description,
tags=img.ai_tags,
confidence=img.ai_confidence,
model_used=img.ai_model_used,
processed_at=img.ai_processed_at,
prompt_tokens=img.ai_prompt_tokens,
output_tokens=img.ai_output_tokens,
),
processing=ProcessingInfo(
status=img.processing_status,
error=img.processing_error,
started_at=img.processing_started_at,
done_at=img.processing_done_at,
),
)
E pydantic_core._pydantic_core.ValidationError: 2 validation errors for ImageDetail
E client_id
E Input should be a valid string [type=string_type, input_value=<MagicMock name='mock.cli..._id' id='2673924352256'>, input_type=MagicMock]
E For further information visit https://errors.pydantic.dev/2.12/v/string_type
E client_name
E Input should be a valid string [type=string_type, input_value=<MagicMock name='mock.cli...ame' id='2673924353264'>, input_type=MagicMock]
E For further information visit https://errors.pydantic.dev/2.12/v/string_type
app\schemas\__init__.py:102: ValidationError
---------------------------- Captured stdout setup ----------------------------
2026-02-25T15:57:39.971817Z [debug ] Using proactor: IocpProactor [asyncio]
2026-02-25T15:57:39.973007Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.973251Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.973568Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925537C0>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.973741Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925537C0>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.973977Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925537C0>) [aiosqlite]
2026-02-25T15:57:39.974151Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925537C0>) completed [aiosqlite]
2026-02-25T15:57:39.974409Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925537C0>) [aiosqlite]
2026-02-25T15:57:39.974541Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925537C0>) completed [aiosqlite]
2026-02-25T15:57:39.974887Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.975016Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.975239Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'PRAGMA temp.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:39.975393Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'PRAGMA temp.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:39.975621Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551640>) [aiosqlite]
2026-02-25T15:57:39.975748Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551640>) completed [aiosqlite]
2026-02-25T15:57:39.975957Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) [aiosqlite]
2026-02-25T15:57:39.976082Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) completed [aiosqlite]
2026-02-25T15:57:39.976415Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.976545Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.976764Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.976910Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.977197Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925523C0>) [aiosqlite]
2026-02-25T15:57:39.977459Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925523C0>) completed [aiosqlite]
2026-02-25T15:57:39.977783Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) [aiosqlite]
2026-02-25T15:57:39.977923Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed [aiosqlite]
2026-02-25T15:57:39.978237Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.978375Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.978712Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'PRAGMA temp.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:39.978970Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'PRAGMA temp.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:39.979269Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552640>) [aiosqlite]
2026-02-25T15:57:39.979656Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552640>) completed [aiosqlite]
2026-02-25T15:57:39.979913Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) [aiosqlite]
2026-02-25T15:57:39.980051Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed [aiosqlite]
2026-02-25T15:57:39.980613Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.980761Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.981007Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551140>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.981299Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551140>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.981621Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551140>) [aiosqlite]
2026-02-25T15:57:39.981786Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551140>) completed [aiosqlite]
2026-02-25T15:57:39.982116Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.982285Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.982546Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) [aiosqlite]
2026-02-25T15:57:39.982766Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed [aiosqlite]
2026-02-25T15:57:39.983033Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) [aiosqlite]
2026-02-25T15:57:39.983192Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) completed [aiosqlite]
2026-02-25T15:57:39.983500Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.983634Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.983849Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) [aiosqlite]
2026-02-25T15:57:39.984055Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.984277Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) [aiosqlite]
2026-02-25T15:57:39.984407Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) completed [aiosqlite]
2026-02-25T15:57:39.984987Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.985113Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.985328Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) [aiosqlite]
2026-02-25T15:57:39.985641Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed [aiosqlite]
2026-02-25T15:57:39.985900Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) [aiosqlite]
2026-02-25T15:57:39.986030Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed [aiosqlite]
2026-02-25T15:57:39.986431Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.986784Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.987147Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) [aiosqlite]
2026-02-25T15:57:39.987397Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed [aiosqlite]
2026-02-25T15:57:39.987678Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) [aiosqlite]
2026-02-25T15:57:39.987814Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) completed [aiosqlite]
2026-02-25T15:57:39.988142Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.988280Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.988505Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) [aiosqlite]
2026-02-25T15:57:39.988733Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.988972Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) [aiosqlite]
2026-02-25T15:57:39.989116Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed [aiosqlite]
2026-02-25T15:57:39.989396Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.989525Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.989736Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3CC0>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) [aiosqlite]
2026-02-25T15:57:39.989950Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3CC0>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed [aiosqlite]
2026-02-25T15:57:39.990162Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3CC0>) [aiosqlite]
2026-02-25T15:57:39.990290Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3CC0>) completed [aiosqlite]
2026-02-25T15:57:39.990565Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.990706Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.990904Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3B40>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) [aiosqlite]
2026-02-25T15:57:39.991088Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3B40>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed [aiosqlite]
2026-02-25T15:57:39.991300Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3B40>) [aiosqlite]
2026-02-25T15:57:39.991441Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3B40>) completed [aiosqlite]
2026-02-25T15:57:39.991700Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.991829Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:39.992119Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:39.992249Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
----------------------------- Captured log setup ------------------------------
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925537C0>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925537C0>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925537C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925537C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925537C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925537C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'PRAGMA temp.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551640>, 'PRAGMA temp.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92551640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925523C0>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925523C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925523C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925523C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'PRAGMA temp.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92552640>, 'PRAGMA temp.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E92552640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92552640>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551140>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551140>, '\nCREATE TABLE api_clients (\n\tid VARCHAR(36) NOT NULL, \n\tname VARCHAR(256) NOT NULL, \n\tapi_key_hash VARCHAR(64) NOT NULL, \n\tscopes JSON NOT NULL, \n\t"plan" VARCHAR(8) NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tstorage_used_bytes BIGINT NOT NULL, \n\tquota_storage_mb INTEGER NOT NULL, \n\tquota_images INTEGER NOT NULL, \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tPRIMARY KEY (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551140>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551140>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553940>, 'CREATE UNIQUE INDEX ix_api_clients_api_key_hash ON api_clients (api_key_hash)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553940>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E38C0>, 'CREATE INDEX ix_api_clients_id ON api_clients (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E38C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553840>, '\nCREATE TABLE images (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tclient_id VARCHAR(36) NOT NULL, \n\toriginal_name VARCHAR(512) NOT NULL, \n\tfilename VARCHAR(512) NOT NULL, \n\tfile_path VARCHAR(1024) NOT NULL, \n\tthumbnail_path VARCHAR(1024), \n\tmime_type VARCHAR(128), \n\tfile_size BIGINT, \n\twidth INTEGER, \n\theight INTEGER, \n\tuploaded_at DATETIME, \n\tprocessing_status VARCHAR(10) NOT NULL, \n\tprocessing_error TEXT, \n\tprocessing_started_at DATETIME, \n\tprocessing_done_at DATETIME, \n\texif_raw JSON, \n\texif_make VARCHAR(256), \n\texif_model VARCHAR(256), \n\texif_lens VARCHAR(256), \n\texif_taken_at DATETIME, \n\texif_gps_lat FLOAT, \n\texif_gps_lon FLOAT, \n\texif_altitude FLOAT, \n\texif_iso INTEGER, \n\texif_aperture VARCHAR(32), \n\texif_shutter VARCHAR(32), \n\texif_focal VARCHAR(32), \n\texif_flash BOOLEAN, \n\texif_orientation INTEGER, \n\texif_software VARCHAR(256), \n\tocr_text TEXT, \n\tocr_language VARCHAR(64), \n\tocr_confidence FLOAT, \n\tocr_has_text BOOLEAN, \n\tai_description TEXT, \n\tai_tags JSON, \n\tai_confidence FLOAT, \n\tai_model_used VARCHAR(128), \n\tai_processed_at DATETIME, \n\tai_prompt_tokens INTEGER, \n\tai_output_tokens INTEGER, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(client_id) REFERENCES api_clients (id)\n)\n\n', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553840>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92551D40>, 'CREATE UNIQUE INDEX ix_images_uuid ON images (uuid)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92551D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E92553340>, 'CREATE INDEX ix_images_id ON images (id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E92553340>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3CC0>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3CC0>, 'CREATE INDEX ix_images_client_id ON images (client_id)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3CC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3CC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3B40>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925E3B40>, 'CREATE INDEX ix_images_processing_status ON images (processing_status)', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3B40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925E3B40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
-------------------------- Captured stdout teardown ---------------------------
2026-02-25T15:57:40.000120Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.000459Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.000702Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4AC0>, 'PRAGMA main.table_info("images")', ()) [aiosqlite]
2026-02-25T15:57:40.000868Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4AC0>, 'PRAGMA main.table_info("images")', ()) completed [aiosqlite]
2026-02-25T15:57:40.001070Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4AC0>) [aiosqlite]
2026-02-25T15:57:40.001228Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4AC0>) completed [aiosqlite]
2026-02-25T15:57:40.001430Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4AC0>) [aiosqlite]
2026-02-25T15:57:40.001555Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4AC0>) completed [aiosqlite]
2026-02-25T15:57:40.001964Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.002215Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.002528Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4D40>, 'PRAGMA main.table_info("api_clients")', ()) [aiosqlite]
2026-02-25T15:57:40.002686Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4D40>, 'PRAGMA main.table_info("api_clients")', ()) completed [aiosqlite]
2026-02-25T15:57:40.002942Z [debug ] executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4D40>) [aiosqlite]
2026-02-25T15:57:40.003090Z [debug ] operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4D40>) completed [aiosqlite]
2026-02-25T15:57:40.003281Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4D40>) [aiosqlite]
2026-02-25T15:57:40.003425Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4D40>) completed [aiosqlite]
2026-02-25T15:57:40.003745Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.003872Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.004073Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4CC0>, '\nDROP TABLE images', ()) [aiosqlite]
2026-02-25T15:57:40.004420Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4CC0>, '\nDROP TABLE images', ()) completed [aiosqlite]
2026-02-25T15:57:40.004632Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4CC0>) [aiosqlite]
2026-02-25T15:57:40.004759Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4CC0>) completed [aiosqlite]
2026-02-25T15:57:40.005040Z [debug ] executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.005167Z [debug ] operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.005416Z [debug ] executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4C40>, '\nDROP TABLE api_clients', ()) [aiosqlite]
2026-02-25T15:57:40.005629Z [debug ] operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4C40>, '\nDROP TABLE api_clients', ()) completed [aiosqlite]
2026-02-25T15:57:40.005852Z [debug ] executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4C40>) [aiosqlite]
2026-02-25T15:57:40.005978Z [debug ] operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4C40>) completed [aiosqlite]
2026-02-25T15:57:40.006305Z [debug ] executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.006439Z [debug ] operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.006732Z [debug ] executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) [aiosqlite]
2026-02-25T15:57:40.006982Z [debug ] operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed [aiosqlite]
2026-02-25T15:57:40.007572Z [debug ] Using proactor: IocpProactor [asyncio]
---------------------------- Captured log teardown ----------------------------
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4AC0>, 'PRAGMA main.table_info("images")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4AC0>, 'PRAGMA main.table_info("images")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4AC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4AC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4D40>, 'PRAGMA main.table_info("api_clients")', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4D40>, 'PRAGMA main.table_info("api_clients")', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method fetchall of sqlite3.Cursor object at 0x0000026E925F4D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4D40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4D40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4CC0>, '\nDROP TABLE images', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4CC0>, '\nDROP TABLE images', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4CC0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4CC0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method cursor of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4C40>, '\nDROP TABLE api_clients', ())
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method execute of sqlite3.Cursor object at 0x0000026E925F4C40>, '\nDROP TABLE api_clients', ()) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4C40>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method close of sqlite3.Cursor object at 0x0000026E925F4C40>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method commit of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG aiosqlite:core.py:114 executing functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>)
DEBUG aiosqlite:core.py:116 operation functools.partial(<built-in method rollback of sqlite3.Connection object at 0x0000026E9249F4C0>) completed
DEBUG asyncio:proactor_events.py:633 Using proactor: IocpProactor
============================== warnings summary ===============================
..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\_pytest\config\__init__.py:1441
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\_pytest\config\__init__.py:1441: PytestConfigWarning: Unknown config option: env
self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ===========================
FAILED tests/test_services.py::test_exif_missing_file - TypeError: 'coroutine...
FAILED tests/test_services.py::test_ocr_disabled - TypeError: 'coroutine' obj...
FAILED tests/test_services.py::test_image_detail_schema - pydantic_core._pyda...
=================== 3 failed, 4 passed, 1 warning in 0.55s ====================
<sys>:0: RuntimeWarning: coroutine 'extract_exif' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
<sys>:0: RuntimeWarning: coroutine 'extract_text' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
View File
+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;
}

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