feat(admin): add system logs viewer, tabbed data management, fix AT20 sync

This commit is contained in:
Matthias Hochmeister
2026-04-18 18:31:22 +02:00
parent 0a6377a64f
commit 0a5402a9e5
7 changed files with 526 additions and 205 deletions

View File

@@ -15,12 +15,13 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import cleanupService from '../services/cleanup.service';
import atemschutzService from '../services/atemschutz.service';
import userService from '../services/user.service';
import pool from '../config/database';
import logger from '../utils/logger';
@@ -208,10 +209,6 @@ router.post(
try {
const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 });
res.json({ success: true, data: response.data });
// Fire-and-forget: sync AT20 courses to atemschutz_traeger after FDISK data is written
atemschutzService.syncLehrgangFromKurse().catch(err =>
logger.error('AT20 Atemschutz-Sync fehlgeschlagen', { error: err })
);
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
res.status(409).json({ success: false, message: 'Sync already in progress' });
@@ -222,6 +219,47 @@ router.post(
}
);
// ---------------------------------------------------------------------------
// GET /api/admin/logs — read Winston application log files
// ---------------------------------------------------------------------------
const LOG_DIR = path.join(__dirname, '../../logs');
router.get(
'/logs',
authenticate,
requirePermission('admin:view'),
async (req: Request, res: Response): Promise<void> => {
try {
const file = req.query.file === 'error' ? 'error.log' : 'combined.log';
const maxLines = Math.min(Math.max(Number(req.query.lines) || 500, 1), 2000);
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : '';
const filePath = path.join(LOG_DIR, file);
if (!fs.existsSync(filePath)) {
res.json({ success: true, data: { file, lines: [], total: 0 } });
return;
}
const content = fs.readFileSync(filePath, 'utf-8');
let allLines = content.split('\n').filter(l => l.trim().length > 0);
if (search) {
allLines = allLines.filter(l => l.toLowerCase().includes(search));
}
const total = allLines.length;
const lines = allLines.slice(-maxLines);
res.json({ success: true, data: { file, lines, total } });
} catch (error) {
logger.error('Failed to read log file', { error });
res.status(500).json({ success: false, message: 'Failed to read log file' });
}
}
);
// ---------------------------------------------------------------------------
// Cleanup / Data Management endpoints
// ---------------------------------------------------------------------------

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import {
Box, Paper, Typography, TextField, Button, Alert,
CircularProgress, Divider, Autocomplete, Chip,
Box, Paper, Typography, TextField, Button, Alert, Tabs, Tab,
CircularProgress, Autocomplete, Chip,
} from '@mui/material';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
@@ -20,33 +20,53 @@ interface CleanupSection {
defaultDays: number;
}
const SECTIONS: CleanupSection[] = [
{ key: 'notifications', label: 'Benachrichtigungen', description: 'Alte Benachrichtigungen aller Benutzer entfernen.', defaultDays: 90 },
{ key: 'audit-log', label: 'Audit-Log', description: 'Alte Audit-Log Eintraege entfernen.', defaultDays: 365 },
{ key: 'events', label: 'Veranstaltungen', description: 'Vergangene Veranstaltungen entfernen (nach Enddatum).', defaultDays: 365 },
{ key: 'bookings', label: 'Fahrzeugbuchungen', description: 'Abgeschlossene oder stornierte Buchungen entfernen.', defaultDays: 180 },
{ key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 },
{ key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 },
{ key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 },
{ key: 'checklist-history', label: 'Checklisten-Historie', description: 'Alte Checklisten-Ausfuehrungen entfernen.', defaultDays: 365 },
];
interface ResetSection {
key: string;
label: string;
description: string;
}
const RESET_SECTIONS: ResetSection[] = [
{ key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-ausruestung-anfragen', label: 'Interne Bestellungen zuruecksetzen', description: 'Alle internen Bestellungen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-checklist-history', label: 'Checklisten-Historie zuruecksetzen', description: 'Alle Checklisten-Ausfuehrungen und Faelligkeiten loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-buchhaltung-transaktionen', label: 'Buchhaltung: Transaktionen loeschen', description: 'Alle Buchungen und Transaktionen loeschen und Nummerierung zuruecksetzen. Konten und Haushaltsjahre bleiben erhalten.' },
{ key: 'reset-buchhaltung-konten', label: 'Buchhaltung: Konten loeschen', description: 'Alle Konten und alle zugehoerigen Transaktionen loeschen und Nummerierung zuruecksetzen. Haushaltsjahre bleiben erhalten.' },
{ key: 'reset-buchhaltung-bankkonten', label: 'Buchhaltung: Bankkonten loeschen', description: 'Alle Bankkonten loeschen und Nummerierung zuruecksetzen.' },
{ key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' },
];
// ── Grouped by feature area ──
const TAB_CLEANUP: Record<string, CleanupSection[]> = {
system: [
{ key: 'notifications', label: 'Benachrichtigungen', description: 'Alte Benachrichtigungen aller Benutzer entfernen.', defaultDays: 90 },
{ key: 'audit-log', label: 'Audit-Log', description: 'Alte Audit-Log Eintraege entfernen.', defaultDays: 365 },
],
kalender: [
{ key: 'events', label: 'Veranstaltungen', description: 'Vergangene Veranstaltungen entfernen (nach Enddatum).', defaultDays: 365 },
{ key: 'bookings', label: 'Fahrzeugbuchungen', description: 'Abgeschlossene oder stornierte Buchungen entfernen.', defaultDays: 180 },
],
fahrzeuge: [
{ key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 },
{ key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 },
],
bestellungen: [
{ key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 },
],
checklisten: [
{ key: 'checklist-history', label: 'Checklisten-Historie', description: 'Alte Checklisten-Ausfuehrungen entfernen.', defaultDays: 365 },
],
};
const TAB_RESET: Record<string, ResetSection[]> = {
fahrzeuge: [
{ key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' },
],
bestellungen: [
{ key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-ausruestung-anfragen', label: 'Interne Bestellungen zuruecksetzen', description: 'Alle internen Bestellungen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' },
],
buchhaltung: [
{ key: 'reset-buchhaltung-transaktionen', label: 'Buchhaltung: Transaktionen loeschen', description: 'Alle Buchungen und Transaktionen loeschen und Nummerierung zuruecksetzen. Konten und Haushaltsjahre bleiben erhalten.' },
{ key: 'reset-buchhaltung-konten', label: 'Buchhaltung: Konten loeschen', description: 'Alle Konten und alle zugehoerigen Transaktionen loeschen und Nummerierung zuruecksetzen. Haushaltsjahre bleiben erhalten.' },
{ key: 'reset-buchhaltung-bankkonten', label: 'Buchhaltung: Bankkonten loeschen', description: 'Alle Bankkonten loeschen und Nummerierung zuruecksetzen.' },
],
checklisten: [
{ key: 'reset-checklist-history', label: 'Checklisten-Historie zuruecksetzen', description: 'Alle Checklisten-Ausfuehrungen und Faelligkeiten loeschen und Nummern zuruecksetzen.' },
{ key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' },
],
};
interface SectionState {
days: number;
@@ -54,8 +74,22 @@ interface SectionState {
loading: boolean;
}
const ALL_CLEANUP = Object.values(TAB_CLEANUP).flat();
const ALL_RESET = Object.values(TAB_RESET).flat();
const TABS = [
{ key: 'fdisk', label: 'FDISK' },
{ key: 'system', label: 'System' },
{ key: 'kalender', label: 'Kalender' },
{ key: 'fahrzeuge', label: 'Fahrzeuge & Ausruestung' },
{ key: 'bestellungen', label: 'Bestellungen' },
{ key: 'buchhaltung', label: 'Buchhaltung' },
{ key: 'checklisten', label: 'Checklisten & Issues' },
];
export default function DataManagementTab() {
const { showSuccess, showError } = useNotification();
const [subTab, setSubTab] = useState(0);
// ── FDISK purge ──
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
@@ -102,16 +136,17 @@ export default function DataManagementTab() {
}
}, [selectedUsers, showSuccess, showError]);
// ── Cleanup state (age-based) ──
const [states, setStates] = useState<Record<string, SectionState>>(() =>
Object.fromEntries(SECTIONS.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }]))
Object.fromEntries(ALL_CLEANUP.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }]))
);
const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null);
const [deleting, setDeleting] = useState(false);
// Reset sections state
// ── Reset state (truncate) ──
const [resetStates, setResetStates] = useState<Record<string, { previewCount: number | null; loading: boolean }>>(() =>
Object.fromEntries(RESET_SECTIONS.map(s => [s.key, { previewCount: null, loading: false }]))
Object.fromEntries(ALL_RESET.map(s => [s.key, { previewCount: null, loading: false }]))
);
const [resetConfirmDialog, setResetConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null);
const [resetDeleting, setResetDeleting] = useState(false);
@@ -179,85 +214,223 @@ export default function DataManagementTab() {
}
}, [resetConfirmDialog, showSuccess, showError]);
return (
<Box sx={{ maxWidth: 800 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Datenverwaltung</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Alte Daten bereinigen, um die Datenbank schlank zu halten. Geloeschte Daten koennen nicht wiederhergestellt werden.
</Typography>
// ── Render helpers ──
{/* FDISK data purge */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
FDISK-Daten loeschen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Loescht alle FDISK-synchronisierten Daten der ausgewaehlten Benutzer: Profilfelder, Ausbildungen,
Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden
die Daten erneut importiert.
</Typography>
const renderCleanupSection = (section: CleanupSection) => {
const s = states[section.key];
return (
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Autocomplete
options={users.filter(u => !selectedUsers.some(s => s.id === u.id))}
loading={usersLoading}
value={null}
inputValue={fdiskInput}
onInputChange={(_e, val, reason) => {
if (reason === 'input') setFdiskInput(val);
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Aelter als (Tage)"
type="number"
size="small"
value={s.days}
onChange={e => {
const v = parseInt(e.target.value, 10);
if (v > 0) updateState(section.key, { days: v, previewCount: null });
}}
onChange={(_e, user) => {
if (user) {
handleAddUser(user);
setFdiskInput('');
}
}}
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
isOptionEqualToValue={(a, b) => a.id === b.id}
sx={{ minWidth: 320, flex: 1 }}
renderInput={(params) => (
<TextField {...params} label="Benutzer hinzufuegen" size="small" />
)}
sx={{ width: 160 }}
inputProps={{ min: 1, max: 3650 }}
/>
<Button
variant="outlined"
size="small"
startIcon={<GroupAddIcon />}
onClick={handleAddAll}
disabled={usersLoading || users.length === 0 || selectedUsers.length === users.length}
onClick={() => handlePreview(section.key)}
disabled={s.loading}
startIcon={s.loading ? <CircularProgress size={16} /> : undefined}
>
Alle hinzufuegen
Vorschau
</Button>
</Box>
{selectedUsers.length > 0 && (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2 }}>
{selectedUsers.map(u => (
<Chip
key={u.id}
label={u.name || u.email}
onDelete={() => handleRemoveUser(u.id)}
{s.previewCount !== null && (
<>
<Alert severity={s.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
{s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden
</Alert>
{s.previewCount > 0 && (
<Button
variant="contained"
color="error"
size="small"
/>
))}
</Box>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
color="error"
disabled={purging}
startIcon={purging ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
onClick={() => setPurgeConfirmOpen(true)}
>
FDISK-Daten loeschen ({selectedUsers.length})
</Button>
</Box>
startIcon={<DeleteSweepIcon />}
onClick={() => setConfirmDialog({ key: section.key, label: section.label, count: s.previewCount! })}
>
Loeschen
</Button>
)}
</>
)}
</Box>
</Paper>
);
};
const renderResetSection = (section: ResetSection) => {
const rs = resetStates[section.key];
return (
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size="small"
onClick={() => handleResetPreview(section.key)}
disabled={rs?.loading}
startIcon={rs?.loading ? <CircularProgress size={16} /> : undefined}
>
Vorschau
</Button>
{rs?.previewCount !== null && rs?.previewCount !== undefined && (
<>
<Alert severity={rs.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
{rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden
</Alert>
{rs.previewCount > 0 && (
<Button
variant="contained"
color="error"
size="small"
startIcon={<RestartAltIcon />}
onClick={() => setResetConfirmDialog({ key: section.key, label: section.label, count: rs.previewCount! })}
>
Zuruecksetzen
</Button>
)}
</>
)}
</Box>
</Paper>
);
};
const renderTabContent = (tabKey: string) => {
const cleanups = TAB_CLEANUP[tabKey] ?? [];
const resets = TAB_RESET[tabKey] ?? [];
return (
<Box sx={{ maxWidth: 800 }}>
{cleanups.length > 0 && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Alte Daten bereinigen. Geloeschte Daten koennen nicht wiederhergestellt werden.
</Typography>
{cleanups.map(renderCleanupSection)}
</>
)}
</Paper>
{cleanups.length > 0 && resets.length > 0 && (
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mt: 3, mb: 1 }}>
Zuruecksetzen
</Typography>
)}
{resets.map(renderResetSection)}
</Box>
);
};
const currentTabKey = TABS[subTab]?.key;
return (
<Box>
<Tabs
value={subTab}
onChange={(_e, v) => setSubTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
{TABS.map((t) => <Tab key={t.key} label={t.label} />)}
</Tabs>
{/* FDISK tab — special layout with user selector */}
{currentTabKey === 'fdisk' && (
<Box sx={{ maxWidth: 800 }}>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
FDISK-Daten loeschen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Loescht alle FDISK-synchronisierten Daten der ausgewaehlten Benutzer: Profilfelder, Ausbildungen,
Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden
die Daten erneut importiert.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Autocomplete
options={users.filter(u => !selectedUsers.some(s => s.id === u.id))}
loading={usersLoading}
value={null}
inputValue={fdiskInput}
onInputChange={(_e, val, reason) => {
if (reason === 'input') setFdiskInput(val);
}}
onChange={(_e, user) => {
if (user) {
handleAddUser(user);
setFdiskInput('');
}
}}
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
isOptionEqualToValue={(a, b) => a.id === b.id}
sx={{ minWidth: 320, flex: 1 }}
renderInput={(params) => (
<TextField {...params} label="Benutzer hinzufuegen" size="small" />
)}
/>
<Button
variant="outlined"
size="small"
startIcon={<GroupAddIcon />}
onClick={handleAddAll}
disabled={usersLoading || users.length === 0 || selectedUsers.length === users.length}
>
Alle hinzufuegen
</Button>
</Box>
{selectedUsers.length > 0 && (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2 }}>
{selectedUsers.map(u => (
<Chip
key={u.id}
label={u.name || u.email}
onDelete={() => handleRemoveUser(u.id)}
size="small"
/>
))}
</Box>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
color="error"
disabled={purging}
startIcon={purging ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
onClick={() => setPurgeConfirmOpen(true)}
>
FDISK-Daten loeschen ({selectedUsers.length})
</Button>
</Box>
</>
)}
</Paper>
</Box>
)}
{/* Feature-area tabs */}
{currentTabKey && currentTabKey !== 'fdisk' && renderTabContent(currentTabKey)}
{/* Shared confirm dialogs */}
<ConfirmDialog
open={purgeConfirmOpen}
onClose={() => !purging && setPurgeConfirmOpen(false)}
@@ -283,63 +456,6 @@ export default function DataManagementTab() {
isLoading={purging}
/>
{SECTIONS.map((section, idx) => {
const s = states[section.key];
return (
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Aelter als (Tage)"
type="number"
size="small"
value={s.days}
onChange={e => {
const v = parseInt(e.target.value, 10);
if (v > 0) updateState(section.key, { days: v, previewCount: null });
}}
sx={{ width: 160 }}
inputProps={{ min: 1, max: 3650 }}
/>
<Button
variant="outlined"
size="small"
onClick={() => handlePreview(section.key)}
disabled={s.loading}
startIcon={s.loading ? <CircularProgress size={16} /> : undefined}
>
Vorschau
</Button>
{s.previewCount !== null && (
<>
<Alert severity={s.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
{s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden
</Alert>
{s.previewCount > 0 && (
<Button
variant="contained"
color="error"
size="small"
startIcon={<DeleteSweepIcon />}
onClick={() => setConfirmDialog({ key: section.key, label: section.label, count: s.previewCount! })}
>
Loeschen
</Button>
)}
</>
)}
</Box>
{idx < SECTIONS.length - 1 && <Divider sx={{ mt: 2, display: 'none' }} />}
</Paper>
);
})}
<ConfirmDialog
open={!!confirmDialog}
onClose={() => setConfirmDialog(null)}
@@ -356,55 +472,6 @@ export default function DataManagementTab() {
isLoading={deleting}
/>
{/* ---- Reset / Truncate sections ---- */}
<Divider sx={{ my: 4 }} />
<Typography variant="h6" sx={{ mb: 1 }}>Daten zuruecksetzen</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Alle Eintraege loeschen und Nummern (IDs) auf 1 zuruecksetzen. Abhaengige Daten werden ebenfalls geloescht.
</Typography>
{RESET_SECTIONS.map((section) => {
const rs = resetStates[section.key];
return (
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size="small"
onClick={() => handleResetPreview(section.key)}
disabled={rs?.loading}
startIcon={rs?.loading ? <CircularProgress size={16} /> : undefined}
>
Vorschau
</Button>
{rs?.previewCount !== null && rs?.previewCount !== undefined && (
<>
<Alert severity={rs.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
{rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden
</Alert>
{rs.previewCount > 0 && (
<Button
variant="contained"
color="error"
size="small"
startIcon={<RestartAltIcon />}
onClick={() => setResetConfirmDialog({ key: section.key, label: section.label, count: rs.previewCount! })}
>
Zuruecksetzen
</Button>
)}
</>
)}
</Box>
</Paper>
);
})}
<ConfirmDialog
open={!!resetConfirmDialog}
onClose={() => setResetConfirmDialog(null)}

View File

@@ -0,0 +1,164 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Box, Card, CardContent, IconButton, TextField, ToggleButton, ToggleButtonGroup,
Tooltip, Typography, CircularProgress,
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
const LINE_OPTIONS = [100, 250, 500, 1000] as const;
export default function SystemLogsTab() {
const { showSuccess, showError } = useNotification();
const logBoxRef = useRef<HTMLDivElement>(null);
const [file, setFile] = useState<'combined' | 'error'>('combined');
const [lines, setLines] = useState<number>(500);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// Debounce the search input
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'system-logs', file, lines, debouncedSearch],
queryFn: () => adminApi.getSystemLogs(file, lines, debouncedSearch),
refetchInterval: 5000,
});
// Auto-scroll to bottom on new data
useEffect(() => {
if (logBoxRef.current) {
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
}
}, [data?.lines.length]);
const copyLogs = useCallback(() => {
const text = (data?.lines ?? []).join('\n');
navigator.clipboard.writeText(text).then(
() => showSuccess('Logs kopiert'),
() => showError('Kopieren fehlgeschlagen'),
);
}, [data?.lines, showSuccess, showError]);
const getLineColor = (line: string): string | undefined => {
if (line.includes('"level":"error"') || line.includes('[error]')) return 'error.light';
if (line.includes('"level":"warn"') || line.includes('[warn]')) return 'warning.light';
return undefined;
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" gutterBottom>System Logs</Typography>
<Typography variant="body2" color="text.secondary">
Anwendungsprotokolle des Backend-Servers. Aktualisiert sich automatisch alle 5 Sekunden.
</Typography>
</Box>
<ToggleButtonGroup
value={file}
exclusive
onChange={(_e, v) => { if (v) setFile(v); }}
size="small"
>
<ToggleButton value="combined">Alle</ToggleButton>
<ToggleButton value="error">Nur Fehler</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={lines}
exclusive
onChange={(_e, v) => { if (v !== null) setLines(v); }}
size="small"
>
{LINE_OPTIONS.map((n) => (
<ToggleButton key={n} value={n}>{n}</ToggleButton>
))}
</ToggleButtonGroup>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<TextField
label="Suche"
size="small"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ flex: 1, maxWidth: 400 }}
placeholder="Filtern nach Text..."
/>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{data ? `${data.lines.length} von ${data.total} Zeilen` : ''}
</Typography>
<Tooltip title="Logs kopieren">
<span>
<IconButton
size="small"
onClick={copyLogs}
disabled={!data?.lines?.length}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{isError && (
<Typography color="error" variant="body2">
Log-Dateien konnten nicht gelesen werden.
</Typography>
)}
{!isLoading && !isError && (
<Box
ref={logBoxRef}
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
bgcolor: 'grey.900',
color: 'grey.100',
borderRadius: 1,
p: 1.5,
maxHeight: 500,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{(data?.lines ?? []).length === 0 ? (
<Typography variant="caption" color="grey.500">Noch keine Logs vorhanden.</Typography>
) : (
(data?.lines ?? []).map((line, i) => (
<Box
key={i}
component="span"
sx={{
display: 'block',
color: getLineColor(line) ?? 'inherit',
}}
>
{line}
</Box>
))
)}
</Box>
)}
</CardContent>
</Card>
</Box>
);
}

View File

@@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import DataManagementTab from '../components/admin/DataManagementTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import SystemLogsTab from '../components/admin/SystemLogsTab';
import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps {
@@ -36,7 +37,7 @@ function SubTabs({ labels, children }: { labels: string[]; children: React.React
);
}
const ADMIN_TAB_COUNT = 4;
const ADMIN_TAB_COUNT = 5;
function AdminDashboard() {
const navigate = useNavigate();
@@ -66,6 +67,7 @@ function AdminDashboard() {
<Tab label="System Mitteilungen" />
<Tab label="Tool Zugriff" />
<Tab label="Daten" />
<Tab label="Debug" />
</Tabs>
</Box>
@@ -85,8 +87,11 @@ function AdminDashboard() {
</SubTabs>
</TabPanel>
<TabPanel value={tab} index={3}>
<SubTabs labels={['Datenverwaltung', 'FDISK Sync']}>
{[<DataManagementTab />, <FdiskSyncTab />]}
<DataManagementTab />
</TabPanel>
<TabPanel value={tab} index={4}>
<SubTabs labels={['FDISK Sync', 'System Logs']}>
{[<FdiskSyncTab />, <SystemLogsTab />]}
</SubTabs>
</TabPanel>
</DashboardLayout>

View File

@@ -16,6 +16,12 @@ export interface FdiskSyncLogsResponse {
logs: FdiskSyncLogEntry[];
}
export interface SystemLogsResponse {
file: string;
lines: string[];
total: number;
}
export const adminApi = {
getServices: () => api.get<ApiResponse<MonitoredService[]>>('/api/admin/services').then(r => r.data.data),
createService: (data: { name: string; url: string }) => api.post<ApiResponse<MonitoredService>>('/api/admin/services', data).then(r => r.data.data),
@@ -32,4 +38,9 @@ export const adminApi = {
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
purgeFdiskData: (userId: string) => api.delete<ApiResponse<{ profileFieldsCleared: number; ausbildungen: number; befoerderungen: number; untersuchungen: number; fahrgenehmigungen: number }>>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data),
purgeUserData: (userId: string) => api.post<ApiResponse<Record<string, number>>>(`/api/admin/users/${userId}/purge-data`).then(r => r.data.data),
getSystemLogs: (file = 'combined', lines = 500, search = '') => {
const params = new URLSearchParams({ file, lines: String(lines) });
if (search) params.set('search', search);
return api.get<ApiResponse<SystemLogsResponse>>(`/api/admin/logs?${params}`).then(r => r.data.data);
},
};

View File

@@ -289,6 +289,41 @@ export async function syncToDatabase(
}
}
/**
* Scans the ausbildung table for AT20 courses with erfolgscode = 'mit Erfolg'
* and upserts atemschutz_traeger records accordingly.
* Must run AFTER syncToDatabase() has committed — i.e. on the same pool, outside the transaction.
*/
export async function syncAT20ToAtemschutz(pool: Pool): Promise<void> {
// First, log a sample of what's actually stored so we can verify the filter strings match.
const sample = await pool.query<{ kurs_kurzbezeichnung: string | null; erfolgscode: string | null; count: string }>(
`SELECT kurs_kurzbezeichnung, erfolgscode, COUNT(*)::text AS count
FROM ausbildung
WHERE kurs_kurzbezeichnung IS NOT NULL
GROUP BY kurs_kurzbezeichnung, erfolgscode
ORDER BY count DESC
LIMIT 20`
);
log(`AT20-Sync: kurs_kurzbezeichnung/erfolgscode distribution (top 20):`);
for (const row of sample.rows) {
log(` kurzbezeichnung=${JSON.stringify(row.kurs_kurzbezeichnung)} erfolgscode=${JSON.stringify(row.erfolgscode)} count=${row.count}`);
}
const result = await pool.query<{ rowCount: number }>(
`INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum)
FROM ausbildung a
WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20'
AND TRIM(a.erfolgscode) = 'mit Erfolg'
GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW()`
);
log(`AT20-Sync: ${result.rowCount ?? 0} atemschutz_traeger rows upserted`);
}
async function syncAusbildungen(
client: PoolClient,
ausbildungen: FdiskAusbildung[],

View File

@@ -2,7 +2,7 @@ import 'dotenv/config';
import * as http from 'http';
import { Pool } from 'pg';
import { scrapeAll } from './scraper';
import { syncToDatabase } from './db';
import { syncToDatabase, syncAT20ToAtemschutz } from './db';
// In-memory log ring buffer — last 500 lines captured from all modules
const LOG_BUFFER_MAX = 500;
@@ -70,6 +70,7 @@ async function runSync(force = false): Promise<void> {
const { members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen } = await scrapeAll(username, password);
await syncToDatabase(pool, members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen, force);
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen, ${befoerderungen.length} Beförderungen, ${untersuchungen.length} Untersuchungen, ${fahrgenehmigungen.length} Fahrgenehmigungen`);
await syncAT20ToAtemschutz(pool);
} finally {
syncRunning = false;
await pool.end();