updated
This commit is contained in:
parent
f87e00ebf3
commit
a1e3d5022a
@ -1,4 +1,4 @@
|
||||
// server.js
|
||||
// backend\server.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
@ -72,6 +72,12 @@ let lastResetTimestamp = new Date();
|
||||
|
||||
// === Funktionen ===
|
||||
|
||||
function endOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x;
|
||||
}
|
||||
|
||||
function pushLogout(userId, reason = 'expired') {
|
||||
const set = sseClients.get(userId);
|
||||
if (!set) return;
|
||||
@ -393,6 +399,36 @@ async function findImageFile(filename) {
|
||||
return searchInDir(WATCH_PATH);
|
||||
}
|
||||
|
||||
async function findSidecarXmlFromMediaFilename(filename) {
|
||||
if (!filename) return null;
|
||||
|
||||
// Basisname aus Bild oder Plate ableiten:
|
||||
// - "abc123.jpg" -> "abc123_Info.xml"
|
||||
// - "abc123_plate.jpg" -> "abc123_Info.xml"
|
||||
const parsed = path.parse(filename);
|
||||
const base = parsed.name.replace(/_plate$/i, ''); // _plate abschneiden
|
||||
const xmlFilename = `${base}_Info.xml`;
|
||||
|
||||
function searchInDir(dir) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = searchInDir(full);
|
||||
if (found) return found;
|
||||
} else if (entry.isFile() && entry.name === xmlFilename) {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
} catch { /* still */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
return searchInDir(WATCH_PATH);
|
||||
}
|
||||
|
||||
|
||||
async function processFile(filePath) {
|
||||
try {
|
||||
const xml = fs.readFileSync(filePath, 'utf-8');
|
||||
@ -1545,6 +1581,140 @@ app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Admin: Erkennungen löschen (mit Vorschau / Dry-Run + Progress über SSE)
|
||||
// Body: { camera?: string, from?: string, to?: string, dryRun?: boolean, alsoFiles?: boolean, clientJobId?: string }
|
||||
app.post('/api/admin/recognitions/purge', verifyToken, async (req, res) => {
|
||||
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' });
|
||||
|
||||
try {
|
||||
let { camera, from, to, dryRun = true, alsoFiles = false, clientJobId } = req.body || {};
|
||||
|
||||
const where = {};
|
||||
if (camera && typeof camera === 'string' && camera.trim()) {
|
||||
where.cameraName = camera.trim();
|
||||
}
|
||||
if (from || to) {
|
||||
where.timestampLocal = {};
|
||||
if (from) where.timestampLocal.gte = new Date(from);
|
||||
if (to) where.timestampLocal.lte = endOfDay(to);
|
||||
}
|
||||
|
||||
if (!where.cameraName && !where.timestampLocal) {
|
||||
return res.status(400).json({ error: 'Bitte mindestens Kamera oder Zeitraum angeben.' });
|
||||
}
|
||||
|
||||
// Vorab: Kandidaten einsammeln (IDs & Dateien), damit wir Batches + Files managen können
|
||||
const rows = await prisma.recognition.findMany({
|
||||
where,
|
||||
select: { id: true, imageFile: true, plateFile: true }
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`🧪 [purge:preview] user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} matches=${rows.length}`);
|
||||
return res.json({
|
||||
dryRun: true,
|
||||
matchCount: rows.length,
|
||||
sampleIds: rows.slice(0, 10).map(r => r.id),
|
||||
camera: camera ?? null,
|
||||
from: from ?? null,
|
||||
to: to ?? null
|
||||
});
|
||||
}
|
||||
|
||||
const userId = String(req.user.id);
|
||||
const jobId = clientJobId || crypto.randomUUID();
|
||||
const total = rows.length;
|
||||
|
||||
console.log(`🧨 [purge:start] job=${jobId} user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} alsoFiles=${!!alsoFiles} total=${total}`);
|
||||
|
||||
// Schnell-Fall: nichts zu löschen
|
||||
if (total === 0) {
|
||||
emitToUser(userId, 'purge-progress', { jobId, stage: 'nichts zu löschen', done: 0, total: 0, progress: 100, filesDeleted: 0 });
|
||||
console.log(`✅ [purge:done] job=${jobId} deleted=0 files=0`);
|
||||
return res.json({ dryRun: false, deletedCount: 0, filesDeleted: 0 });
|
||||
}
|
||||
|
||||
let filesDeleted = 0;
|
||||
let done = 0;
|
||||
const BATCH = 500;
|
||||
const t0 = Date.now();
|
||||
|
||||
const ping = (stage, overrideProgress) => {
|
||||
const computed = total > 0 ? Math.round((done / total) * 98) : 1; // bis 98%
|
||||
const progress = Math.max(1, Math.min(99, overrideProgress ?? computed));
|
||||
emitToUser(userId, 'purge-progress', { jobId, stage, done, total, progress, filesDeleted });
|
||||
};
|
||||
|
||||
ping('starte…', 1);
|
||||
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
const slice = rows.slice(i, i + BATCH);
|
||||
const ids = slice.map(r => r.id);
|
||||
|
||||
// DB: diesen Batch löschen
|
||||
const delRes = await prisma.recognition.deleteMany({ where: { id: { in: ids } } });
|
||||
done += delRes.count;
|
||||
|
||||
// Dateien: best-effort (außerhalb der DB-Transaktion)
|
||||
if (alsoFiles) {
|
||||
for (const r of slice) {
|
||||
const deletedPaths = new Set();
|
||||
|
||||
// Bilder (Full + Plate)
|
||||
for (const fname of [r.imageFile, r.plateFile]) {
|
||||
if (!fname) continue;
|
||||
try {
|
||||
const abs = await findImageFile(fname);
|
||||
if (abs && fs.existsSync(abs) && !deletedPaths.has(abs)) {
|
||||
await fs.promises.unlink(abs);
|
||||
filesDeleted += 1;
|
||||
deletedPaths.add(abs);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// XML-Sidecar (abgeleitet vom Bild-/Plate-Namen)
|
||||
for (const candidate of [r.imageFile, r.plateFile]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const xmlAbs = await findSidecarXmlFromMediaFilename(candidate);
|
||||
if (xmlAbs && fs.existsSync(xmlAbs) && !deletedPaths.has(xmlAbs)) {
|
||||
await fs.promises.unlink(xmlAbs);
|
||||
filesDeleted += 1;
|
||||
deletedPaths.add(xmlAbs);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fortschritt + Konsole
|
||||
ping('lösche…');
|
||||
if (i === 0 || i + BATCH >= rows.length || (i / BATCH) % 2 === 0) {
|
||||
const pct = Math.round((done / total) * 100);
|
||||
console.log(`… [purge:progress] job=${jobId} ${done}/${total} (${pct}%) files=${filesDeleted}`);
|
||||
}
|
||||
|
||||
// Event-Loop freigeben, damit SSE sofort rausgeht
|
||||
await new Promise(r => setImmediate(r));
|
||||
}
|
||||
|
||||
ping('fertig', 99);
|
||||
const ms = Date.now() - t0;
|
||||
console.log(`✅ [purge:done] job=${jobId} deleted=${done} files=${filesDeleted} in ${ms}ms`);
|
||||
|
||||
return res.json({
|
||||
dryRun: false,
|
||||
deletedCount: done,
|
||||
filesDeleted
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ purge error:', err);
|
||||
return res.status(500).json({ error: 'Löschen fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// === DELETE ===
|
||||
|
||||
app.delete('/api/notifications/:id', verifyToken, async (req, res) => {
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
// frontend\src\app\(protected)\admin\page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import UserForm from '../../components/UserForm';
|
||||
import UserTable from '../../components/UserTable';
|
||||
import AdminPurgePanel from '../../components/AdminPurgePanel';
|
||||
|
||||
export default function Administration() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminPurgePanel />
|
||||
<UserForm onUserCreated={() => {}} />
|
||||
<UserTable />
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import path from "path";
|
||||
import ChecksumCopy from "@/app/components/ChecksumCopy";
|
||||
import crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import Alert from "@/app/components/Alert";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
// optional: Seiten-Caching (Inhalte werden eh per mtime-Cache beschleunigt)
|
||||
@ -83,14 +84,24 @@ async function withAutoChecksums(fw: Firmware): Promise<Firmware> {
|
||||
|
||||
|
||||
/* ---------- Typen ---------- */
|
||||
type AlertColor = 'dark' | 'secondary' | 'info' | 'success' | 'danger' | 'warning' | 'light';
|
||||
|
||||
type Notice = {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'solid' | 'soft';
|
||||
color?: AlertColor;
|
||||
};
|
||||
|
||||
type Firmware = {
|
||||
version: string;
|
||||
date: string; // ISO-Datum
|
||||
url: string;
|
||||
releaseNotesUrl?: string; // <-- NEU: Link zur PDF
|
||||
releaseNotesUrl?: string;
|
||||
notes?: string; // (legacy, falls alte JSONs noch Text haben)
|
||||
checksumSha256?: string;
|
||||
checksumMD5?: string;
|
||||
notice?: Notice;
|
||||
};
|
||||
|
||||
type Doc = {
|
||||
@ -98,11 +109,13 @@ type Doc = {
|
||||
url: string;
|
||||
lang?: string;
|
||||
kind?: string;
|
||||
notice?: Notice;
|
||||
};
|
||||
|
||||
type DownloadsFile = {
|
||||
firmwares: Firmware[];
|
||||
documents: Doc[];
|
||||
globalNotice?: Notice;
|
||||
};
|
||||
|
||||
/* ---------- Utils ---------- */
|
||||
@ -163,6 +176,15 @@ export default async function DownloadPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{data.globalNotice && (
|
||||
<Alert
|
||||
title={data.globalNotice.title}
|
||||
message={data.globalNotice.message}
|
||||
type={data.globalNotice.type ?? 'soft'}
|
||||
color={data.globalNotice.color ?? 'info'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 2-Spalten-Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
{/* Firmware (links) */}
|
||||
@ -233,10 +255,18 @@ export default async function DownloadPage() {
|
||||
|
||||
{/* ⬇️ SHA/MD5: klickbar & kopierbar */}
|
||||
{(latest.checksumSha256 || latest.checksumMD5) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={latest.checksumSha256} className="w-full" />
|
||||
<ChecksumCopy label="MD5" value={latest.checksumMD5} className="w-full" />
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={latest.checksumSha256} className="w-full" />
|
||||
<ChecksumCopy label="MD5" value={latest.checksumMD5} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
{latest.notice && (
|
||||
<Alert
|
||||
title={latest.notice.title}
|
||||
message={latest.notice.message}
|
||||
type={latest.notice.type ?? 'soft'}
|
||||
color={latest.notice.color ?? 'info'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@ -249,53 +279,80 @@ export default async function DownloadPage() {
|
||||
<summary className="cursor-pointer select-none text-sm text-gray-700 dark:text-neutral-300">
|
||||
Ältere Versionen anzeigen
|
||||
</summary>
|
||||
<ul className="mt-3 divide-y divide-gray-200 dark:divide-neutral-800">
|
||||
{older.map((fw) => (
|
||||
<li key={fw.version} className="py-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-neutral-100">Version {fw.version}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400">{fmtDateDE(fw.date)}</div>
|
||||
|
||||
{(fw.releaseNotesUrl || fw.notes) && (
|
||||
<div className="text-sm mt-1">
|
||||
<Link
|
||||
href={fw.releaseNotesUrl ?? "#"}
|
||||
prefetch={false}
|
||||
target={fw.releaseNotesUrl ? "_blank" : undefined}
|
||||
rel={fw.releaseNotesUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1 text-emerald-700 hover:underline dark:text-emerald-400"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
Release Notes (PDF)
|
||||
</Link>
|
||||
{/* statt <ul/divide-y> jetzt Karten wie bei "latest" */}
|
||||
<div className="mt-3 space-y-3">
|
||||
{older.map((fw) => (
|
||||
<article
|
||||
key={fw.version}
|
||||
className="rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4"
|
||||
>
|
||||
{/* Kopfzeile: Version/Datum links, Download rechts – wie bei latest */}
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-base font-medium text-gray-900 dark:text-neutral-100">
|
||||
Version {fw.version}
|
||||
</div>
|
||||
)}
|
||||
{(fw.checksumSha256 || fw.checksumMD5) && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={fw.checksumSha256} />
|
||||
<ChecksumCopy label="MD5" value={fw.checksumMD5} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
{fmtDateDE(fw.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:mt-0">
|
||||
<Link
|
||||
href={fw.url}
|
||||
prefetch={false}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-neutral-700 px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-neutral-800"
|
||||
download
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M8.25 11.25 12 15l3.75-3.75" />
|
||||
<path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Link
|
||||
href={fw.url}
|
||||
prefetch={false}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-neutral-700 px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-neutral-800"
|
||||
download
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12" /><path d="M8.25 11.25 12 15l3.75-3.75" /><path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
|
||||
</svg>
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* Release Notes Link – wie bei latest */}
|
||||
{(fw.releaseNotesUrl || fw.notes) && (
|
||||
<p className="mt-3 text-sm">
|
||||
<Link
|
||||
href={fw.releaseNotesUrl ?? "#"}
|
||||
prefetch={false}
|
||||
target={fw.releaseNotesUrl ? "_blank" : undefined}
|
||||
rel={fw.releaseNotesUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1 text-emerald-700 hover:underline dark:text-emerald-400"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
Release Notes (PDF)
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* SHA/MD5 – exakt wie bei latest (Spacing + w-full) */}
|
||||
{(fw.checksumSha256 || fw.checksumMD5) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ChecksumCopy label="SHA-256" value={fw.checksumSha256} className="w-full" />
|
||||
<ChecksumCopy label="MD5" value={fw.checksumMD5} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optionaler Hinweis */}
|
||||
{fw.notice && (
|
||||
<Alert
|
||||
title={fw.notice.title}
|
||||
message={fw.notice.message}
|
||||
type={fw.notice.type ?? 'soft'}
|
||||
color={fw.notice.color ?? 'info'}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
@ -322,6 +379,14 @@ export default async function DownloadPage() {
|
||||
Download
|
||||
</Link>
|
||||
</div>
|
||||
{doc.notice && (
|
||||
<Alert
|
||||
title={doc.notice.title}
|
||||
message={doc.notice.message}
|
||||
type={doc.notice.type ?? 'soft'}
|
||||
color={doc.notice.color ?? 'info'}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,7 @@ import Modal from '@/app/components/Modal';
|
||||
import { useSSE } from '@/app/components/SSEContext';
|
||||
import type { Recognition } from '@/types/plates';
|
||||
|
||||
|
||||
const PANEL_ANIM_MS = 0;
|
||||
|
||||
export default function ResultsPage() {
|
||||
|
||||
298
frontend/src/app/components/AdminPurgePanel.tsx
Normal file
298
frontend/src/app/components/AdminPurgePanel.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
// frontend\src\app\components\AdminPurgePanel.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Alert from '../components/Alert';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
type PreviewResult = { dryRun: true; matchCount: number; sampleIds: number[]; camera: string|null; from: string|null; to: string|null };
|
||||
type PurgeResult = { dryRun: false; deletedCount: number; filesDeleted: number };
|
||||
type PurgeProgressEvt = { jobId: string; stage: string; done: number; total: number; progress: number; filesDeleted?: number };
|
||||
|
||||
export default function AdminPurgePanel() {
|
||||
const [cameras, setCameras] = useState<string[]>([]);
|
||||
const [camera, setCamera] = useState<string>('');
|
||||
const [from, setFrom] = useState<string>('');
|
||||
const [to, setTo] = useState<string>('');
|
||||
const [alsoFiles, setAlsoFiles] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [result, setResult] = useState<PurgeResult | null>(null);
|
||||
const [confirm, setConfirm] = useState<string>('');
|
||||
|
||||
// Progress (SSE)
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<string>('');
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [done, setDone] = useState<number>(0);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [filesDeleted, setFilesDeleted] = useState<number>(0);
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Kameraliste laden
|
||||
useEffect(() => {
|
||||
fetch('/api/cameras', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => setCameras(d.cameras ?? []))
|
||||
.catch(() => setCameras([]));
|
||||
}, []);
|
||||
|
||||
const canDelete = useMemo(() => {
|
||||
if (!preview) return false;
|
||||
if (preview.matchCount <= 0) return false;
|
||||
return confirm.trim().toUpperCase() === 'LÖSCHEN';
|
||||
}, [preview, confirm]);
|
||||
|
||||
const attachSSE = (id: string) => {
|
||||
// alte Verbindung schließen
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close(); } catch {}
|
||||
sseRef.current = null;
|
||||
}
|
||||
// neue Verbindung
|
||||
const es = new EventSource('/api/recognitions/stream', { withCredentials: true } as EventSourceInit);
|
||||
es.addEventListener('purge-progress', (evt: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as PurgeProgressEvt;
|
||||
if (data.jobId !== id) return;
|
||||
setStage(data.stage || '');
|
||||
setProgress(Math.max(0, Math.min(100, data.progress || 0)));
|
||||
setDone(data.done ?? 0);
|
||||
setTotal(data.total ?? 0);
|
||||
setFilesDeleted(data.filesDeleted ?? 0);
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
// optional: handle errors
|
||||
es.onerror = () => { /* still keep it open; the server pings */ };
|
||||
sseRef.current = es;
|
||||
};
|
||||
|
||||
const detachSSE = () => {
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close(); } catch {}
|
||||
sseRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => detachSSE(); // cleanup on unmount
|
||||
}, []);
|
||||
|
||||
const doPreview = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const body = {
|
||||
camera: camera || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
dryRun: true
|
||||
};
|
||||
const r = await fetch('/api/admin/recognitions/purge', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const json: PreviewResult | { error: string } = await r.json();
|
||||
if ('error' in json) throw new Error(json.error);
|
||||
setPreview(json as PreviewResult);
|
||||
} catch (e) {
|
||||
setPreview(null);
|
||||
alert('Vorschau fehlgeschlagen: ' + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!preview) return;
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
// Progress-UI resetten
|
||||
const id = crypto.randomUUID();
|
||||
setJobId(id);
|
||||
setStage('starte…');
|
||||
setProgress(1);
|
||||
setDone(0);
|
||||
setTotal(preview.matchCount);
|
||||
setFilesDeleted(0);
|
||||
attachSSE(id);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
camera: camera || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
dryRun: false,
|
||||
alsoFiles,
|
||||
clientJobId: id
|
||||
};
|
||||
const r = await fetch('/api/admin/recognitions/purge', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const json: PurgeResult | { error: string } = await r.json();
|
||||
if ('error' in json) throw new Error(json.error);
|
||||
setResult(json as PurgeResult);
|
||||
setPreview(null);
|
||||
setConfirm('');
|
||||
// finaler Balken
|
||||
setStage('fertig');
|
||||
setProgress(100);
|
||||
} catch (e) {
|
||||
alert('Löschen fehlgeschlagen: ' + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// kleine Gnadenfrist, damit der letzte SSE-Stand sichtbar bleibt
|
||||
setTimeout(() => {
|
||||
detachSSE();
|
||||
setJobId(null);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-red-200 dark:border-red-900/40 bg-white dark:bg-neutral-900 p-4">
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400">Gefahrenbereich: Daten löschen</h2>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
Als Admin kannst du Erkennungen nach Kamera und Zeitraum dauerhaft löschen.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Kamera */}
|
||||
<label className="block">
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">Kamera (optional)</span>
|
||||
<select
|
||||
value={camera}
|
||||
onChange={(e) => setCamera(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
|
||||
>
|
||||
<option value="">– Alle Kameras –</option>
|
||||
{cameras.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Zeitraum */}
|
||||
<label className="block">
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">Von (inkl.)</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">Bis (inkl.)</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Dateien */}
|
||||
<label className="mt-2 inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alsoFiles}
|
||||
onChange={(e) => setAlsoFiles(e.currentTarget.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-neutral-700"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Zugehörige Dateien (Bilder & XML) ebenfalls löschen (best-effort)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button size="small" variant="ghost" onClick={doPreview} disabled={loading}>
|
||||
Vorschau
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<Alert
|
||||
type="soft"
|
||||
color={preview.matchCount > 0 ? 'warning' : 'secondary'}
|
||||
title="Vorschau"
|
||||
message={
|
||||
preview.matchCount > 0
|
||||
? `${preview.matchCount.toLocaleString('de-DE')} Einträge würden gelöscht. ` +
|
||||
(preview.camera ? `Kamera: ${preview.camera}. ` : 'Alle Kameras. ') +
|
||||
(preview.from ? `Von: ${preview.from}. ` : '') +
|
||||
(preview.to ? `Bis: ${preview.to}.` : '')
|
||||
: 'Keine passenden Einträge gefunden.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm + Delete */}
|
||||
{preview && preview.matchCount > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Alert
|
||||
type="soft"
|
||||
color="danger"
|
||||
title="Achtung"
|
||||
message="Diese Aktion ist endgültig und kann nicht rückgängig gemacht werden."
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Tippe <b>LÖSCHEN</b> zur Bestätigung
|
||||
</span>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-2 text-sm"
|
||||
placeholder="LÖSCHEN"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<Button size="small" variant="ghost" disabled={!canDelete || loading} onClick={doDelete}>
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fortschritt */}
|
||||
{jobId && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-neutral-400 mb-1">
|
||||
<span>{stage || '…'}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded bg-gray-200 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className="h-2 bg-blue-600 dark:bg-blue-500 transition-all"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-neutral-400">
|
||||
{done.toLocaleString('de-DE')} / {total.toLocaleString('de-DE')} Einträge
|
||||
{filesDeleted ? ` • ${filesDeleted} Datei(en)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ergebnis */}
|
||||
{result && (
|
||||
<Alert
|
||||
type="soft"
|
||||
color="success"
|
||||
title="Erledigt"
|
||||
message={`${result.deletedCount.toLocaleString('de-DE')} Einträge gelöscht.` + (result.filesDeleted ? ` ${result.filesDeleted} Datei(en) entfernt.` : '')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\app\components\Alert.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
|
||||
62
frontend/src/app/components/DownloadLinkWithAlert.tsx
Normal file
62
frontend/src/app/components/DownloadLinkWithAlert.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Alert from './Alert';
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
children: React.ReactNode; // Inhalt (Icon + Text)
|
||||
className?: string; // Button-Klassen vom Aufrufer
|
||||
download?: string | boolean; // durchreichen an <Link>
|
||||
prefetch?: boolean;
|
||||
alertTitle?: string;
|
||||
alertMessage?: string;
|
||||
alertType?: 'solid' | 'soft';
|
||||
alertColor?: 'dark' | 'secondary' | 'info' | 'success' | 'danger' | 'warning' | 'light';
|
||||
hideAfterMs?: number; // 0 = stehen lassen
|
||||
};
|
||||
|
||||
export default function DownloadLinkWithAlert({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
download = true,
|
||||
prefetch = false,
|
||||
alertTitle = 'Download gestartet',
|
||||
alertMessage = 'Dein Download sollte gleich beginnen. Falls nicht, Rechtsklick → „Link speichern unter…“.',
|
||||
alertType = 'soft',
|
||||
alertColor = 'info',
|
||||
hideAfterMs = 6000,
|
||||
}: Props) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show || hideAfterMs <= 0) return;
|
||||
const t = setTimeout(() => setShow(false), hideAfterMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [show, hideAfterMs]);
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-2">
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={prefetch}
|
||||
className={className}
|
||||
download={download}
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
{show && (
|
||||
<Alert
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
type={alertType}
|
||||
color={alertColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user