Monitoring UX: Log leeren + PageSize wählbar

- Backend: DELETE /api/monitoring/events (settings:update). Optional
  ?olderThanDays=N – nur Events älter als N Tage löschen.
  Hinterlässt selbst einen Audit-Eintrag "Log geleert: X Einträge"
  mit User-E-Mail + IP, damit der Vorgang nachvollziehbar bleibt.
- Frontend: "Log leeren"-Button öffnet Bestätigungs-Modal mit
  optionalem "älter als X Tage"-Filter. Roter Bestätigungs-Button.
- Frontend: PageSize-Selector (10/25/50/100/200) neben dem Header.
  Wechsel setzt automatisch zurück auf Seite 1.

Live-verifiziert: Clear löscht 10 Events, schreibt 1 Audit-Event,
PageSize=5 wird in pagination respektiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-05-01 09:31:53 +02:00
parent 0cf3dd6a7b
commit e2fdb069ac
4 changed files with 133 additions and 4 deletions

View File

@ -148,6 +148,51 @@ export async function testAlert(_req: AuthRequest, res: Response): Promise<void>
} }
} }
/**
* DELETE /api/monitoring/events
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
* Audit-Trail erhalten bleibt.
*/
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const olderThanDays = req.query.olderThanDays
? parseInt(req.query.olderThanDays as string)
: undefined;
const where: any = {};
if (olderThanDays && olderThanDays > 0) {
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
where.createdAt = { lt: cutoff };
}
const result = await prisma.securityEvent.deleteMany({ where });
// Audit-Spur: Wer hat geleert
const user = (req as any).user;
await prisma.securityEvent.create({
data: {
type: 'PERMISSION_CHANGED',
severity: 'INFO',
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
userId: user?.userId || null,
userEmail: user?.email || null,
ipAddress: req.ip || 'unknown',
endpoint: 'DELETE /api/monitoring/events',
},
});
res.json({
success: true,
message: `${result.count} Events gelöscht`,
data: { deletedCount: result.count },
} as any);
} catch (error) {
console.error('clearEvents error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
}
}
/** /**
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest) * POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
*/ */

View File

@ -11,5 +11,6 @@ router.get('/settings', requirePermission('settings:read'), monitoringController
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings); router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert); router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow); router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
export default router; export default router;

View File

@ -7,7 +7,8 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input'; import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select'; import Select from '../../components/ui/Select';
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight } from 'lucide-react'; import Modal from '../../components/ui/Modal';
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
{ value: '', label: 'Alle Typen' }, { value: '', label: 'Alle Typen' },
@ -58,12 +59,15 @@ export default function Monitoring() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
type: '' as SecurityEventType | '', type: '' as SecurityEventType | '',
severity: '' as SecuritySeverity | '', severity: '' as SecuritySeverity | '',
search: '', search: '',
ip: '', ip: '',
}); });
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
const [alertEmail, setAlertEmail] = useState(''); const [alertEmail, setAlertEmail] = useState('');
const [digestEnabled, setDigestEnabled] = useState(false); const [digestEnabled, setDigestEnabled] = useState(false);
@ -82,11 +86,22 @@ export default function Monitoring() {
// Events laden // Events laden
const { data: eventsData, isLoading: eventsLoading } = useQuery({ const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['monitoring-events', page, filters], queryKey: ['monitoring-events', page, pageSize, filters],
queryFn: () => monitoringApi.getEvents({ page, limit: 50, ...filters }), queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
refetchInterval: 30_000, // alle 30s neu laden refetchInterval: 30_000, // alle 30s neu laden
}); });
const clearEvents = useMutation({
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
onSuccess: (res) => {
toast.success(res.message || 'Events gelöscht');
setShowClearConfirm(false);
setClearOlderThanDays('');
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
},
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
});
const saveSettings = useMutation({ const saveSettings = useMutation({
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }), mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
onSuccess: () => { onSuccess: () => {
@ -227,7 +242,26 @@ export default function Monitoring() {
{/* Tabelle */} {/* Tabelle */}
<Card> <Card>
<h2 className="text-lg font-semibold mb-3">Events</h2> <div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 className="text-lg font-semibold">Events</h2>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Pro Seite:</label>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
</Button>
</div>
</div>
{eventsLoading ? ( {eventsLoading ? (
<div className="text-gray-500 py-4">Lade</div> <div className="text-gray-500 py-4">Lade</div>
) : events.length === 0 ? ( ) : events.length === 0 ? (
@ -284,6 +318,50 @@ export default function Monitoring() {
</div> </div>
)} )}
</Card> </Card>
{/* Clear-Confirm-Modal */}
<Modal
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
title="Security-Log leeren"
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-gray-700">
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nur Events älter als (Tage)
</label>
<input
type="number"
min="0"
value={clearOlderThanDays}
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="leer = alle löschen"
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Beispiel: 30 = nur Events älter als 30 Tage löschen.
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
Abbrechen
</Button>
<Button
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
disabled={clearEvents.isPending}
className="!bg-red-600 hover:!bg-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@ -1489,6 +1489,11 @@ export const monitoringApi = {
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest'); const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
return res.data; return res.data;
}, },
clearEvents: async (olderThanDays?: number) => {
const q = olderThanDays ? `?olderThanDays=${olderThanDays}` : '';
const res = await api.delete<ApiResponse<{ deletedCount: number }>>(`/monitoring/events${q}`);
return res.data;
},
}; };
export const emailLogApi = { export const emailLogApi = {