backoffice nextJs replaces admin in rust

This commit is contained in:
2026-03-05 15:47:18 +01:00
parent 20f9af6cba
commit 3a96f6ba36
24 changed files with 1765 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
API_LISTEN_ADDR=0.0.0.0:8080
ADMIN_UI_LISTEN_ADDR=0.0.0.0:8082
BACKOFFICE_PORT=8082
API_BASE_URL=http://api:8080
INDEXER_LISTEN_ADDR=0.0.0.0:8081
INDEXER_SCAN_INTERVAL_SECONDS=5

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ target/
.DS_Store
tmp/
libraries/
node_modules/
.next/

11
PLAN.md
View File

@@ -5,7 +5,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
- API REST (librairies, livres, metadonnees, recherche, streaming pages),
- indexation incrementale,
- recherche full-text,
- admin UI (Rust SSR) pour gerer librairies/jobs/tokens,
- backoffice web (Next.js) pour gerer librairies/jobs/tokens,
- deploiement Docker Compose homelab.
## Decisions figees
@@ -19,6 +19,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
- Rendu PDF: a la volee
- CBR: extraction temporaire disque (`unrar-free`, commande `unrar`) + cleanup
- Formats pages: `webp`, `jpeg`, `png`
- Backoffice UI: Next.js (remplace Rust SSR)
---
@@ -134,12 +135,14 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
**DoD:** Service stable sous charge homelab.
### T16 - Admin UI Rust SSR
### T16 - Backoffice Next.js
- [x] Bootstrap app Next.js (`apps/backoffice`)
- [x] Vue Libraries
- [x] Vue Jobs
- [x] Vue API Tokens (create/list/revoke)
- [x] Auth backoffice via token API
**DoD:** Admin complet utilisable sans SPA lourde.
**DoD:** Backoffice Next.js utilisable pour l'administration complete.
### T17 - Observabilite et hardening
- [x] Logs structures `tracing`
@@ -206,3 +209,5 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
- 2026-03-05: Lot 3 avancee: endpoint pages (`/books/:id/pages/:n`) actif avec cache LRU, ETag/Cache-Control, limite concurrence rendu et timeouts.
- 2026-03-05: hardening API: readiness expose sans auth via `route_layer`, metriques simples `/metrics`, rate limiting lecture (120 req/s).
- 2026-03-05: smoke + bench scripts corriges et verifies (`infra/smoke.sh`, `infra/bench.sh`).
- 2026-03-05: pivot backoffice valide: remplacement de l'admin UI Rust SSR par une app Next.js.
- 2026-03-05: backoffice Next.js implemente (`apps/backoffice`) avec branding neon base sur le logo, actions libraries/jobs/tokens, et integration Docker Compose.

View File

@@ -0,0 +1,21 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY apps/backoffice/package.json apps/backoffice/package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY apps/backoffice/ ./
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8082
RUN apk add --no-cache wget
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 8082
CMD ["node", "server.js"]

View File

