add book_count feature, migrate backoffice to server actions, fix healthcheck

This commit is contained in:
2026-03-05 21:29:48 +01:00
parent 3a96f6ba36
commit ef8a755a83
13 changed files with 222 additions and 79 deletions

View File

@@ -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.

View File

@@ -59,6 +59,57 @@ pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<I
Ok(Json(rows.into_iter().map(map_row).collect()))
}
pub async fn cancel_job(
State(state): State<AppState>,
id: axum::extract::Path<Uuid>,
) -> Result<Json<IndexJobItem>, 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<AppState>) -> Result<Json<Vec<FolderItem>>, 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"),

View File

@@ -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<AppState>) -> Result<Json<Vec<LibraryDto>>, 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<AppState>) -> Result<Json<Vec<Li
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
})
.collect();
@@ -65,6 +71,7 @@ pub async fn create_library(
name: input.name.trim().to_string(),
root_path,
enabled: true,
book_count: 0,
}))
}

View File

@@ -94,6 +94,8 @@ async fn main() -> 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(

View File

@@ -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

View File

@@ -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);

View File

@@ -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 (
<>
<h1>Index Jobs</h1>
<div className="card">
<form action="/jobs/rebuild" method="post">
<input name="library_id" placeholder="optional library UUID" />
<form action={triggerRebuild}>
<select name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
<button type="submit">Queue Rebuild</button>
</form>
</div>
@@ -18,20 +45,34 @@ export default async function JobsPage() {
<thead>
<tr>
<th>ID</th>
<th>Library</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<code>{job.id}</code>
<code>{job.id.slice(0, 8)}</code>
</td>
<td>{job.library_id ? libraryMap.get(job.library_id) || job.library_id.slice(0, 8) : "—"}</td>
<td>{job.type}</td>
<td>{job.status}</td>
<td>{job.created_at}</td>
<td>
<span className={`status-${job.status}`}>{job.status}</span>
{job.error_opt && <span className="error-hint" title={job.error_opt}>!</span>}
</td>
<td>{new Date(job.created_at).toLocaleString()}</td>
<td>
{job.status === "pending" || job.status === "running" ? (
<form action={cancelJobAction}>
<input type="hidden" name="id" value={job.id} />
<button type="submit" className="cancel-btn">Cancel</button>
</form>
) : null}
</td>
</tr>
))}
</tbody>

View File

@@ -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));
}

View File

@@ -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<CreatedToken>("/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);
}

View File

@@ -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 ? (
<div className="card">
<strong>Token created:</strong>
<strong>Token created (copy it now, it won't be shown again):</strong>
<pre>{params.created}</pre>
</div>
) : null}
<div className="card">
<form action="/tokens/create" method="post">
<form action={createTokenAction}>
<input name="name" placeholder="token name" required />
<select name="scope" defaultValue="read">
<option value="read">read</option>
@@ -52,10 +72,12 @@ export default async function TokensPage({
</td>
<td>{token.revoked_at ? "yes" : "no"}</td>
<td>
<form className="inline" action="/tokens/revoke" method="post">
<input type="hidden" name="id" value={token.id} />
<button type="submit">Revoke</button>
</form>
{!token.revoked_at && (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<button type="submit">Revoke</button>
</form>
)}
</td>
</tr>
))}

View File

@@ -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));
}

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T>
return (await res.json()) as T;
}
export async function listLibraries() {
export async function fetchLibraries() {
return apiFetch<LibraryDto[]>("/libraries");
}
export async function createLibrary(name: string, rootPath: string) {
return apiFetch<LibraryDto>("/libraries", {
method: "POST",
body: JSON.stringify({ name, root_path: rootPath })
});
}
export async function deleteLibrary(id: string) {
return apiFetch<void>(`/libraries/${id}`, { method: "DELETE" });
}
export async function listJobs() {
return apiFetch<IndexJobDto[]>("/index/status");
}
export async function rebuildIndex(libraryId?: string) {
const body = libraryId ? { library_id: libraryId } : {};
return apiFetch<IndexJobDto>("/index/rebuild", {
method: "POST",
body: JSON.stringify(body)
});
}
export async function cancelJob(id: string) {
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
}
export async function listFolders() {
return apiFetch<FolderItem[]>("/folders");
}
export async function listTokens() {
return apiFetch<TokenDto[]>("/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<void>(`/admin/tokens/${id}`, { method: "DELETE" });
}

View File

@@ -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