From ef8a755a8343fbbd8927dd3c82ffc8d348040891 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Thu, 5 Mar 2026 21:29:48 +0100 Subject: [PATCH] add book_count feature, migrate backoffice to server actions, fix healthcheck --- PLAN.md | 6 ++- apps/api/src/index_jobs.rs | 51 ++++++++++++++++++++ apps/api/src/libraries.rs | 9 +++- apps/api/src/main.rs | 2 + apps/backoffice/Dockerfile | 1 + apps/backoffice/app/globals.css | 26 ++++++++++ apps/backoffice/app/jobs/page.tsx | 55 +++++++++++++++++++--- apps/backoffice/app/jobs/rebuild/route.ts | 17 ------- apps/backoffice/app/tokens/create/route.ts | 27 ----------- apps/backoffice/app/tokens/page.tsx | 38 +++++++++++---- apps/backoffice/app/tokens/revoke/route.ts | 13 ----- apps/backoffice/lib/api.ts | 50 +++++++++++++++++++- infra/docker-compose.yml | 6 +-- 13 files changed, 222 insertions(+), 79 deletions(-) delete mode 100644 apps/backoffice/app/jobs/rebuild/route.ts delete mode 100644 apps/backoffice/app/tokens/create/route.ts delete mode 100644 apps/backoffice/app/tokens/revoke/route.ts diff --git a/PLAN.md b/PLAN.md index 380b16e..7ce191f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -137,10 +137,12 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques ### T16 - Backoffice Next.js - [x] Bootstrap app Next.js (`apps/backoffice`) -- [x] Vue Libraries -- [x] Vue Jobs +- [x] Vue Libraries (folder selector, add/delete) +- [x] Vue Jobs (rebuild per library, cancel job) - [x] Vue API Tokens (create/list/revoke) - [x] Auth backoffice via token API +- [x] Theme switcher Light/Dark +- [x] Style aligne avec site officiel **DoD:** Backoffice Next.js utilisable pour l'administration complete. diff --git a/apps/api/src/index_jobs.rs b/apps/api/src/index_jobs.rs index 4cb060c..8b4b876 100644 --- a/apps/api/src/index_jobs.rs +++ b/apps/api/src/index_jobs.rs @@ -59,6 +59,57 @@ pub async fn list_index_jobs(State(state): State) -> Result, + id: axum::extract::Path, +) -> Result, ApiError> { + let rows_affected = sqlx::query( + "UPDATE index_jobs SET status = 'cancelled' WHERE id = $1 AND status IN ('pending', 'running')", + ) + .bind(id.0) + .execute(&state.pool) + .await?; + + if rows_affected.rows_affected() == 0 { + return Err(ApiError::not_found("job not found or already finished")); + } + + let row = sqlx::query( + "SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs WHERE id = $1", + ) + .bind(id.0) + .fetch_one(&state.pool) + .await?; + + Ok(Json(map_row(row))) +} + +#[derive(Serialize)] +pub struct FolderItem { + pub name: String, + pub path: String, +} + +pub async fn list_folders(State(_state): State) -> Result>, ApiError> { + let libraries_path = std::path::Path::new("/libraries"); + let mut folders = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(libraries_path) { + for entry in entries.flatten() { + if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().to_string(); + folders.push(FolderItem { + name: name.clone(), + path: format!("/libraries/{}", name), + }); + } + } + } + + folders.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(folders)) +} + fn map_row(row: sqlx::postgres::PgRow) -> IndexJobItem { IndexJobItem { id: row.get("id"), diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index b54b86f..b1e2e7b 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -13,6 +13,7 @@ pub struct LibraryDto { pub name: String, pub root_path: String, pub enabled: bool, + pub book_count: i64, } #[derive(Deserialize)] @@ -22,7 +23,11 @@ pub struct CreateLibraryInput { } pub async fn list_libraries(State(state): State) -> Result>, ApiError> { - let rows = sqlx::query("SELECT id, name, root_path, enabled FROM libraries ORDER BY created_at DESC") + let rows = sqlx::query( + "SELECT l.id, l.name, l.root_path, l.enabled, + (SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count + FROM libraries l ORDER BY l.created_at DESC" + ) .fetch_all(&state.pool) .await?; @@ -33,6 +38,7 @@ pub async fn list_libraries(State(state): State) -> Result anyhow::Result<()> { .route("/libraries/:id", delete(libraries::delete_library)) .route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild)) .route("/index/status", get(index_jobs::list_index_jobs)) + .route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job)) + .route("/folders", get(index_jobs::list_folders)) .route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token)) .route("/admin/tokens/:id", delete(tokens::revoke_token)) .route_layer(middleware::from_fn_with_state( diff --git a/apps/backoffice/Dockerfile b/apps/backoffice/Dockerfile index 1b14390..48c9c29 100644 --- a/apps/backoffice/Dockerfile +++ b/apps/backoffice/Dockerfile @@ -13,6 +13,7 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=8082 +ENV HOST=0.0.0.0 RUN apk add --no-cache wget COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index ea45aca..2bb3a8b 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -210,6 +210,32 @@ button:hover { min-width: 66px; } +.cancel-btn { + background: linear-gradient(95deg, hsl(2 72% 48% / 0.15), hsl(338 82% 62% / 0.2)); + border-color: hsl(2 72% 48% / 0.5); +} + +.status-pending { color: hsl(45 93% 47%); } +.status-running { color: hsl(192 85% 55%); } +.status-completed { color: hsl(142 60% 45%); } +.status-failed { color: hsl(2 72% 48%); } +.status-cancelled { color: hsl(220 13% 40%); } + +.error-hint { + display: inline-block; + margin-left: 6px; + width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + border-radius: 50%; + background: hsl(2 72% 48%); + color: white; + font-size: 11px; + font-weight: bold; + cursor: help; +} + .card { background: var(--card); border: 1px solid var(--line); diff --git a/apps/backoffice/app/jobs/page.tsx b/apps/backoffice/app/jobs/page.tsx index f4a7fd6..d7dcd1b 100644 --- a/apps/backoffice/app/jobs/page.tsx +++ b/apps/backoffice/app/jobs/page.tsx @@ -1,16 +1,43 @@ -import { listJobs } from "../../lib/api"; +import { revalidatePath } from "next/cache"; +import { listJobs, fetchLibraries, rebuildIndex, cancelJob, IndexJobDto, LibraryDto } from "../../lib/api"; export const dynamic = "force-dynamic"; export default async function JobsPage() { - const jobs = await listJobs().catch(() => []); + const [jobs, libraries] = await Promise.all([ + listJobs().catch(() => [] as IndexJobDto[]), + fetchLibraries().catch(() => [] as LibraryDto[]) + ]); + + const libraryMap = new Map(libraries.map(l => [l.id, l.name])); + + async function triggerRebuild(formData: FormData) { + "use server"; + const libraryId = formData.get("library_id") as string; + await rebuildIndex(libraryId || undefined); + revalidatePath("/jobs"); + } + + async function cancelJobAction(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await cancelJob(id); + revalidatePath("/jobs"); + } return ( <>

