From a1e3d5022a844d0ac10088c2b0b4356f63254833 Mon Sep 17 00:00:00 2001
From: Linrador <68631622+Linrador@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:47:40 +0100
Subject: [PATCH] updated
---
backend/server.js | 172 +++++++++-
frontend/src/app/(protected)/admin/page.tsx | 4 +
.../src/app/(protected)/downloads/page.tsx | 161 +++++++---
frontend/src/app/(protected)/results/page.tsx | 1 +
.../src/app/components/AdminPurgePanel.tsx | 298 ++++++++++++++++++
frontend/src/app/components/Alert.tsx | 2 +
.../app/components/DownloadLinkWithAlert.tsx | 62 ++++
7 files changed, 651 insertions(+), 49 deletions(-)
create mode 100644 frontend/src/app/components/AdminPurgePanel.tsx
create mode 100644 frontend/src/app/components/DownloadLinkWithAlert.tsx
diff --git a/backend/server.js b/backend/server.js
index 378c603..10729e6 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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) => {
diff --git a/frontend/src/app/(protected)/admin/page.tsx b/frontend/src/app/(protected)/admin/page.tsx
index c47a66d..75e92cd 100644
--- a/frontend/src/app/(protected)/admin/page.tsx
+++ b/frontend/src/app/(protected)/admin/page.tsx
@@ -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 (
diff --git a/frontend/src/app/(protected)/downloads/page.tsx b/frontend/src/app/(protected)/downloads/page.tsx
index 5a6a3d5..8e003d1 100644
--- a/frontend/src/app/(protected)/downloads/page.tsx
+++ b/frontend/src/app/(protected)/downloads/page.tsx
@@ -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 {
/* ---------- 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() {
+ {data.globalNotice && (
+
+ )}
+
{/* 2-Spalten-Layout */}
{/* Firmware (links) */}
@@ -233,10 +255,18 @@ export default async function DownloadPage() {
{/* ⬇️ SHA/MD5: klickbar & kopierbar */}
{(latest.checksumSha256 || latest.checksumMD5) && (
-
-
-
-
+
+
+
+
+ )}
+ {latest.notice && (
+
)}
) : (
@@ -249,53 +279,80 @@ export default async function DownloadPage() {
Ältere Versionen anzeigen
-
- {older.map((fw) => (
- -
-
-
Version {fw.version}
-
{fmtDateDE(fw.date)}
- {(fw.releaseNotesUrl || fw.notes) && (
-
-
-
- Release Notes (PDF)
-
+ {/* statt
jetzt Karten wie bei "latest" */}
+
+ {older.map((fw) => (
+
+ {/* Kopfzeile: Version/Datum links, Download rechts – wie bei latest */}
+
+
+
+ Version {fw.version}
- )}
- {(fw.checksumSha256 || fw.checksumMD5) && (
-
-
-
-
- )}
+
+ {fmtDateDE(fw.date)}
+
+
+
+
+
+ Download
+
+
-
-
+
+ {/* Release Notes Link – wie bei latest */}
+ {(fw.releaseNotesUrl || fw.notes) && (
+
+
+
+ Release Notes (PDF)
+
+
+ )}
+
+ {/* SHA/MD5 – exakt wie bei latest (Spacing + w-full) */}
+ {(fw.checksumSha256 || fw.checksumMD5) && (
+
+
+
+
+ )}
+
+ {/* Optionaler Hinweis */}
+ {fw.notice && (
+
+ )}
+
))}
-
+
)}
@@ -322,6 +379,14 @@ export default async function DownloadPage() {
Download
+ {doc.notice && (
+
+ )}
))}
diff --git a/frontend/src/app/(protected)/results/page.tsx b/frontend/src/app/(protected)/results/page.tsx
index cc14d06..602dc4f 100644
--- a/frontend/src/app/(protected)/results/page.tsx
+++ b/frontend/src/app/(protected)/results/page.tsx
@@ -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() {
diff --git a/frontend/src/app/components/AdminPurgePanel.tsx b/frontend/src/app/components/AdminPurgePanel.tsx
new file mode 100644
index 0000000..1b7d8eb
--- /dev/null
+++ b/frontend/src/app/components/AdminPurgePanel.tsx
@@ -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([]);
+ const [camera, setCamera] = useState('');
+ const [from, setFrom] = useState('');
+ const [to, setTo] = useState('');
+ const [alsoFiles, setAlsoFiles] = useState(false);
+
+ const [loading, setLoading] = useState(false);
+ const [preview, setPreview] = useState(null);
+ const [result, setResult] = useState(null);
+ const [confirm, setConfirm] = useState('');
+
+ // Progress (SSE)
+ const [jobId, setJobId] = useState(null);
+ const [stage, setStage] = useState('');
+ const [progress, setProgress] = useState(0);
+ const [done, setDone] = useState(0);
+ const [total, setTotal] = useState(0);
+ const [filesDeleted, setFilesDeleted] = useState(0);
+ const sseRef = useRef(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 (
+
+ Gefahrenbereich: Daten löschen
+
+ Als Admin kannst du Erkennungen nach Kamera und Zeitraum dauerhaft löschen.
+
+
+
+ {/* Kamera */}
+
+
+ {/* Zeitraum */}
+
+
+
+ {/* Dateien */}
+
+
+
+
+
+
+
+ {/* Preview */}
+ {preview && (
+ 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 && (
+
+
+
+
+
+
+
+ )}
+
+ {/* Fortschritt */}
+ {jobId && (
+
+
+ {stage || '…'}
+ {Math.round(progress)}%
+
+
+
+ {done.toLocaleString('de-DE')} / {total.toLocaleString('de-DE')} Einträge
+ {filesDeleted ? ` • ${filesDeleted} Datei(en)` : ''}
+
+
+ )}
+
+ {/* Ergebnis */}
+ {result && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/components/Alert.tsx b/frontend/src/app/components/Alert.tsx
index 34da207..1fb9815 100644
--- a/frontend/src/app/components/Alert.tsx
+++ b/frontend/src/app/components/Alert.tsx
@@ -1,3 +1,5 @@
+// frontend\src\app\components\Alert.tsx
+
'use client';
import clsx from 'clsx';
diff --git a/frontend/src/app/components/DownloadLinkWithAlert.tsx b/frontend/src/app/components/DownloadLinkWithAlert.tsx
new file mode 100644
index 0000000..217d24b
--- /dev/null
+++ b/frontend/src/app/components/DownloadLinkWithAlert.tsx
@@ -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
+ 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 (
+
+
setShow(true)}
+ >
+ {children}
+
+
+ {show && (
+
+ )}
+
+ );
+}