fix(web,api): débloquer la boucle de chargement 'Inventaire en cours' (Recherche/Admin)

- web: gestion d'erreur et d'état vide sur SearchPage/AdminPage
- web: client API — traitement des réponses sans contenu/erreurs réseau
- api: jobs.service — éviter le statut de scan perpétuellement en cours
- tests: client.test.ts

Refs: #12 (QA non-GREEN faute d'exécution, pas d'échec réel)
This commit is contained in:
Git Agent
2026-08-23 10:01:54 +02:00
parent 8f1140127f
commit e94524f119
6 changed files with 159 additions and 45 deletions

View File

@ -1,12 +1,16 @@
import { Injectable } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { Injectable, OnModuleInit } from "@nestjs/common";
import { desc, eq, inArray } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { jobs } from "../database/schema.js";
@Injectable()
export class JobsService {
export class JobsService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {}
onModuleInit(): void {
this.failInterruptedJobs();
}
create(type: string, detail?: string) {
const now = this.database.now();
return this.database.db
@ -43,4 +47,16 @@ export class JobsService {
list(limit = 50) {
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all();
}
private failInterruptedJobs(): void {
this.database.db
.update(jobs)
.set({
status: "failed",
error: "Job interrupted before completion, most likely by API shutdown or restart",
updatedAt: this.database.now()
})
.where(inArray(jobs.status, ["queued", "running"]))
.run();
}
}

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { ApiFallbackError, getApiFallback } from "./client";
describe("api fallback helpers", () => {
it("extracts typed fallback payloads", () => {
expect(getApiFallback<string[]>(new ApiFallbackError("offline", ["demo"]))).toEqual(["demo"]);
});
it("ignores non fallback errors", () => {
expect(getApiFallback<string[]>(new Error("boom"))).toBeUndefined();
});
});

View File

@ -28,6 +28,10 @@ export class ApiFallbackError extends Error {
}
}
export function getApiFallback<T>(error: unknown): T | undefined {
return error instanceof ApiFallbackError ? (error.fallback as T) : undefined;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
const response = await fetch(`${API_BASE}${path}`, {

View File

@ -1,26 +1,51 @@
import { FormEvent, useEffect, useState } from "react";
import { Play, Plus } from "lucide-react";
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
import { api } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
import { api, getApiFallback } from "../api/client";
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
export function AdminPage() {
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]);
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("Bibliotheque locale");
const [path, setPath] = useState("/library");
const [error, setError] = useState<string>();
async function refresh() {
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
setLibraries(nextLibraries);
setJobs(nextJobs);
setUsers(nextUsers);
setLoading(true);
setError(undefined);
const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
const errors: string[] = [];
if (libraryResult.status === "fulfilled") {
setLibraries(libraryResult.value);
} else {
setLibraries(getApiFallback<LibraryDto[]>(libraryResult.reason) ?? []);
errors.push("bibliotheques");
}
if (jobResult.status === "fulfilled") {
setJobs(jobResult.value);
} else {
setJobs(getApiFallback<JobDto[]>(jobResult.reason) ?? []);
errors.push("travaux");
}
if (userResult.status === "fulfilled") {
setUsers(userResult.value);
} else {
setUsers(getApiFallback<UserDto[]>(userResult.reason) ?? []);
errors.push("comptes");
}
setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined);
setLoading(false);
}
useEffect(() => {
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
void refresh();
}, []);
async function createLibrary(event: FormEvent) {
@ -30,7 +55,9 @@ export function AdminPage() {
await api.createLibrary({ name, path, enabled: true });
await refresh();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "Creation impossible");
const fallback = getApiFallback<LibraryDto>(createError);
if (fallback) setLibraries((current) => [fallback, ...current]);
setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible");
}
}
@ -40,20 +67,28 @@ export function AdminPage() {
await api.scanLibrary(id);
await refresh();
} catch (scanError) {
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
const fallback = getApiFallback<JobDto>(scanError);
if (fallback) setJobs((current) => [fallback, ...current]);
setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible");
}
}
if (!libraries) return <LoadingState />;
return (
<div className="page-grid">
<Panel className="span-2">
<div className="section-heading">
<h1>Administration</h1>
<span>{users.length} comptes</span>
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
</div>
<ErrorRibbon message={error} />
{error && (
<div className="retry-row">
<span>Les formulaires restent disponibles.</span>
<button className="ghost-button" onClick={() => void refresh()}>
Reessayer
</button>
</div>
)}
<form className="admin-form" onSubmit={createLibrary}>
<label>
Nom du rayon
@ -75,32 +110,44 @@ export function AdminPage() {
<h2>Travaux</h2>
<span>{jobs.length}</span>
</div>
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<strong>{job.type}</strong>
<span>{job.status}</span>
</div>
))}
</div>
{loading && !jobs.length ? (
<LoadingState label="Lecture des travaux" />
) : jobs.length ? (
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<strong>{job.type}</strong>
<span>{job.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="Aucun travail" detail="Les scans apparaitront ici." />
)}
</Panel>
<Panel className="span-3">
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div>
<strong>{library.name}</strong>
<span>{library.path}</span>
{loading && !libraries.length ? (
<LoadingState label="Lecture des rayons" />
) : libraries.length ? (
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div>
<strong>{library.name}</strong>
<span>{library.path}</span>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
</div>
))}
</div>
))}
</div>
) : (
<EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
)}
</Panel>
</div>
);

View File

@ -1,22 +1,37 @@
import { FormEvent, useEffect, useState } from "react";
import { Search } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { api, getApiFallback } from "../api/client";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SearchPage() {
const [query, setQuery] = useState("");
const [books, setBooks] = useState<BookDto[] | null>(null);
const [books, setBooks] = useState<BookDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
async function loadBooks(nextQuery = query) {
setLoading(true);
setError(undefined);
try {
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
} catch (loadError) {
const fallback = getApiFallback<BookDto[]>(loadError);
setBooks(fallback ?? []);
setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible.");
} finally {
setLoading(false);
}
}
useEffect(() => {
api.books().then(setBooks);
void loadBooks("");
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
setBooks(null);
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
await loadBooks(query);
}
return (
@ -29,8 +44,16 @@ export function SearchPage() {
Chercher
</button>
</form>
{error && (
<div className="retry-row">
<span>{error}</span>
<button className="ghost-button" onClick={() => void loadBooks(query)}>
Reessayer
</button>
</div>
)}
</Panel>
{!books ? (
{loading && !books.length ? (
<LoadingState />
) : books.length ? (
<section className="book-grid span-3">
@ -40,7 +63,10 @@ export function SearchPage() {
</section>
) : (
<Panel className="span-3">
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
<EmptyState
title={loading ? "Recherche en cours" : "Aucun specimen"}
detail={loading ? "Le formulaire reste disponible." : "Essaie un autre terme ou relance l'indexation."}
/>
</Panel>
)}
</div>

View File

@ -326,6 +326,15 @@ input {
background: rgba(169, 72, 52, 0.18);
}
.retry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
color: var(--ink-muted);
}
.empty-state,
.loading-state {
display: grid;