@@ -0,0 +1,279 @@
:root {
color-scheme: light;
--background: hsl(36 33% 97%);
--foreground: hsl(222 33% 15%);
--card: hsl(0 0% 100%);
--line: hsl(32 18% 84%);
--line-strong: hsl(32 18% 76%);
--primary: hsl(198 78% 37%);
--primary-soft: hsl(198 52% 90%);
--cyan: hsl(192 85% 55%);
--pink: hsl(338 82% 62%);
--text-muted: hsl(220 13% 40%);
--shadow-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
--shadow-2: 0 12px 30px -12px rgb(23 32 46 / 0.22);
}
.dark {
color-scheme: dark;
--background: hsl(222 35% 10%);
--foreground: hsl(38 20% 92%);
--card: hsl(221 31% 13%);
--line: hsl(219 18% 25%);
--line-strong: hsl(219 18% 33%);
--primary: hsl(194 76% 62%);
--primary-soft: hsl(210 34% 24%);
--cyan: hsl(192 85% 55%);
--pink: hsl(338 82% 62%);
--text-muted: hsl(218 17% 72%);
--shadow-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
--shadow-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--foreground);
font-family: "Avenir Next", "Segoe UI", "Noto Sans", sans-serif;
background: var(--background);
min-height: 100vh;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(112deg, hsl(198 78% 37% / 0.14) 0%, hsl(192 85% 55% / 0.11) 28%, transparent 56%),
linear-gradient(248deg, hsl(338 82% 62% / 0.11) 0%, transparent 46%),
repeating-linear-gradient(135deg, hsl(222 33% 15% / 0.035) 0 1px, transparent 1px 11px);
opacity: 1;
}
main {
position: relative;
max-width: 1050px;
margin: 0 auto;
padding: 28px 22px 40px;
}
h1,
h2,
h3 {
font-family: "Baskerville", "Times New Roman", serif;
letter-spacing: 0.01em;
}
.top-nav {
position: sticky;
top: 0;
z-index: 3;
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
padding: 14px 22px;
border-bottom: 1px solid hsl(198 78% 37% / 0.25);
background: hsl(36 33% 97% / 0.72);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-1);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--foreground);
}
.brand img {
border-radius: 8px;
box-shadow: 0 8px 24px -10px rgb(23 32 46 / 0.35);
}
.brand-name {
background: linear-gradient(90deg, var(--primary), var(--cyan), var(--pink));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 1.02rem;
font-weight: 800;
letter-spacing: 0.06em;
}
.brand-subtitle {
margin-left: -4px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.68rem;
color: var(--text-muted);
font-weight: 700;
}
.links {
display: flex;
gap: 14px;
}
.links-wrap {
display: flex;
align-items: center;
gap: 10px;
}
a {
color: var(--primary);
text-decoration: none;
font-weight: 700;
}
.links a {
padding: 7px 10px;
border-radius: 8px;
}
.links a:hover {
background: hsl(198 52% 90% / 0.65);
color: hsl(222 33% 15%);
}
table {
width: 100%;
border-collapse: collapse;
background: hsl(0 0% 100% / 0.95);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-2);
}
th,
td {
padding: 10px;
border-bottom: 1px solid hsl(32 18% 86%);
text-align: left;
}
th {
color: hsl(222 33% 18%);
background: hsl(36 30% 95%);
}
code,
pre {
font-family: "JetBrains Mono", "SF Mono", monospace;
}
pre {
margin: 10px 0 0;
white-space: pre-wrap;
word-break: break-all;
background: hsl(36 24% 93%);
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
form.inline {
display: inline;
}
input,
select,
button {
border-radius: 8px;
padding: 8px 10px;
margin: 4px 8px 4px 0;
border: 1px solid var(--line-strong);
background: white;
color: var(--foreground);
}
button {
border: 1px solid hsl(198 78% 37% / 0.45);
background: linear-gradient(95deg, hsl(198 78% 37% / 0.12), hsl(192 85% 55% / 0.15));
color: hsl(222 33% 16%);
cursor: pointer;
font-weight: 700;
}
button:hover {
box-shadow: 0 6px 20px -10px hsl(198 78% 37% / 0.5);
}
.theme-toggle {
min-width: 66px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
margin: 16px 0;
box-shadow: var(--shadow-2);
}
.dark .top-nav {
background: hsl(222 35% 10% / 0.72);
border-bottom-color: hsl(194 76% 62% / 0.3);
}
.dark .links a:hover {
background: hsl(210 34% 24% / 0.8);
color: hsl(38 20% 92%);
}
.dark table {
background: hsl(221 31% 13% / 0.95);
}
.dark th {
color: hsl(38 20% 92%);
background: hsl(221 24% 17%);
}
.dark th,
.dark td {
border-bottom-color: hsl(219 18% 22%);
}
.dark input,
.dark select,
.dark button {
background: hsl(221 31% 13%);
color: hsl(38 20% 92%);
}
.dark pre {
background: hsl(221 24% 17%);
}
@media (max-width: 720px) {
.top-nav {
flex-direction: column;
align-items: flex-start;
}
.links-wrap {
width: 100%;
justify-content: space-between;
}
.links {
flex-wrap: wrap;
}
.brand span {
font-size: 14px;
}
main {
padding: 18px 14px 30px;
}
}

View File

@@ -0,0 +1,6 @@
export async function GET() {
return new Response("ok", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" }
});
}

View File

