feat(web): page admin des sources de métadonnées et de l'automatisation

Configuration des providers (activation, priorité, clé API), réglages
d'automatisation (watch des bibliothèques, auto-enrichissement,
planifications scan/enrichissement) et déclenchement manuel, avec
client API, mocks et styles associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 13:10:50 +02:00
parent 48e9459cf3
commit 62abf890e0
10 changed files with 905 additions and 7 deletions

View File

@ -0,0 +1,506 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, Play, Save } from "lucide-react";
import type {
AutomationFrequency,
AutomationScheduleDto,
AutomationSettingsDto,
MetadataProviderId,
MetadataSourcesConfigDto
} from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
import {
metadataSourcesPayload,
moveSource,
normalizeMetadataSources,
providerLabels,
scheduleDays,
scheduleSummary
} from "./adminAutomation";
type AdminAutomationTab = "sources" | "automation";
type ApiState<T> = {
initial: T | null;
draft: T | null;
loading: boolean;
saving: boolean;
error?: string;
success?: string;
};
const defaultMetadataConfig: MetadataSourcesConfigDto = {
isbnPriorityEnabled: true,
sources: [
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
]
};
const defaultAutomationSettings: AutomationSettingsDto = {
watchLibraries: false,
autoEnrichNewBooks: false,
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
};
export function AdminAutomationPage() {
const [tab, setTab] = useState<AdminAutomationTab>("sources");
const [metadataState, setMetadataState] = useState<ApiState<MetadataSourcesConfigDto>>({
initial: null,
draft: null,
loading: true,
saving: false
});
const [automationState, setAutomationState] = useState<ApiState<AutomationSettingsDto>>({
initial: null,
draft: null,
loading: true,
saving: false
});
const [apiKeys, setApiKeys] = useState<Partial<Record<MetadataProviderId, string>>>({});
const metadataDirty = useMemo(
() => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)),
[metadataState.initial, metadataState.draft]
);
const automationDirty = useMemo(
() =>
Boolean(automationState.initial && automationState.draft && JSON.stringify(automationState.initial) !== JSON.stringify(automationState.draft)),
[automationState.initial, automationState.draft]
);
async function refreshMetadata() {
setMetadataState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
try {
const next = normalizeMetadataSources(await api.metadataSources());
setMetadataState({ initial: next, draft: next, loading: false, saving: false });
setApiKeys({});
} catch (error) {
const fallback = getApiFallback<MetadataSourcesConfigDto>(error);
const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig);
setMetadataState({
initial: next,
draft: next,
loading: false,
saving: false,
error: fallback ? "Sources chargees en mode degrade." : "Lecture des sources impossible."
});
}
}
async function refreshAutomation() {
setAutomationState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
try {
const next = await api.automationSettings();
setAutomationState({ initial: next, draft: next, loading: false, saving: false });
} catch (error) {
const fallback = getApiFallback<AutomationSettingsDto>(error);
const next = fallback ?? defaultAutomationSettings;
setAutomationState({
initial: next,
draft: next,
loading: false,
saving: false,
error: fallback ? "Automatisation chargee en mode degrade." : "Lecture de l'automatisation impossible."
});
}
}
useEffect(() => {
void refreshMetadata();
void refreshAutomation();
}, []);
async function saveMetadata(event: FormEvent) {
event.preventDefault();
if (!metadataState.draft) return;
setMetadataState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
try {
const payload = metadataSourcesPayload(metadataState.draft);
payload.sources = payload.sources?.map((source) => {
const apiKey = apiKeys[source.provider]?.trim();
return apiKey ? { ...source, apiKey } : source;
});
const next = normalizeMetadataSources(await api.updateMetadataSources(payload));
setMetadataState({ initial: next, draft: next, loading: false, saving: false, success: "Sources enregistrees." });
setApiKeys({});
} catch (error) {
setMetadataState((current) => ({
...current,
saving: false,
error: error instanceof Error ? error.message : "Enregistrement des sources impossible."
}));
}
}
async function saveAutomation(event: FormEvent) {
event.preventDefault();
if (!automationState.draft) return;
setAutomationState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
try {
const next = await api.updateAutomationSettings(automationState.draft);
setAutomationState({ initial: next, draft: next, loading: false, saving: false, success: "Automatisation enregistree." });
} catch (error) {
setAutomationState((current) => ({
...current,
saving: false,
error: error instanceof Error ? error.message : "Enregistrement de l'automatisation impossible."
}));
}
}
async function runNow(kind: "scan" | "enrich") {
setAutomationState((current) => ({ ...current, error: undefined, success: undefined }));
try {
if (kind === "scan") await api.runAutomationScan();
else await api.runAutomationEnrich();
setAutomationState((current) => ({
...current,
success: kind === "scan" ? "Scan planifie demande." : "Enrichissement planifie demande."
}));
} catch (error) {
setAutomationState((current) => ({
...current,
error: error instanceof Error ? error.message : "Demande impossible."
}));
}
}
return (
<div className="page-grid">
<Panel className="span-3">
<div className="section-heading">
<div>
<h1>Automatisation & enrichissement</h1>
<p className="muted-copy">Sources, priorites et taches recurrentes.</p>
</div>
<span>{metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"}</span>
</div>
<div className="admin-tabs" role="tablist" aria-label="Automatisation admin">
<button className={tab === "sources" ? "active" : ""} onClick={() => setTab("sources")} role="tab" aria-selected={tab === "sources"}>
Sources de métadonnées
</button>
<button
className={tab === "automation" ? "active" : ""}
onClick={() => setTab("automation")}
role="tab"
aria-selected={tab === "automation"}
>
Automatisation
</button>
</div>
</Panel>
{tab === "sources" ? (
<MetadataSourcesPanel
state={metadataState}
dirty={metadataDirty}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
onChange={(draft) => setMetadataState((current) => ({ ...current, draft, success: undefined }))}
onSubmit={saveMetadata}
onRefresh={refreshMetadata}
/>
) : (
<AutomationPanel
state={automationState}
dirty={automationDirty}
onChange={(draft) => setAutomationState((current) => ({ ...current, draft, success: undefined }))}
onSubmit={saveAutomation}
onRefresh={refreshAutomation}
onRunNow={runNow}
/>
)}
</div>
);
}
function MetadataSourcesPanel({
state,
dirty,
apiKeys,
setApiKeys,
onChange,
onSubmit,
onRefresh
}: {
state: ApiState<MetadataSourcesConfigDto>;
dirty: boolean;
apiKeys: Partial<Record<MetadataProviderId, string>>;
setApiKeys: (next: Partial<Record<MetadataProviderId, string>>) => void;
onChange: (draft: MetadataSourcesConfigDto) => void;
onSubmit: (event: FormEvent) => void;
onRefresh: () => Promise<void>;
}) {
const draft = state.draft;
if (state.loading && !draft) return <LoadingPanel label="Lecture des sources" />;
if (!draft) return null;
const local = draft.sources.find((source) => source.provider === "local");
const external = draft.sources.filter((source) => source.provider !== "local");
return (
<form className="span-3 automation-grid" onSubmit={onSubmit}>
<Panel className="span-3">
<div className="section-heading compact-heading">
<div>
<h2>Sources de métadonnées</h2>
<p className="muted-copy">La source locale reste active en premier passage.</p>
</div>
<StatusText dirty={dirty} loading={state.loading} />
</div>
<ErrorRibbon message={state.error} />
{state.success && <div className="success-ribbon">{state.success}</div>}
<label className="toggle-row">
<input
type="checkbox"
checked={draft.isbnPriorityEnabled}
onChange={(event) => onChange({ ...draft, isbnPriorityEnabled: event.target.checked })}
/>
<span>
<strong>Priorite ISBN globale</strong>
<small>Les correspondances ISBN passent avant les rapprochements titre/auteur.</small>
</span>
</label>
</Panel>
<Panel className="span-3">
<div className="provider-list">
{local && (
<div className="provider-row provider-local">
<div>
<strong>{providerLabels.local}</strong>
<span>Source locale</span>
</div>
<span className="status-pill active">toujours active</span>
</div>
)}
{external.map((source, index) => (
<div className="provider-row" key={source.provider}>
<label className="toggle-row">
<input
type="checkbox"
checked={source.enabled}
onChange={(event) =>
onChange({
...draft,
sources: draft.sources.map((item) =>
item.provider === source.provider ? { ...item, enabled: event.target.checked } : item
)
})
}
/>
<span>
<strong>{providerLabels[source.provider]}</strong>
<small>{source.enabled ? "active" : "inactive"}</small>
</span>
</label>
<label>
Cle API
<input
value={apiKeys[source.provider] ?? ""}
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
placeholder={source.hasApiKey ? "cle conservee" : "optionnelle"}
/>
</label>
<div className="provider-actions">
<button
className="ghost-button icon-button"
type="button"
title="Monter"
disabled={index === 0}
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, -1) })}
>
<ArrowUp size={16} />
</button>
<button
className="ghost-button icon-button"
type="button"
title="Descendre"
disabled={index === external.length - 1}
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, 1) })}
>
<ArrowDown size={16} />
</button>
</div>
</div>
))}
</div>
</Panel>
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
</form>
);
}
function AutomationPanel({
state,
dirty,
onChange,
onSubmit,
onRefresh,
onRunNow
}: {
state: ApiState<AutomationSettingsDto>;
dirty: boolean;
onChange: (draft: AutomationSettingsDto) => void;
onSubmit: (event: FormEvent) => void;
onRefresh: () => Promise<void>;
onRunNow: (kind: "scan" | "enrich") => Promise<void>;
}) {
const draft = state.draft;
if (state.loading && !draft) return <LoadingPanel label="Lecture de l'automatisation" />;
if (!draft) return null;
return (
<form className="span-3 automation-grid" onSubmit={onSubmit}>
<Panel className="span-3">
<div className="section-heading compact-heading">
<div>
<h2>Automatisation</h2>
<p className="muted-copy">Surveillance des dossiers et traitements planifies.</p>
</div>
<StatusText dirty={dirty} loading={state.loading} />
</div>
<ErrorRibbon message={state.error} />
{state.success && <div className="success-ribbon">{state.success}</div>}
<div className="toggle-stack">
<label className="toggle-row">
<input
type="checkbox"
checked={draft.watchLibraries}
onChange={(event) => onChange({ ...draft, watchLibraries: event.target.checked })}
/>
<span>
<strong>Watch auto</strong>
<small>Les bibliotheques actives declenchent un scan quand un fichier change.</small>
</span>
</label>
<label className="toggle-row">
<input
type="checkbox"
checked={draft.autoEnrichNewBooks}
onChange={(event) => onChange({ ...draft, autoEnrichNewBooks: event.target.checked })}
/>
<span>
<strong>Auto enrich new files</strong>
<small>Les nouveaux livres passent par la chaine d'enrichissement active.</small>
</span>
</label>
</div>
</Panel>
<SchedulePanel
title="Scan planifié"
schedule={draft.scanSchedule}
summary={scheduleSummary(draft.scanSchedule, "Scan")}
runLabel="Lancer un scan"
onRun={() => onRunNow("scan")}
onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })}
/>
<SchedulePanel
title="Enrichissement planifié"
schedule={draft.enrichSchedule}
summary={scheduleSummary(draft.enrichSchedule, "Enrichissement")}
runLabel="Lancer l'enrichissement"
onRun={() => onRunNow("enrich")}
onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })}
/>
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
</form>
);
}
function SchedulePanel({
title,
schedule,
summary,
runLabel,
onRun,
onChange
}: {
title: string;
schedule: AutomationScheduleDto;
summary: string;
runLabel: string;
onRun: () => Promise<void>;
onChange: (schedule: AutomationScheduleDto) => void;
}) {
function patch(next: Partial<AutomationScheduleDto>) {
onChange({ ...schedule, ...next });
}
return (
<Panel>
<div className="section-heading compact-heading">
<div>
<h2>{title}</h2>
<p className="muted-copy">{summary}</p>
</div>
<button className="ghost-button icon-text-button" type="button" onClick={() => void onRun()}>
<Play size={16} />
{runLabel}
</button>
</div>
<div className="schedule-controls">
<label>
Frequence
<select value={schedule.frequency} onChange={(event) => patch({ frequency: event.target.value as AutomationFrequency })}>
<option value="disabled">Desactive</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Heure
<input type="time" value={schedule.time} onChange={(event) => patch({ time: event.target.value })} />
</label>
{schedule.frequency === "weekly" && (
<label>
Jour
<select value={schedule.dayOfWeek} onChange={(event) => patch({ dayOfWeek: Number(event.target.value) })}>
{scheduleDays.map((day) => (
<option value={day.value} key={day.value}>
{day.label}
</option>
))}
</select>
</label>
)}
</div>
</Panel>
);
}
function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise<void> }) {
return (
<Panel className="span-3 save-bar">
<span>{dirty ? "Modifications en attente." : "Aucune modification en attente."}</span>
<div>
<button className="ghost-button" type="button" onClick={() => void onRefresh()} disabled={saving}>
Recharger
</button>
<button className="primary-button" type="submit" disabled={!dirty || saving}>
<Save size={16} />
{saving ? "Enregistrement" : "Enregistrer"}
</button>
</div>
</Panel>
);
}
function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) {
if (loading) return <span>chargement</span>;
return <span>{dirty ? "non enregistre" : "synchronise"}</span>;
}
function LoadingPanel({ label }: { label: string }) {
return (
<Panel className="span-3">
<LoadingState label={label} />
</Panel>
);
}

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { MetadataSourcesConfigDto } from "@readabook/shared";
import { metadataSourcesPayload, moveSource, normalizeMetadataSources, scheduleSummary } from "./adminAutomation";
const config: MetadataSourcesConfigDto = {
isbnPriorityEnabled: true,
sources: [
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: true },
{ provider: "local", enabled: false, priority: 99, hasApiKey: false },
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
]
};
describe("admin automation helpers", () => {
it("keeps the local metadata source active and first", () => {
expect(normalizeMetadataSources(config).sources[0]).toMatchObject({
provider: "local",
enabled: true,
priority: 0
});
});
it("excludes the local source from the update payload", () => {
expect(metadataSourcesPayload(normalizeMetadataSources(config))).toEqual({
isbnPriorityEnabled: true,
sources: [
{ provider: "openlibrary", enabled: true, priority: 1 },
{ provider: "googlebooks", enabled: false, priority: 2 },
{ provider: "bnf", enabled: false, priority: 3 }
]
});
});
it("moves only external providers", () => {
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks"]);
});
it("summarizes weekly schedules", () => {
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
});
});

