backoffice nextJs replaces admin in rust
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
API_LISTEN_ADDR=0.0.0.0:8080
|
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
|
API_BASE_URL=http://api:8080
|
||||||
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
||||||
INDEXER_SCAN_INTERVAL_SECONDS=5
|
INDEXER_SCAN_INTERVAL_SECONDS=5
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ target/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
tmp/
|
tmp/
|
||||||
libraries/
|
libraries/
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
|||||||
11
PLAN.md
11
PLAN.md
@@ -5,7 +5,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
|||||||
- API REST (librairies, livres, metadonnees, recherche, streaming pages),
|
- API REST (librairies, livres, metadonnees, recherche, streaming pages),
|
||||||
- indexation incrementale,
|
- indexation incrementale,
|
||||||
- recherche full-text,
|
- 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.
|
- deploiement Docker Compose homelab.
|
||||||
|
|
||||||
## Decisions figees
|
## Decisions figees
|
||||||
@@ -19,6 +19,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
|||||||
- Rendu PDF: a la volee
|
- Rendu PDF: a la volee
|
||||||
- CBR: extraction temporaire disque (`unrar-free`, commande `unrar`) + cleanup
|
- CBR: extraction temporaire disque (`unrar-free`, commande `unrar`) + cleanup
|
||||||
- Formats pages: `webp`, `jpeg`, `png`
|
- 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.
|
**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 Libraries
|
||||||
- [x] Vue Jobs
|
- [x] Vue Jobs
|
||||||
- [x] Vue API Tokens (create/list/revoke)
|
- [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
|
### T17 - Observabilite et hardening
|
||||||
- [x] Logs structures `tracing`
|
- [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: 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: 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: 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.
|
||||||
|
|||||||
21
apps/backoffice/Dockerfile
Normal file
21
apps/backoffice/Dockerfile
Normal 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"]
|
||||||
279
apps/backoffice/app/globals.css
Normal file
279
apps/backoffice/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/backoffice/app/health/route.ts
Normal file
6
apps/backoffice/app/health/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function GET() {
|
||||||
|
return new Response("ok", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8" }
|
||||||
|
});
|
||||||
|
}
|
||||||
41
apps/backoffice/app/jobs/page.tsx
Normal file
41
apps/backoffice/app/jobs/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/backoffice/app/jobs/rebuild/route.ts
Normal file
17
apps/backoffice/app/jobs/rebuild/route.ts
Normal 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));
|
||||||
|
}
|
||||||
40
apps/backoffice/app/layout.tsx
Normal file
40
apps/backoffice/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/backoffice/app/page.tsx
Normal file
11
apps/backoffice/app/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/backoffice/app/theme-provider.tsx
Normal file
12
apps/backoffice/app/theme-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/backoffice/app/theme-toggle.tsx
Normal file
28
apps/backoffice/app/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/backoffice/app/tokens/create/route.ts
Normal file
27
apps/backoffice/app/tokens/create/route.ts
Normal 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);
|
||||||
|
}
|
||||||
66
apps/backoffice/app/tokens/page.tsx
Normal file
66
apps/backoffice/app/tokens/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/backoffice/app/tokens/revoke/route.ts
Normal file
13
apps/backoffice/app/tokens/revoke/route.ts
Normal 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));
|
||||||
|
}
|
||||||
67
apps/backoffice/lib/api.ts
Normal file
67
apps/backoffice/lib/api.ts
Normal 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
6
apps/backoffice/next-env.d.ts
vendored
Normal 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.
|
||||||
7
apps/backoffice/next.config.mjs
Normal file
7
apps/backoffice/next.config.mjs
Normal 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
1047
apps/backoffice/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
apps/backoffice/package.json
Normal file
22
apps/backoffice/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/backoffice/public/logo.png
Normal file
BIN
apps/backoffice/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
36
apps/backoffice/tsconfig.json
Normal file
36
apps/backoffice/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -89,14 +89,17 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
admin-ui:
|
backoffice:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: apps/admin-ui/Dockerfile
|
dockerfile: apps/backoffice/Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
|
environment:
|
||||||
|
- PORT=${BACKOFFICE_PORT:-8082}
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
ports:
|
ports:
|
||||||
- "8082:8082"
|
- "${BACKOFFICE_PORT:-8082}:8082"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
BASE_API="${BASE_API:-http://127.0.0.1:8080}"
|
BASE_API="${BASE_API:-http://127.0.0.1:8080}"
|
||||||
BASE_INDEXER="${BASE_INDEXER:-http://127.0.0.1:8081}"
|
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}"
|
TOKEN="${API_TOKEN:-stripstream-dev-bootstrap-token}"
|
||||||
|
|
||||||
echo "[smoke] health checks"
|
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_API/ready" >/dev/null
|
||||||
curl -fsS "$BASE_INDEXER/health" >/dev/null
|
curl -fsS "$BASE_INDEXER/health" >/dev/null
|
||||||
curl -fsS "$BASE_INDEXER/ready" >/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"
|
echo "[smoke] list libraries"
|
||||||
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE_API/libraries" >/dev/null
|
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE_API/libraries" >/dev/null
|
||||||
|
|||||||
Reference in New Issue
Block a user