@@ -0,0 +1,41 @@
import { listJobs } from "../../lib/api";
export const dynamic = "force-dynamic";
export default async function JobsPage() {
const jobs = await listJobs().catch(() => []);
return (
<>
<h1>Index Jobs</h1>
<div className="card">
<form action="/jobs/rebuild" method="post">
<input name="library_id" placeholder="optional library UUID" />
<button type="submit">Queue Rebuild</button>
</form>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<code>{job.id}</code>
</td>
<td>{job.type}</td>
<td>{job.status}</td>
<td>{job.created_at}</td>
</tr>
))}
</tbody>
</table>
</>
);
}

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";
import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle";
export const metadata: Metadata = {
title: "Stripstream Backoffice",
description: "Backoffice administration for Stripstream Librarian"
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>
<nav className="top-nav">
<Link href="/" className="brand">
<Image src="/logo.png" alt="Stripstream" width={36} height={36} />
<span className="brand-name">StripStream</span>
<span className="brand-subtitle">backoffice</span>
</Link>
<div className="links-wrap">
<div className="links">
<Link href="/">Dashboard</Link>
<Link href="/libraries">Libraries</Link>
<Link href="/jobs">Jobs</Link>
<Link href="/tokens">Tokens</Link>
</div>
<ThemeToggle />
</div>
</nav>
<main>{children}</main>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,11 @@
export default function DashboardPage() {
return (
<>
<h1>Stripstream Backoffice</h1>
<p>Manage libraries, indexing jobs, and API tokens from a Next.js admin interface.</p>
<div className="card">
<p>Use the navigation links above to access each admin section.</p>
</div>
</>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const activeTheme = theme === "system" ? resolvedTheme : theme;
const nextTheme = activeTheme === "dark" ? "light" : "dark";
return (
<button
type="button"
className="theme-toggle"
onClick={() => setTheme(nextTheme)}
aria-label="Toggle color theme"
disabled={!mounted}
>
{mounted ? (activeTheme === "dark" ? "Dark" : "Light") : "Theme"}
</button>
);
}

View File

@@ -0,0 +1,27 @@
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

@@ -0,0 +1,66 @@
import { listTokens } from "../../lib/api";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const params = await searchParams;
const tokens = await listTokens().catch(() => []);
return (
<>
<h1>API Tokens</h1>
{params.created ? (
<div className="card">
<strong>Token created:</strong>
<pre>{params.created}</pre>
</div>
) : null}
<div className="card">
<form action="/tokens/create" method="post">
<input name="name" placeholder="token name" required />
<select name="scope" defaultValue="read">
<option value="read">read</option>
<option value="admin">admin</option>
</select>
<button type="submit">Create Token</button>
</form>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Prefix</th>
<th>Revoked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope}</td>
<td>
<code>{token.prefix}</code>
</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>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,67 @@
export type LibraryDto = {
id: string;
name: string;
root_path: string;
enabled: boolean;
};
export type IndexJobDto = {
id: string;
type: string;
status: string;
created_at: string;
};
export type TokenDto = {
id: string;
name: string;
scope: string;
prefix: string;
revoked_at: string | null;
};
function config() {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
if (!token) {
throw new Error("API_BOOTSTRAP_TOKEN is required for backoffice");
}
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const { baseUrl, token } = config();
const headers = new Headers(init?.headers || {});
headers.set("Authorization", `Bearer ${token}`);
if (init?.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers,
cache: "no-store"
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${path} failed (${res.status}): ${text}`);
}
if (res.status === 204) {
return null as T;
}
return (await res.json()) as T;
}
export async function listLibraries() {
return apiFetch<LibraryDto[]>("/libraries");
}
export async function listJobs() {
return apiFetch<IndexJobDto[]>("/index/status");
}
export async function listTokens() {
return apiFetch<TokenDto[]>("/admin/tokens");
}

6
apps/backoffice/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
typedRoutes: true
};
export default nextConfig;

1047
apps/backoffice/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "stripstream-backoffice",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 8082",
"build": "next build",
"start": "next start -p 8082"
},
"dependencies": {
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",
"typescript": "5.8.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -89,14 +89,17 @@ services:
timeout: 5s
retries: 5
admin-ui:
backoffice:
build:
context: ..
dockerfile: apps/admin-ui/Dockerfile
dockerfile: apps/backoffice/Dockerfile
env_file:
- ../.env
environment:
- PORT=${BACKOFFICE_PORT:-8082}
- HOSTNAME=0.0.0.0
ports:
- "8082:8082"
- "${BACKOFFICE_PORT:-8082}:8082"
depends_on:
api:
condition: service_healthy

View File

@@ -3,7 +3,7 @@ set -euo pipefail
BASE_API="${BASE_API:-http://127.0.0.1:8080}"
BASE_INDEXER="${BASE_INDEXER:-http://127.0.0.1:8081}"
BASE_ADMIN="${BASE_ADMIN:-http://127.0.0.1:8082}"
BASE_BACKOFFICE="${BASE_BACKOFFICE:-${BASE_ADMIN:-http://127.0.0.1:8082}}"
TOKEN="${API_TOKEN:-stripstream-dev-bootstrap-token}"
echo "[smoke] health checks"
@@ -11,7 +11,7 @@ curl -fsS "$BASE_API/health" >/dev/null
curl -fsS "$BASE_API/ready" >/dev/null
curl -fsS "$BASE_INDEXER/health" >/dev/null
curl -fsS "$BASE_INDEXER/ready" >/dev/null
curl -fsS "$BASE_ADMIN/health" >/dev/null
curl -fsS "$BASE_BACKOFFICE/health" >/dev/null
echo "[smoke] list libraries"
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE_API/libraries" >/dev/null