feat(indexing): Lot 4 - Progression temps reel, Full Rebuild, Optimisations
- Ajout migrations DB: index_job_errors, library_monitoring, full_rebuild_type - API: endpoints progression temps reel (/jobs/:id/stream), active jobs, details - API: support full_rebuild avec suppression donnees existantes - Indexer: logs detailles avec timing [SCAN][META][PARSER][BDD] - Indexer: optimisation parsing PDF (lopdf -> pdfinfo) 235x plus rapide - Indexer: corrections chemins LIBRARIES_ROOT_PATH pour dev local - Backoffice: composants JobProgress, JobsIndicator (header), JobsList - Backoffice: SSE streaming pour progression temps reel - Backoffice: boutons Index/Index Full sur page libraries - Backoffice: highlight job apres creation avec redirection - Fix: parsing volume type i32, sync meilisearch cleanup Perf: parsing PDF passe de 8.7s a 37ms Perf: indexation 45 fichiers en ~15s vs plusieurs minutes avant
This commit is contained in:
4
apps/backoffice/.env.local
Normal file
4
apps/backoffice/.env.local
Normal file
@@ -0,0 +1,4 @@
|
||||
API_BASE_URL=http://localhost:8080
|
||||
API_BOOTSTRAP_TOKEN=stripstream-dev-bootstrap-token
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_API_BOOTSTRAP_TOKEN=stripstream-dev-bootstrap-token
|
||||
36
apps/backoffice/app/api/jobs/[id]/cancel/route.ts
Normal file
36
apps/backoffice/app/api/jobs/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||
|
||||
if (!apiToken) {
|
||||
return NextResponse.json({ error: "API token not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/cancel/${id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `API error: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Proxy error:", error);
|
||||
return NextResponse.json({ error: "Failed to cancel job" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
apps/backoffice/app/api/jobs/[id]/route.ts
Normal file
35
apps/backoffice/app/api/jobs/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||
|
||||
if (!apiToken) {
|
||||
return NextResponse.json({ error: "API token not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/jobs/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `API error: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Proxy error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch job" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
87
apps/backoffice/app/api/jobs/[id]/stream/route.ts
Normal file
87
apps/backoffice/app/api/jobs/[id]/stream/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||
|
||||
if (!apiToken) {
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({ error: "API token not configured" })}\n\n`,
|
||||
{ status: 500, headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send initial headers for SSE
|
||||
controller.enqueue(new TextEncoder().encode(""));
|
||||
|
||||
let lastData: string | null = null;
|
||||
let isActive = true;
|
||||
|
||||
const fetchJob = async () => {
|
||||
if (!isActive) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/jobs/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const dataStr = JSON.stringify(data);
|
||||
|
||||
// Only send if data changed
|
||||
if (dataStr !== lastData) {
|
||||
lastData = dataStr;
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||
);
|
||||
|
||||
// Stop polling if job is complete
|
||||
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||
isActive = false;
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSE fetch error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
await fetchJob();
|
||||
|
||||
// Poll every 500ms while job is active
|
||||
const interval = setInterval(async () => {
|
||||
if (!isActive) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
await fetchJob();
|
||||
}, 500);
|
||||
|
||||
// Cleanup on abort
|
||||
request.signal.addEventListener("abort", () => {
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
31
apps/backoffice/app/api/jobs/route.ts
Normal file
31
apps/backoffice/app/api/jobs/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||
|
||||
if (!apiToken) {
|
||||
return NextResponse.json({ error: "API token not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/status`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `API error: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Proxy error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch jobs" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
76
apps/backoffice/app/api/jobs/stream/route.ts
Normal file
76
apps/backoffice/app/api/jobs/stream/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||
|
||||
if (!apiToken) {
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({ error: "API token not configured" })}\n\n`,
|
||||
{ status: 500, headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(""));
|
||||
|
||||
let lastData: string | null = null;
|
||||
let isActive = true;
|
||||
|
||||
const fetchJobs = async () => {
|
||||
if (!isActive) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/status`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const dataStr = JSON.stringify(data);
|
||||
|
||||
// Send if data changed
|
||||
if (dataStr !== lastData) {
|
||||
lastData = dataStr;
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSE fetch error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
await fetchJobs();
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
if (!isActive) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
await fetchJobs();
|
||||
}, 2000);
|
||||
|
||||
// Cleanup
|
||||
request.signal.addEventListener("abort", () => {
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
123
apps/backoffice/app/components/JobProgress.tsx
Normal file
123
apps/backoffice/app/components/JobProgress.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ProgressEvent {
|
||||
job_id: string;
|
||||
status: string;
|
||||
current_file: string | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
total_files: number | null;
|
||||
stats_json: {
|
||||
scanned_files: number;
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface JobProgressProps {
|
||||
jobId: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Use SSE via local proxy
|
||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
const progressData: ProgressEvent = {
|
||||
job_id: data.id,
|
||||
status: data.status,
|
||||
current_file: data.current_file,
|
||||
progress_percent: data.progress_percent,
|
||||
processed_files: data.processed_files,
|
||||
total_files: data.total_files,
|
||||
stats_json: data.stats_json,
|
||||
};
|
||||
|
||||
setProgress(progressData);
|
||||
|
||||
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||
setIsComplete(true);
|
||||
eventSource.close();
|
||||
onComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to parse SSE data");
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE error:", err);
|
||||
eventSource.close();
|
||||
setError("Connection lost");
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [jobId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return <div className="progress-error">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return <div className="progress-loading">Loading progress...</div>;
|
||||
}
|
||||
|
||||
const percent = progress.progress_percent ?? 0;
|
||||
const processed = progress.processed_files ?? 0;
|
||||
const total = progress.total_files ?? 0;
|
||||
|
||||
return (
|
||||
<div className="job-progress">
|
||||
<div className="progress-header">
|
||||
<span className={`status-badge status-${progress.status}`}>
|
||||
{progress.status}
|
||||
</span>
|
||||
{isComplete && <span className="complete-badge">Complete</span>}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<span className="progress-percent">{percent}%</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-stats">
|
||||
<span>{processed} / {total} files</span>
|
||||
{progress.current_file && (
|
||||
<span className="current-file" title={progress.current_file}>
|
||||
Current: {progress.current_file.length > 40
|
||||
? progress.current_file.substring(0, 40) + "..."
|
||||
: progress.current_file}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress.stats_json && (
|
||||
<div className="progress-detailed-stats">
|
||||
<span>Scanned: {progress.stats_json.scanned_files}</span>
|
||||
<span>Indexed: {progress.stats_json.indexed_files}</span>
|
||||
<span>Removed: {progress.stats_json.removed_files}</span>
|
||||
{progress.stats_json.errors > 0 && (
|
||||
<span className="error-count">Errors: {progress.stats_json.errors}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/backoffice/app/components/JobRow.tsx
Normal file
75
apps/backoffice/app/components/JobRow.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
id: string;
|
||||
library_id: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
error_opt: string | null;
|
||||
};
|
||||
libraryName: string | undefined;
|
||||
highlighted?: boolean;
|
||||
onCancel: (id: string) => void;
|
||||
}
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) {
|
||||
const [showProgress, setShowProgress] = useState(
|
||||
highlighted || job.status === "running" || job.status === "pending"
|
||||
);
|
||||
|
||||
const handleComplete = () => {
|
||||
setShowProgress(false);
|
||||
// Trigger a page refresh to update the job status
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={highlighted ? "job-highlighted" : undefined}>
|
||||
<td>
|
||||
<code>{job.id.slice(0, 8)}</code>
|
||||
</td>
|
||||
<td>{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}</td>
|
||||
<td>{job.type}</td>
|
||||
<td>
|
||||
<span className={`status-${job.status}`}>{job.status}</span>
|
||||
{job.error_opt && <span className="error-hint" title={job.error_opt}>!</span>}
|
||||
{job.status === "running" && (
|
||||
<button
|
||||
className="toggle-progress-btn"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(job.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
{job.status === "pending" || job.status === "running" ? (
|
||||
<button
|
||||
className="cancel-btn"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||
<tr className="progress-row">
|
||||
<td colSpan={6}>
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
apps/backoffice/app/components/JobsIndicator.tsx
Normal file
144
apps/backoffice/app/components/JobsIndicator.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
status: string;
|
||||
current_file: string | null;
|
||||
progress_percent: number | null;
|
||||
}
|
||||
|
||||
interface JobsIndicatorProps {
|
||||
apiBaseUrl: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export function JobsIndicator({ apiBaseUrl, apiToken }: JobsIndicatorProps) {
|
||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActiveJobs = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/index/jobs/active`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const jobs = await response.json();
|
||||
// Enrich with details for running jobs
|
||||
const jobsWithDetails = await Promise.all(
|
||||
jobs.map(async (job: Job) => {
|
||||
if (job.status === "running") {
|
||||
try {
|
||||
const detailRes = await fetch(`${apiBaseUrl}/index/jobs/${job.id}`, {
|
||||
headers: { "Authorization": `Bearer ${apiToken}` },
|
||||
});
|
||||
if (detailRes.ok) {
|
||||
const detail = await detailRes.json();
|
||||
return { ...job, ...detail };
|
||||
}
|
||||
} catch {
|
||||
// ignore detail fetch errors
|
||||
}
|
||||
}
|
||||
return job;
|
||||
})
|
||||
);
|
||||
setActiveJobs(jobsWithDetails);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveJobs();
|
||||
const interval = setInterval(fetchActiveJobs, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [apiBaseUrl, apiToken]);
|
||||
|
||||
const pendingCount = activeJobs.filter(j => j.status === "pending").length;
|
||||
const runningCount = activeJobs.filter(j => j.status === "running").length;
|
||||
const totalCount = activeJobs.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<Link href="/jobs" className="jobs-indicator empty">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 12h8M12 8v8" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jobs-indicator-container">
|
||||
<button
|
||||
className="jobs-indicator active"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 12h8M12 8v8" />
|
||||
</svg>
|
||||
{totalCount > 0 && (
|
||||
<span className="jobs-badge">
|
||||
{totalCount > 9 ? "9+" : totalCount}
|
||||
</span>
|
||||
)}
|
||||
{runningCount > 0 && (
|
||||
<span className="jobs-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="jobs-dropdown">
|
||||
<div className="jobs-dropdown-header">
|
||||
<strong>Active Jobs</strong>
|
||||
<Link href="/jobs" onClick={() => setIsOpen(false)}>View all</Link>
|
||||
</div>
|
||||
|
||||
{activeJobs.length === 0 ? (
|
||||
<p className="jobs-empty">No active jobs</p>
|
||||
) : (
|
||||
<ul className="jobs-list">
|
||||
{activeJobs.map(job => (
|
||||
<li key={job.id} className={`job-item job-${job.status}`}>
|
||||
<div className="job-header">
|
||||
<span className={`job-status status-${job.status}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<code className="job-id">{job.id.slice(0, 8)}</code>
|
||||
</div>
|
||||
{job.status === "running" && job.progress_percent !== null && (
|
||||
<div className="job-mini-progress">
|
||||
<div
|
||||
className="job-progress-bar"
|
||||
style={{ width: `${job.progress_percent}%` }}
|
||||
/>
|
||||
<span>{job.progress_percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
{job.current_file && (
|
||||
<p className="job-file" title={job.current_file}>
|
||||
{job.current_file.length > 30
|
||||
? job.current_file.substring(0, 30) + "..."
|
||||
: job.current_file}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/backoffice/app/components/JobsIndicatorWrapper.tsx
Normal file
12
apps/backoffice/app/components/JobsIndicatorWrapper.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { JobsIndicator } from "./JobsIndicator";
|
||||
|
||||
interface JobsIndicatorWrapperProps {
|
||||
apiBaseUrl: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export function JobsIndicatorWrapper({ apiBaseUrl, apiToken }: JobsIndicatorWrapperProps) {
|
||||
return <JobsIndicator apiBaseUrl={apiBaseUrl} apiToken={apiToken} />;
|
||||
}
|
||||
91
apps/backoffice/app/components/JobsList.tsx
Normal file
91
apps/backoffice/app/components/JobsList.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
library_id: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
error_opt: string | null;
|
||||
}
|
||||
|
||||
interface JobsListProps {
|
||||
initialJobs: Job[];
|
||||
libraries: Map<string, string>;
|
||||
highlightJobId?: string;
|
||||
}
|
||||
|
||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
// Refresh jobs list via SSE
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/jobs/stream");
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (Array.isArray(data)) {
|
||||
setJobs(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse SSE data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE error:", err);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${id}/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state to reflect cancellation
|
||||
setJobs(jobs.map(job =>
|
||||
job.id === id ? { ...job, status: "cancelled" } : job
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel job:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<table>
|
||||
<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) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -215,6 +215,25 @@ button:hover {
|
||||
border-color: hsl(2 72% 48% / 0.5);
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
background: linear-gradient(95deg, hsl(142 60% 45% / 0.15), hsl(142 60% 55% / 0.2));
|
||||
border-color: hsl(142 60% 45% / 0.5);
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.delete-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);
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.full-rebuild-btn {
|
||||
background: linear-gradient(95deg, hsl(280 60% 45% / 0.15), hsl(280 60% 55% / 0.2));
|
||||
border-color: hsl(280 60% 45% / 0.5);
|
||||
}
|
||||
|
||||
.status-pending { color: hsl(45 93% 47%); }
|
||||
.status-running { color: hsl(192 85% 55%); }
|
||||
.status-completed { color: hsl(142 60% 45%); }
|
||||
@@ -729,3 +748,426 @@ button:hover {
|
||||
max-width: 400px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Job Progress Component */
|
||||
.job-progress {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.status-pending {
|
||||
background: hsl(45 93% 90%);
|
||||
color: hsl(45 93% 35%);
|
||||
}
|
||||
|
||||
.status-badge.status-running {
|
||||
background: hsl(198 52% 90%);
|
||||
color: hsl(198 78% 37%);
|
||||
}
|
||||
|
||||
.status-badge.status-success {
|
||||
background: hsl(142 60% 90%);
|
||||
color: hsl(142 60% 35%);
|
||||
}
|
||||
|
||||
.status-badge.status-failed {
|
||||
background: hsl(2 72% 90%);
|
||||
color: hsl(2 72% 45%);
|
||||
}
|
||||
|
||||
.status-badge.status-cancelled {
|
||||
background: hsl(220 13% 90%);
|
||||
color: hsl(220 13% 40%);
|
||||
}
|
||||
|
||||
.complete-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: hsl(142 60% 90%);
|
||||
color: hsl(142 60% 35%);
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, hsl(198 78% 37%), hsl(192 85% 55%));
|
||||
border-radius: 12px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.current-file {
|
||||
font-size: 0.8rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-detailed-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-count {
|
||||
color: hsl(2 72% 48%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
background: hsl(198 52% 95%);
|
||||
}
|
||||
|
||||
.progress-row td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-progress-btn {
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Jobs Indicator */
|
||||
.jobs-indicator-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.jobs-indicator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.jobs-indicator:hover {
|
||||
background: hsl(198 52% 90% / 0.5);
|
||||
}
|
||||
|
||||
.jobs-indicator.active {
|
||||
border-color: hsl(198 78% 37% / 0.5);
|
||||
}
|
||||
|
||||
.jobs-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: hsl(2 72% 48%);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jobs-pulse {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: hsl(142 60% 45%);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.jobs-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-2);
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.jobs-dropdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.jobs-dropdown-header a {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.jobs-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.job-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.job-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.job-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.job-status {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.job-status.status-pending {
|
||||
background: hsl(45 93% 90%);
|
||||
color: hsl(45 93% 35%);
|
||||
}
|
||||
|
||||
.job-status.status-running {
|
||||
background: hsl(198 52% 90%);
|
||||
color: hsl(198 78% 37%);
|
||||
}
|
||||
|
||||
.job-mini-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.job-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: hsl(198 78% 37%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-mini-progress span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
.job-file {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-loading,
|
||||
.progress-error {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-error {
|
||||
color: hsl(2 72% 48%);
|
||||
}
|
||||
|
||||
/* Dark mode overrides for new components */
|
||||
.dark .status-badge.status-pending {
|
||||
background: hsl(45 93% 25%);
|
||||
color: hsl(45 93% 65%);
|
||||
}
|
||||
|
||||
.dark .status-badge.status-running {
|
||||
background: hsl(198 52% 25%);
|
||||
color: hsl(198 78% 75%);
|
||||
}
|
||||
|
||||
.dark .status-badge.status-success {
|
||||
background: hsl(142 60% 25%);
|
||||
color: hsl(142 60% 65%);
|
||||
}
|
||||
|
||||
.dark .status-badge.status-failed {
|
||||
background: hsl(2 72% 25%);
|
||||
color: hsl(2 72% 65%);
|
||||
}
|
||||
|
||||
.dark .status-badge.status-cancelled {
|
||||
background: hsl(220 13% 25%);
|
||||
color: hsl(220 13% 65%);
|
||||
}
|
||||
|
||||
.dark .complete-badge {
|
||||
background: hsl(142 60% 25%);
|
||||
color: hsl(142 60% 65%);
|
||||
}
|
||||
|
||||
.dark .progress-row {
|
||||
background: hsl(198 52% 15%);
|
||||
}
|
||||
|
||||
.dark .jobs-indicator:hover {
|
||||
background: hsl(210 34% 24% / 0.5);
|
||||
}
|
||||
|
||||
.dark .job-status.status-pending {
|
||||
background: hsl(45 93% 25%);
|
||||
color: hsl(45 93% 65%);
|
||||
}
|
||||
|
||||
.dark .job-status.status-running {
|
||||
background: hsl(198 52% 25%);
|
||||
color: hsl(198 78% 75%);
|
||||
}
|
||||
|
||||
/* Progress bar visibility fix */
|
||||
.job-progress {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: hsl(220 13% 90%);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, hsl(198 78% 37%), hsl(192 85% 55%));
|
||||
border-radius: 12px;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
text-shadow: 0 0 2px rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
background: hsl(198 52% 95%);
|
||||
}
|
||||
|
||||
.progress-row td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Highlighted job row */
|
||||
tr.job-highlighted {
|
||||
background: hsl(198 78% 95%);
|
||||
box-shadow: inset 0 0 0 2px hsl(198 78% 37%);
|
||||
}
|
||||
|
||||
tr.job-highlighted td {
|
||||
animation: pulse-border 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { box-shadow: inset 0 0 0 1px hsl(198 78% 37% / 0.3); }
|
||||
50% { box-shadow: inset 0 0 0 2px hsl(198 78% 37% / 0.6); }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, cancelJob, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function JobsPage() {
|
||||
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
|
||||
const { highlight } = await searchParams;
|
||||
const [jobs, libraries] = await Promise.all([
|
||||
listJobs().catch(() => [] as IndexJobDto[]),
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
@@ -14,17 +17,22 @@ export default async function JobsPage() {
|
||||
async function triggerRebuild(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
await rebuildIndex(libraryId || undefined);
|
||||
const result = await rebuildIndex(libraryId || undefined);
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
async function cancelJobAction(formData: FormData) {
|
||||
async function triggerFullRebuild(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await cancelJob(id);
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
const result = await rebuildIndex(libraryId || undefined, true);
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Index Jobs</h1>
|
||||
@@ -40,43 +48,23 @@ export default async function JobsPage() {
|
||||
</select>
|
||||
<button type="submit">Queue Rebuild</button>
|
||||
</form>
|
||||
<form action={triggerFullRebuild} style={{ marginTop: '12px' }}>
|
||||
<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" className="full-rebuild-btn">Full Rebuild (Reindex All)</button>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<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.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>
|
||||
<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>
|
||||
</table>
|
||||
<JobsList
|
||||
initialJobs={jobs}
|
||||
libraries={libraryMap}
|
||||
highlightJobId={highlight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicatorWrapper } from "./components/JobsIndicatorWrapper";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stripstream Backoffice",
|
||||
@@ -12,6 +13,9 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN || "";
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
@@ -30,6 +34,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<Link href="/jobs">Jobs</Link>
|
||||
<Link href="/tokens">Tokens</Link>
|
||||
</div>
|
||||
<JobsIndicatorWrapper apiBaseUrl={apiBaseUrl} apiToken={apiToken} />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -123,12 +123,23 @@ export async function deleteLibrary(id: string) {
|
||||
return apiFetch<void>(`/libraries/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function scanLibrary(libraryId: string, full?: boolean) {
|
||||
const body: { full?: boolean } = {};
|
||||
if (full) body.full = true;
|
||||
return apiFetch<IndexJobDto>(`/libraries/${libraryId}/scan`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listJobs() {
|
||||
return apiFetch<IndexJobDto[]>("/index/status");
|
||||
}
|
||||
|
||||
export async function rebuildIndex(libraryId?: string) {
|
||||
const body = libraryId ? { library_id: libraryId } : {};
|
||||
export async function rebuildIndex(libraryId?: string, full?: boolean) {
|
||||
const body: { library_id?: string; full?: boolean } = {};
|
||||
if (libraryId) body.library_id = libraryId;
|
||||
if (full) body.full = true;
|
||||
return apiFetch<IndexJobDto>("/index/rebuild", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
|
||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user