View File

@ -0,0 +1,65 @@
import type {
AutomationScheduleDto,
MetadataProviderId,
MetadataSourceConfigDto,
MetadataSourcesConfigDto,
UpdateMetadataSourcesConfigDto
} from "@readabook/shared";
export const providerLabels: Record<MetadataProviderId, string> = {
local: "Fichier local",
openlibrary: "OpenLibrary",
googlebooks: "Google Books",
bnf: "BnF"
};
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
export function normalizeMetadataSources(config: MetadataSourcesConfigDto): MetadataSourcesConfigDto {
const sorted = [...config.sources].sort((left, right) => left.priority - right.priority);
const local = sorted.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
const external = sorted.filter((source) => source.provider !== "local");
return {
isbnPriorityEnabled: config.isbnPriorityEnabled,
sources: [
{ ...local, enabled: true, priority: 0 },
...external.map((source, index) => ({ ...source, priority: index + 1 }))
]
};
}
export function metadataSourcesPayload(config: MetadataSourcesConfigDto): UpdateMetadataSourcesConfigDto {
const sources: NonNullable<UpdateMetadataSourcesConfigDto["sources"]> = [];
config.sources.forEach((source) => {
if (source.provider === "local") return;
sources.push({
provider: source.provider,
enabled: source.enabled,
priority: sources.length + 1
});
});
return {
isbnPriorityEnabled: config.isbnPriorityEnabled,
sources
};
}
export function moveSource(sources: MetadataSourceConfigDto[], provider: MetadataProviderId, direction: -1 | 1): MetadataSourceConfigDto[] {
const external = sources.filter((source) => source.provider !== "local");
const index = external.findIndex((source) => source.provider === provider);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= external.length) return sources;
const nextExternal = [...external];
[nextExternal[index], nextExternal[nextIndex]] = [nextExternal[nextIndex], nextExternal[index]];
const local = sources.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
return [local, ...nextExternal].map((source, priority) => ({ ...source, priority: source.provider === "local" ? 0 : priority }));
}
export function scheduleSummary(schedule: AutomationScheduleDto, subject: string): string {
if (schedule.frequency === "disabled") return `${subject} desactive.`;
if (schedule.frequency === "daily") return `${subject} tous les jours a ${schedule.time}.`;
return `${subject} chaque ${weekdays[schedule.dayOfWeek]} a ${schedule.time}.`;
}
export const scheduleDays = weekdays.map((label, value) => ({ label, value }));