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