diff --git a/server.js b/server.js index dd70194..fdd5894 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi const notificationService = require('./services/notificationService'); const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore'); const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore'); +const requestLogStore = require('./services/requestLogStore'); const { getStoreStatus: getCachedStoreStatusEntry, setStoreStatus: setCachedStoreStatusEntry, @@ -33,6 +34,7 @@ const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; const storeLocationIndex = new Map(); let storeLocationIndexUpdatedAt = 0; const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000; +const BACKGROUND_STORE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; function toRadians(value) { return (value * Math.PI) / 180; @@ -63,6 +65,40 @@ app.use(cors()); app.use(express.json({ limit: '1mb' })); app.use(express.static(path.join(__dirname, 'build'))); +app.use((req, res, next) => { + const startedAt = Date.now(); + let responseBodySnippet = null; + + const captureBody = (body) => { + responseBodySnippet = body; + return body; + }; + + const originalJson = res.json.bind(res); + res.json = (body) => originalJson(captureBody(body)); + + const originalSend = res.send.bind(res); + res.send = (body) => originalSend(captureBody(body)); + + res.on('finish', () => { + try { + requestLogStore.add({ + direction: 'incoming', + method: req.method, + path: req.originalUrl || req.url, + status: res.statusCode, + durationMs: Date.now() - startedAt, + sessionId: req.session?.id || null, + profileId: req.session?.profile?.id || null, + responseBody: responseBodySnippet + }); + } catch (error) { + console.warn('[REQUEST-LOG] Schreiben fehlgeschlagen:', error.message); + } + }); + next(); +}); + function isAdmin(profile) { if (!adminEmail || !profile?.email) { return false; @@ -510,6 +546,32 @@ async function loadStoresForSession(session, _settings, { forceRefresh = false, }; } +function startBackgroundStoreRefreshTicker() { + const runCheck = () => { + sessionStore.list().forEach((session) => { + if (!session?.id || !session.cookieHeader) { + return; + } + const cacheFresh = isStoreCacheFresh(session); + const job = getStoreRefreshJob(session.id); + const jobRunning = job?.status === 'running'; + if (jobRunning || cacheFresh) { + return; + } + const reason = session.storesCache?.fetchedAt ? 'background-stale-cache' : 'background-no-cache'; + const result = triggerStoreRefresh(session, { force: true, reason }); + if (result?.started) { + console.log(`[STORE-REFRESH] Hintergrund-Refresh gestartet für Session ${session.id} (${reason})`); + } + }); + }; + + setTimeout(() => { + runCheck(); + setInterval(runCheck, BACKGROUND_STORE_REFRESH_INTERVAL_MS); + }, 60 * 1000); +} + async function restoreSessionsFromDisk() { const saved = credentialStore.loadAll(); const entries = Object.entries(saved); @@ -943,6 +1005,12 @@ app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => { res.json(updated); }); +app.get('/api/debug/requests', requireAuth, requireAdmin, (req, res) => { + const limit = req.query?.limit ? Number(req.query.limit) : undefined; + const logs = requestLogStore.list(limit); + res.json({ logs }); +}); + app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); @@ -957,6 +1025,7 @@ async function startServer() { } catch (error) { console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message); } + startBackgroundStoreRefreshTicker(); app.listen(port, () => { console.log(`Server läuft auf Port ${port}`); }); diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index e146962..01b9a81 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const requestLogStore = require('./requestLogStore'); const BASE_URL = 'https://foodsharing.de'; @@ -11,6 +12,49 @@ const client = axios.create({ } }); +client.interceptors.request.use((config) => { + config.metadata = { startedAt: Date.now() }; + return config; +}); + +client.interceptors.response.use( + (response) => { + const startedAt = response?.config?.metadata?.startedAt || Date.now(); + try { + requestLogStore.add({ + direction: 'outgoing', + target: 'foodsharing.de', + method: (response.config?.method || 'GET').toUpperCase(), + path: response.config?.url || '', + status: response.status, + durationMs: Date.now() - startedAt, + responseBody: response.data + }); + } catch (error) { + console.warn('[REQUEST-LOG] Outgoing-Log fehlgeschlagen:', error.message); + } + return response; + }, + (error) => { + const startedAt = error?.config?.metadata?.startedAt || Date.now(); + try { + requestLogStore.add({ + direction: 'outgoing', + target: 'foodsharing.de', + method: (error.config?.method || 'GET').toUpperCase(), + path: error.config?.url || '', + status: error?.response?.status || null, + durationMs: Date.now() - startedAt, + error: error?.message || 'Unbekannter Fehler', + responseBody: error?.response?.data + }); + } catch (logError) { + console.warn('[REQUEST-LOG] Outgoing-Error-Log fehlgeschlagen:', logError.message); + } + return Promise.reject(error); + } +); + function extractCsrfToken(cookies = []) { if (!Array.isArray(cookies)) { return null; @@ -230,6 +274,16 @@ async function pickupRuleCheck(storeId, utcDate, profileId, session) { return response.data?.result === true; } +async function fetchStoreMembers(storeId, cookieHeader) { + if (!storeId) { + return []; + } + const response = await client.get(`/api/stores/${storeId}/member`, { + headers: buildHeaders(cookieHeader) + }); + return Array.isArray(response.data) ? response.data : []; +} + async function bookSlot(storeId, utcDate, profileId, session) { await client.post( `/api/stores/${storeId}/pickups/${utcDate}/${profileId}`, @@ -248,6 +302,7 @@ module.exports = { fetchPickups, fetchRegionStores, fetchStoreDetails, + fetchStoreMembers, pickupRuleCheck, bookSlot }; diff --git a/services/notificationService.js b/services/notificationService.js index bd99419..5cf389b 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -199,10 +199,29 @@ async function sendTestNotification(profileId, channel) { await Promise.all(tasks); } +async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonLines = [] }) { + if (!profileId || !Array.isArray(reasonLines) || reasonLines.length === 0) { + return; + } + const adminSettings = adminConfig.readSettings(); + const userSettings = readNotificationSettings(profileId); + const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null; + const title = `Prüfung fällig: ${storeName}`; + const messageBody = reasonLines.join('\n'); + const message = storeLink ? `${messageBody}\n${storeLink}` : messageBody; + + await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, { + title, + message, + priority: 'high' + }); +} + module.exports = { sendSlotNotification, sendStoreWatchNotification, sendStoreWatchSummaryNotification, sendTestNotification, - sendDesiredWindowMissedNotification + sendDesiredWindowMissedNotification, + sendDormantPickupWarning }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index 473bb50..0b5b8cc 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -6,6 +6,7 @@ const notificationService = require('./notificationService'); const { readConfig, writeConfig } = require('./configStore'); const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore'); const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache'); +const { sendDormantPickupWarning } = require('./notificationService'); function wait(ms) { if (!ms || ms <= 0) { @@ -557,6 +558,7 @@ function scheduleEntry(sessionId, entry, settings) { function scheduleConfig(sessionId, config, settings) { const resolvedSettings = resolveSettings(settings); sessionStore.clearJobs(sessionId); + scheduleDormantMembershipCheck(sessionId); const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings); const entries = Array.isArray(config) ? config : []; const activeEntries = entries.filter((entry) => entry.active); @@ -581,6 +583,100 @@ async function runStoreWatchCheck(sessionId, settings, options = {}) { return checkWatchedStores(sessionId, resolvedSettings, options); } +function setMonthOffset(date, offset) { + const copy = new Date(date.getTime()); + copy.setMonth(copy.getMonth() + offset); + return copy; +} + +async function checkDormantMembers(sessionId) { + const session = sessionStore.get(sessionId); + if (!session?.profile?.id) { + return; + } + const profileId = session.profile.id; + const ensured = await ensureSession(session); + if (!ensured) { + return; + } + const config = readConfig(profileId); + const skipMap = new Map(); + config.forEach((entry) => { + if (entry?.id) { + skipMap.set(String(entry.id), !!entry.skipDormantCheck); + } + }); + + const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : []; + if (stores.length === 0) { + console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`); + } + const fourMonthsAgo = setMonthOffset(new Date(), -4).getTime(); + const hygieneCutoff = Date.now() + 6 * 7 * 24 * 60 * 60 * 1000; + + for (const store of stores) { + const storeId = store?.id ? String(store.id) : null; + if (!storeId) { + continue; + } + if (skipMap.get(storeId)) { + continue; + } + let members = []; + try { + members = await foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader); + } catch (error) { + console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message); + continue; + } + const memberEntry = members.find((m) => String(m?.id) === String(profileId)); + if (!memberEntry) { + continue; + } + const reasons = []; + const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null; + if (!lastFetchMs || lastFetchMs < fourMonthsAgo) { + const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt'; + reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`); + } + if (memberEntry.hygiene_certificate_until) { + const expiry = new Date(memberEntry.hygiene_certificate_until.replace(' ', 'T')); + if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) { + reasons.push( + `Hygiene-Nachweis läuft bald ab: ${expiry.toLocaleDateString('de-DE')} (unter 6 Wochen)` + ); + } + } + if (reasons.length > 0) { + try { + await sendDormantPickupWarning({ + profileId, + storeName: store.name || `Store ${storeId}`, + storeId, + reasonLines: reasons + }); + } catch (error) { + console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message); + } + } + } +} + +function scheduleDormantMembershipCheck(sessionId) { + const cronExpression = '0 4 */14 * *'; + const job = cron.schedule( + cronExpression, + () => { + checkDormantMembers(sessionId).catch((error) => { + console.error('[DORMANT] Prüfung fehlgeschlagen:', error.message); + }); + }, + { timezone: 'Europe/Berlin' } + ); + sessionStore.attachJob(sessionId, job); + setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180)); +} + module.exports = { scheduleConfig, runStoreWatchCheck diff --git a/services/requestLogStore.js b/services/requestLogStore.js new file mode 100644 index 0000000..dd778c3 --- /dev/null +++ b/services/requestLogStore.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +const path = require('path'); +const { v4: uuid } = require('uuid'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const LOG_FILE = path.join(CONFIG_DIR, 'request-logs.json'); +const TTL_MS = 14 * 24 * 60 * 60 * 1000; +const MAX_BODY_CHARS = 10000; + +function ensureDir() { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function readLogs() { + try { + ensureDir(); + if (!fs.existsSync(LOG_FILE)) { + return []; + } + const raw = fs.readFileSync(LOG_FILE, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn('[REQUEST-LOG] Konnte Logdatei nicht lesen:', error.message); + return []; + } +} + +function persistLogs(logs) { + try { + ensureDir(); + fs.writeFileSync(LOG_FILE, JSON.stringify(logs, null, 2)); + } catch (error) { + console.warn('[REQUEST-LOG] Konnte Logdatei nicht schreiben:', error.message); + } +} + +function prune(logs = []) { + const cutoff = Date.now() - TTL_MS; + return logs.filter((entry) => Number(entry?.timestamp) >= cutoff); +} + +function serializeBodySnippet(body) { + try { + if (body === undefined || body === null) { + return null; + } + let text = ''; + if (typeof body === 'string') { + text = body; + } else if (Buffer.isBuffer(body)) { + text = body.toString('utf8'); + } else { + text = JSON.stringify(body); + } + if (text.length > MAX_BODY_CHARS) { + return `${text.slice(0, MAX_BODY_CHARS)}… (gekürzt)`; + } + return text; + } catch (error) { + return `<>`; + } +} + +function add(entry = {}) { + const logs = prune(readLogs()); + const record = { + id: uuid(), + timestamp: Date.now(), + ...entry + }; + if ('responseBody' in record) { + record.responseBody = serializeBodySnippet(record.responseBody); + } + logs.push(record); + persistLogs(logs); + return record; +} + +function list(limit = 500) { + const sanitizedLimit = Math.max(1, Math.min(Number(limit) || 500, 2000)); + const logs = prune(readLogs()); + persistLogs(logs); + return logs.slice(-sanitizedLimit).reverse(); +} + +module.exports = { + add, + list, + serializeBodySnippet +}; diff --git a/src/App.js b/src/App.js index 45b0d3a..389effc 100644 --- a/src/App.js +++ b/src/App.js @@ -23,6 +23,7 @@ import ConfirmationDialog from './components/ConfirmationDialog'; import StoreSyncOverlay from './components/StoreSyncOverlay'; import RangePickerModal from './components/RangePickerModal'; import StoreWatchPage from './components/StoreWatchPage'; +import DebugPage from './components/DebugPage'; function App() { const [credentials, setCredentials] = useState({ email: '', password: '' }); @@ -484,6 +485,15 @@ function App() { ); }; + const handleToggleDormantSkip = (entryId) => { + setIsDirty(true); + setConfig((prev) => + prev.map((item) => + item.id === entryId ? { ...item, skipDormantCheck: !item.skipDormantCheck } : item + ) + ); + }; + const handleWeekdayChange = (entryId, value) => { setIsDirty(true); setConfig((prev) => @@ -623,6 +633,7 @@ function App() { active: false, checkProfileId: true, onlyNotify: false, + skipDormantCheck: false, hidden: false } ]; @@ -732,6 +743,7 @@ function App() { onToggleActive={handleToggleActive} onToggleProfileCheck={handleToggleProfileCheck} onToggleOnlyNotify={handleToggleOnlyNotify} + onToggleDormantSkip={handleToggleDormantSkip} onWeekdayChange={handleWeekdayChange} weekdays={weekdays} onRangePickerRequest={setActiveRangePicker} @@ -778,12 +790,12 @@ function App() { setNotificationPanelOpen((prev) => !prev)} notificationProps={sharedNotificationProps} @@ -791,6 +803,12 @@ function App() { /> } /> + : + } + /> } /> diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js index 72f2f8b..e23d732 100644 --- a/src/components/DashboardView.js +++ b/src/components/DashboardView.js @@ -97,6 +97,7 @@ const DashboardView = ({ onToggleActive, onToggleProfileCheck, onToggleOnlyNotify, + onToggleDormantSkip, onWeekdayChange, weekdays, onRangePickerRequest, @@ -225,7 +226,7 @@ const DashboardView = ({ id: 'checkProfileId', header: () => Profil prüfen, cell: ({ row }) => ( -
+
Ruhe-Prüfung, + cell: ({ row }) => ( +
+ onToggleDormantSkip(row.original.id)} + title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren" + /> +
+ ), + enableSorting: false, + enableColumnFilter: false + }), columnHelper.accessor((row) => row.onlyNotify, { id: 'onlyNotify', header: ({ column }) => ( @@ -545,18 +563,30 @@ const DashboardView = ({

{statusLabel}

- -
- ); - })} - - )} +
+ + +
+ + ); + })} + + )} {error && ( diff --git a/src/components/DebugPage.js b/src/components/DebugPage.js new file mode 100644 index 0000000..8a95625 --- /dev/null +++ b/src/components/DebugPage.js @@ -0,0 +1,441 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + useReactTable +} from '@tanstack/react-table'; + +const formatDateTime = (value) => { + if (!value) { + return ''; + } + try { + return new Date(value).toLocaleString(); + } catch { + return String(value); + } +}; + +const DirectionBadge = ({ value }) => { + const base = 'px-2 py-1 text-xs rounded-full font-semibold'; + if (value === 'outgoing') { + return Outgoing; + } + return Incoming; +}; + +const StatusBadge = ({ status }) => { + const code = Number(status); + let color = 'bg-gray-100 text-gray-700'; + if (code >= 500) { + color = 'bg-red-100 text-red-700'; + } else if (code >= 400) { + color = 'bg-amber-100 text-amber-800'; + } else if (code > 0) { + color = 'bg-green-100 text-green-700'; + } + return {status ?? '—'}; +}; + +const buildRequestLink = (path, target) => { + if (!path) { + return null; + } + const trimmed = String(path).trim(); + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + let base = ''; + if (target) { + const normalizedTarget = String(target).trim().replace(/\/+$/, ''); + base = /^https?:\/\//i.test(normalizedTarget) + ? normalizedTarget + : `https://${normalizedTarget}`; + } else if (typeof window !== 'undefined' && window.location?.origin) { + base = window.location.origin; + } + if (!base) { + return null; + } + const normalizedPath = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return `${base}${normalizedPath}`; +}; + +const DebugPage = ({ authorizedFetch }) => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const columnHelper = useMemo(() => createColumnHelper(), []); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 100 }); + const [expandedId, setExpandedId] = useState(null); + + const TABLE_STATE_KEY = 'debugRequestsTableState'; + + const readTableState = useCallback(() => { + if (typeof window === 'undefined') { + return { sorting: [], columnFilters: [] }; + } + try { + const raw = window.localStorage.getItem(TABLE_STATE_KEY); + if (!raw) { + return { sorting: [], columnFilters: [] }; + } + const parsed = JSON.parse(raw); + return { + sorting: Array.isArray(parsed.sorting) ? parsed.sorting : [], + columnFilters: Array.isArray(parsed.columnFilters) ? parsed.columnFilters : [] + }; + } catch { + return { sorting: [], columnFilters: [] }; + } + }, []); + + const persistTableState = useCallback((state) => { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem(TABLE_STATE_KEY, JSON.stringify(state)); + } catch { + /* ignore */ + } + }, []); + + const fetchLogs = useCallback(async () => { + setLoading(true); + setError(''); + try { + const response = await authorizedFetch('/api/debug/requests'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setLogs(Array.isArray(data.logs) ? data.logs : []); + } catch (err) { + setError(`Logs konnten nicht geladen werden: ${err.message}`); + } finally { + setLoading(false); + } + }, [authorizedFetch]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + const rows = useMemo(() => logs.map((entry) => ({ + id: entry.id, + direction: entry.direction || 'incoming', + method: entry.method || '', + path: entry.path || '', + status: entry.status ?? null, + durationMs: entry.durationMs ?? null, + timestamp: entry.timestamp, + profileId: entry.profileId || null, + sessionId: entry.sessionId || null, + target: entry.target || null, + error: entry.error || null, + responseBody: entry.responseBody || null + })), [logs]); + + const ColumnTextFilter = ({ column, placeholder }) => { + if (!column.getCanFilter()) { + return null; + } + return ( + column.setFilterValue(event.target.value || undefined)} + placeholder={placeholder} + className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + ); + }; + + const SortableHeader = ({ column, label, placeholder }) => ( +
+ + +
+ ); + + const columns = useMemo( + () => [ + columnHelper.accessor('timestamp', { + header: ({ column }) => , + cell: ({ getValue }) => formatDateTime(getValue()), + sortingFn: 'datetime', + filterFn: 'includesString' + }), + columnHelper.accessor('direction', { + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.target && {row.original.target}} +
+ ), + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('method', { + header: ({ column }) => , + cell: ({ getValue }) => {getValue()}, + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('path', { + header: ({ column }) => , + cell: ({ getValue, row }) => { + const value = getValue(); + const href = buildRequestLink(value, row.original.target); + if (!href) { + return ( + + {value || '—'} + + ); + } + return ( + event.stopPropagation()} + className="max-w-xs truncate block whitespace-nowrap text-blue-600 hover:underline" + title={href} + > + {value || href} + + ); + }, + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('status', { + header: ({ column }) => , + cell: ({ getValue }) => , + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('durationMs', { + header: ({ column }) => , + cell: ({ getValue }) => (getValue() != null ? getValue() : '—'), + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('profileId', { + header: ({ column }) => , + cell: ({ getValue }) => {getValue() || '—'}, + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('sessionId', { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return ( + + {value || '—'} + + ); + }, + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }), + columnHelper.accessor('error', { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return ( + + {value || '—'} + + ); + }, + sortingFn: 'alphanumeric', + filterFn: 'includesString' + }) + ], + [columnHelper] + ); + + const initialState = useMemo(() => readTableState(), [readTableState]); + const [tableSorting, setTableSorting] = useState(initialState.sorting.length ? initialState.sorting : [{ id: 'timestamp', desc: true }]); + const [tableFilters, setTableFilters] = useState(initialState.columnFilters); + + useEffect(() => { + persistTableState({ sorting: tableSorting, columnFilters: tableFilters }); + }, [tableFilters, tableSorting, persistTableState]); + + const table = useReactTable({ + data: rows, + columns, + state: { + sorting: tableSorting, + columnFilters: tableFilters, + pagination + }, + onSortingChange: setTableSorting, + onColumnFiltersChange: setTableFilters, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + return ( +
+
+
+

Request-Debug

+

Alle Requests der letzten 14 Tage

+
+ +
+ {error && ( +
+ {error} +
+ )} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => { + const isExpanded = expandedId === row.original.id; + return ( + + setExpandedId((prev) => (prev === row.original.id ? null : row.original.id))} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + {isExpanded && ( + + + + )} + + ); + }) + )} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+ Keine Einträge vorhanden. +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ Details + Richtung: {row.original.direction} + Methode: {row.original.method} + Status: {row.original.status ?? '—'} + Dauer: {row.original.durationMs != null ? `${row.original.durationMs} ms` : '—'} +
+
+

Response-Body

+
+                                {row.original.responseBody || 'Keine Details erfasst.'}
+                              
+
+
+
+
+
+
+ Zeilen pro Seite: + +
+
+ + + + Seite {table.getState().pagination.pageIndex + 1} von {table.getPageCount() || 1} + + + +
+
+
+ ); +}; + +export default DebugPage; diff --git a/src/components/NavigationTabs.js b/src/components/NavigationTabs.js index e3a8904..d10481c 100644 --- a/src/components/NavigationTabs.js +++ b/src/components/NavigationTabs.js @@ -9,6 +9,7 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => { { to: '/store-watch', label: 'Betriebs-Monitoring' } ]; if (isAdmin) { + tabs.push({ to: '/debug', label: 'Debug' }); tabs.push({ to: '/admin', label: 'Admin' }); }