Index Jobs

-
- + +
@@ -18,20 +45,34 @@ export default async function JobsPage() { ID + Library Type Status Created + Actions {jobs.map((job) => ( - {job.id} + {job.id.slice(0, 8)} + {job.library_id ? libraryMap.get(job.library_id) || job.library_id.slice(0, 8) : "—"} {job.type} - {job.status} - {job.created_at} + + {job.status} + {job.error_opt && !} + + {new Date(job.created_at).toLocaleString()} + + {job.status === "pending" || job.status === "running" ? ( +
+ + +
+ ) : null} + ))} diff --git a/apps/backoffice/app/jobs/rebuild/route.ts b/apps/backoffice/app/jobs/rebuild/route.ts deleted file mode 100644 index e4fa592..0000000 --- a/apps/backoffice/app/jobs/rebuild/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { revalidatePath } from "next/cache"; -import { NextResponse } from "next/server"; -import { apiFetch } from "../../../lib/api"; - -export async function POST(req: Request) { - const form = await req.formData(); - const libraryId = String(form.get("library_id") || "").trim(); - const body = libraryId ? { library_id: libraryId } : {}; - - await apiFetch("/index/rebuild", { - method: "POST", - body: JSON.stringify(body) - }).catch(() => null); - - revalidatePath("/jobs"); - return NextResponse.redirect(new URL("/jobs", req.url)); -} diff --git a/apps/backoffice/app/tokens/create/route.ts b/apps/backoffice/app/tokens/create/route.ts deleted file mode 100644 index cb15bcf..0000000 --- a/apps/backoffice/app/tokens/create/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { revalidatePath } from "next/cache"; -import { NextResponse } from "next/server"; -import { apiFetch } from "../../../lib/api"; - -type CreatedToken = { token: string }; - -export async function POST(req: Request) { - const form = await req.formData(); - const name = String(form.get("name") || "").trim(); - const scope = String(form.get("scope") || "read").trim(); - - let created = ""; - if (name) { - const res = await apiFetch("/admin/tokens", { - method: "POST", - body: JSON.stringify({ name, scope }) - }).catch(() => null); - created = res?.token || ""; - } - - revalidatePath("/tokens"); - const url = new URL("/tokens", req.url); - if (created) { - url.searchParams.set("created", created); - } - return NextResponse.redirect(url); -} diff --git a/apps/backoffice/app/tokens/page.tsx b/apps/backoffice/app/tokens/page.tsx index 627ba37..5d59103 100644 --- a/apps/backoffice/app/tokens/page.tsx +++ b/apps/backoffice/app/tokens/page.tsx @@ -1,4 +1,6 @@ -import { listTokens } from "../../lib/api"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api"; export const dynamic = "force-dynamic"; @@ -8,7 +10,25 @@ export default async function TokensPage({ searchParams: Promise<{ created?: string }>; }) { const params = await searchParams; - const tokens = await listTokens().catch(() => []); + const tokens = await listTokens().catch(() => [] as TokenDto[]); + + async function createTokenAction(formData: FormData) { + "use server"; + const name = formData.get("name") as string; + const scope = formData.get("scope") as string; + if (name) { + const result = await createToken(name, scope); + revalidatePath("/tokens"); + redirect(`/tokens?created=${encodeURIComponent(result.token)}`); + } + } + + async function revokeTokenAction(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await revokeToken(id); + revalidatePath("/tokens"); + } return ( <> @@ -16,13 +36,13 @@ export default async function TokensPage({ {params.created ? (
- Token created: + Token created (copy it now, it won't be shown again):
{params.created}
) : null}
-
+ - -
+ {!token.revoked_at && ( +
+ + +
+ )} ))} diff --git a/apps/backoffice/app/tokens/revoke/route.ts b/apps/backoffice/app/tokens/revoke/route.ts deleted file mode 100644 index d91cb31..0000000 --- a/apps/backoffice/app/tokens/revoke/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { revalidatePath } from "next/cache"; -import { NextResponse } from "next/server"; -import { apiFetch } from "../../../lib/api"; - -export async function POST(req: Request) { - const form = await req.formData(); - const id = String(form.get("id") || "").trim(); - if (id) { - await apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }).catch(() => null); - } - revalidatePath("/tokens"); - return NextResponse.redirect(new URL("/tokens", req.url)); -} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index a8ffe13..4751c26 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -3,12 +3,17 @@ export type LibraryDto = { name: string; root_path: string; enabled: boolean; + book_count: number; }; export type IndexJobDto = { id: string; + library_id: string | null; type: string; status: string; + started_at: string | null; + finished_at: string | null; + error_opt: string | null; created_at: string; }; @@ -20,6 +25,11 @@ export type TokenDto = { revoked_at: string | null; }; +export type FolderItem = { + name: string; + path: string; +}; + function config() { const baseUrl = process.env.API_BASE_URL || "http://api:8080"; const token = process.env.API_BOOTSTRAP_TOKEN; @@ -54,14 +64,52 @@ export async function apiFetch(path: string, init?: RequestInit): Promise return (await res.json()) as T; } -export async function listLibraries() { +export async function fetchLibraries() { return apiFetch("/libraries"); } +export async function createLibrary(name: string, rootPath: string) { + return apiFetch("/libraries", { + method: "POST", + body: JSON.stringify({ name, root_path: rootPath }) + }); +} + +export async function deleteLibrary(id: string) { + return apiFetch(`/libraries/${id}`, { method: "DELETE" }); +} + export async function listJobs() { return apiFetch("/index/status"); } +export async function rebuildIndex(libraryId?: string) { + const body = libraryId ? { library_id: libraryId } : {}; + return apiFetch("/index/rebuild", { + method: "POST", + body: JSON.stringify(body) + }); +} + +export async function cancelJob(id: string) { + return apiFetch(`/index/cancel/${id}`, { method: "POST" }); +} + +export async function listFolders() { + return apiFetch("/folders"); +} + export async function listTokens() { return apiFetch("/admin/tokens"); } + +export async function createToken(name: string, scope: string) { + return apiFetch<{ token: string }>("/admin/tokens", { + method: "POST", + body: JSON.stringify({ name, scope }) + }); +} + +export async function revokeToken(id: string) { + return apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }); +} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 63e3e5c..9af4c27 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -96,15 +96,15 @@ services: env_file: - ../.env environment: - - PORT=${BACKOFFICE_PORT:-8082} - - HOSTNAME=0.0.0.0 + - PORT=8082 + - HOST=0.0.0.0 ports: - "${BACKOFFICE_PORT:-8082}:8082" depends_on: api: condition: service_healthy healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/health"] + test: ["CMD", "wget", "-q", "-O", "-", "http://host.docker.internal:8082/health"] interval: 10s timeout: 5s retries: 5