Added Debug Log and info pickup older 6month + hygiene crt
This commit is contained in:
69
server.js
69
server.js
@@ -13,6 +13,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi
|
|||||||
const notificationService = require('./services/notificationService');
|
const notificationService = require('./services/notificationService');
|
||||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||||
|
const requestLogStore = require('./services/requestLogStore');
|
||||||
const {
|
const {
|
||||||
getStoreStatus: getCachedStoreStatusEntry,
|
getStoreStatus: getCachedStoreStatusEntry,
|
||||||
setStoreStatus: setCachedStoreStatusEntry,
|
setStoreStatus: setCachedStoreStatusEntry,
|
||||||
@@ -33,6 +34,7 @@ const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
|
|||||||
const storeLocationIndex = new Map();
|
const storeLocationIndex = new Map();
|
||||||
let storeLocationIndexUpdatedAt = 0;
|
let storeLocationIndexUpdatedAt = 0;
|
||||||
const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000;
|
const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const BACKGROUND_STORE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function toRadians(value) {
|
function toRadians(value) {
|
||||||
return (value * Math.PI) / 180;
|
return (value * Math.PI) / 180;
|
||||||
@@ -63,6 +65,40 @@ app.use(cors());
|
|||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'build')));
|
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) {
|
function isAdmin(profile) {
|
||||||
if (!adminEmail || !profile?.email) {
|
if (!adminEmail || !profile?.email) {
|
||||||
return false;
|
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() {
|
async function restoreSessionsFromDisk() {
|
||||||
const saved = credentialStore.loadAll();
|
const saved = credentialStore.loadAll();
|
||||||
const entries = Object.entries(saved);
|
const entries = Object.entries(saved);
|
||||||
@@ -943,6 +1005,12 @@ app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => {
|
|||||||
res.json(updated);
|
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) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
@@ -957,6 +1025,7 @@ async function startServer() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
|
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
|
||||||
}
|
}
|
||||||
|
startBackgroundStoreRefreshTicker();
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server läuft auf Port ${port}`);
|
console.log(`Server läuft auf Port ${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const requestLogStore = require('./requestLogStore');
|
||||||
|
|
||||||
const BASE_URL = 'https://foodsharing.de';
|
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 = []) {
|
function extractCsrfToken(cookies = []) {
|
||||||
if (!Array.isArray(cookies)) {
|
if (!Array.isArray(cookies)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -230,6 +274,16 @@ async function pickupRuleCheck(storeId, utcDate, profileId, session) {
|
|||||||
return response.data?.result === true;
|
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) {
|
async function bookSlot(storeId, utcDate, profileId, session) {
|
||||||
await client.post(
|
await client.post(
|
||||||
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
||||||
@@ -248,6 +302,7 @@ module.exports = {
|
|||||||
fetchPickups,
|
fetchPickups,
|
||||||
fetchRegionStores,
|
fetchRegionStores,
|
||||||
fetchStoreDetails,
|
fetchStoreDetails,
|
||||||
|
fetchStoreMembers,
|
||||||
pickupRuleCheck,
|
pickupRuleCheck,
|
||||||
bookSlot
|
bookSlot
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,10 +199,29 @@ async function sendTestNotification(profileId, channel) {
|
|||||||
await Promise.all(tasks);
|
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 = {
|
module.exports = {
|
||||||
sendSlotNotification,
|
sendSlotNotification,
|
||||||
sendStoreWatchNotification,
|
sendStoreWatchNotification,
|
||||||
sendStoreWatchSummaryNotification,
|
sendStoreWatchSummaryNotification,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
sendDesiredWindowMissedNotification
|
sendDesiredWindowMissedNotification,
|
||||||
|
sendDormantPickupWarning
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const notificationService = require('./notificationService');
|
|||||||
const { readConfig, writeConfig } = require('./configStore');
|
const { readConfig, writeConfig } = require('./configStore');
|
||||||
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
||||||
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||||
|
const { sendDormantPickupWarning } = require('./notificationService');
|
||||||
|
|
||||||
function wait(ms) {
|
function wait(ms) {
|
||||||
if (!ms || ms <= 0) {
|
if (!ms || ms <= 0) {
|
||||||
@@ -557,6 +558,7 @@ function scheduleEntry(sessionId, entry, settings) {
|
|||||||
function scheduleConfig(sessionId, config, settings) {
|
function scheduleConfig(sessionId, config, settings) {
|
||||||
const resolvedSettings = resolveSettings(settings);
|
const resolvedSettings = resolveSettings(settings);
|
||||||
sessionStore.clearJobs(sessionId);
|
sessionStore.clearJobs(sessionId);
|
||||||
|
scheduleDormantMembershipCheck(sessionId);
|
||||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||||
const entries = Array.isArray(config) ? config : [];
|
const entries = Array.isArray(config) ? config : [];
|
||||||
const activeEntries = entries.filter((entry) => entry.active);
|
const activeEntries = entries.filter((entry) => entry.active);
|
||||||
@@ -581,6 +583,100 @@ async function runStoreWatchCheck(sessionId, settings, options = {}) {
|
|||||||
return checkWatchedStores(sessionId, resolvedSettings, 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 = {
|
module.exports = {
|
||||||
scheduleConfig,
|
scheduleConfig,
|
||||||
runStoreWatchCheck
|
runStoreWatchCheck
|
||||||
|
|||||||
93
services/requestLogStore.js
Normal file
93
services/requestLogStore.js
Normal file
@@ -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 `<<Konnte Response nicht serialisieren: ${error.message}>>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
30
src/App.js
30
src/App.js
@@ -23,6 +23,7 @@ import ConfirmationDialog from './components/ConfirmationDialog';
|
|||||||
import StoreSyncOverlay from './components/StoreSyncOverlay';
|
import StoreSyncOverlay from './components/StoreSyncOverlay';
|
||||||
import RangePickerModal from './components/RangePickerModal';
|
import RangePickerModal from './components/RangePickerModal';
|
||||||
import StoreWatchPage from './components/StoreWatchPage';
|
import StoreWatchPage from './components/StoreWatchPage';
|
||||||
|
import DebugPage from './components/DebugPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
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) => {
|
const handleWeekdayChange = (entryId, value) => {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
@@ -623,6 +633,7 @@ function App() {
|
|||||||
active: false,
|
active: false,
|
||||||
checkProfileId: true,
|
checkProfileId: true,
|
||||||
onlyNotify: false,
|
onlyNotify: false,
|
||||||
|
skipDormantCheck: false,
|
||||||
hidden: false
|
hidden: false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -732,6 +743,7 @@ function App() {
|
|||||||
onToggleActive={handleToggleActive}
|
onToggleActive={handleToggleActive}
|
||||||
onToggleProfileCheck={handleToggleProfileCheck}
|
onToggleProfileCheck={handleToggleProfileCheck}
|
||||||
onToggleOnlyNotify={handleToggleOnlyNotify}
|
onToggleOnlyNotify={handleToggleOnlyNotify}
|
||||||
|
onToggleDormantSkip={handleToggleDormantSkip}
|
||||||
onWeekdayChange={handleWeekdayChange}
|
onWeekdayChange={handleWeekdayChange}
|
||||||
weekdays={weekdays}
|
weekdays={weekdays}
|
||||||
onRangePickerRequest={setActiveRangePicker}
|
onRangePickerRequest={setActiveRangePicker}
|
||||||
@@ -778,12 +790,12 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/store-watch"
|
path="/store-watch"
|
||||||
element={
|
element={
|
||||||
<StoreWatchPage
|
<StoreWatchPage
|
||||||
authorizedFetch={authorizedFetch}
|
authorizedFetch={authorizedFetch}
|
||||||
knownStores={stores}
|
knownStores={stores}
|
||||||
userLocation={userLocationWithLabel}
|
userLocation={userLocationWithLabel}
|
||||||
locationLoading={preferencesLoading}
|
locationLoading={preferencesLoading}
|
||||||
locationError={preferencesError}
|
locationError={preferencesError}
|
||||||
notificationPanelOpen={notificationPanelOpen}
|
notificationPanelOpen={notificationPanelOpen}
|
||||||
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||||
notificationProps={sharedNotificationProps}
|
notificationProps={sharedNotificationProps}
|
||||||
@@ -791,6 +803,12 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debug"
|
||||||
|
element={
|
||||||
|
session?.isAdmin ? <DebugPage authorizedFetch={authorizedFetch} /> : <AdminAccessMessage />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/admin" element={adminPageContent} />
|
<Route path="/admin" element={adminPageContent} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const DashboardView = ({
|
|||||||
onToggleActive,
|
onToggleActive,
|
||||||
onToggleProfileCheck,
|
onToggleProfileCheck,
|
||||||
onToggleOnlyNotify,
|
onToggleOnlyNotify,
|
||||||
|
onToggleDormantSkip,
|
||||||
onWeekdayChange,
|
onWeekdayChange,
|
||||||
weekdays,
|
weekdays,
|
||||||
onRangePickerRequest,
|
onRangePickerRequest,
|
||||||
@@ -225,7 +226,7 @@ const DashboardView = ({
|
|||||||
id: 'checkProfileId',
|
id: 'checkProfileId',
|
||||||
header: () => <span>Profil prüfen</span>,
|
header: () => <span>Profil prüfen</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-center">
|
<div className="text-center" title={row.original.lastFetchDate ? `Letzte Abholung: ${row.original.lastFetchDate}` : 'Keine Info zur letzten Abholung'}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@@ -237,6 +238,23 @@ const DashboardView = ({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableColumnFilter: false
|
enableColumnFilter: false
|
||||||
}),
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'skipDormantCheck',
|
||||||
|
header: () => <span>Ruhe-Prüfung</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-center" title={row.original.lastFetchDate ? `Letzte Abholung: ${row.original.lastFetchDate}` : 'Keine Info zur letzten Abholung'}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-5 w-5"
|
||||||
|
checked={!!row.original.skipDormantCheck}
|
||||||
|
onChange={() => onToggleDormantSkip(row.original.id)}
|
||||||
|
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableColumnFilter: false
|
||||||
|
}),
|
||||||
columnHelper.accessor((row) => row.onlyNotify, {
|
columnHelper.accessor((row) => row.onlyNotify, {
|
||||||
id: 'onlyNotify',
|
id: 'onlyNotify',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -545,18 +563,30 @@ const DashboardView = ({
|
|||||||
</p>
|
</p>
|
||||||
<p className={`text-xs mt-2 ${statusClass}`}>{statusLabel}</p>
|
<p className={`text-xs mt-2 ${statusClass}`}>{statusLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => onStoreSelect(store)}
|
<label className="flex items-center gap-1 text-sm text-gray-700">
|
||||||
title={blockedByNoPickups ? 'Keine Pickups – automatisch verborgen' : undefined}
|
<input
|
||||||
className="bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 px-3 py-1.5 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
|
type="checkbox"
|
||||||
>
|
className="h-4 w-4"
|
||||||
{isVisible ? 'Zur Liste springen' : 'Hinzufügen'}
|
checked={!!entry?.skipDormantCheck}
|
||||||
</button>
|
onChange={() => onToggleDormantSkip(storeId)}
|
||||||
</div>
|
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren"
|
||||||
);
|
/>
|
||||||
})}
|
<span>Ruhe-Prüfung</span>
|
||||||
</div>
|
</label>
|
||||||
)}
|
<button
|
||||||
|
onClick={() => onStoreSelect(store)}
|
||||||
|
title={blockedByNoPickups ? 'Keine Pickups – automatisch verborgen' : undefined}
|
||||||
|
className="bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 px-3 py-1.5 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
{isVisible ? 'Zur Liste springen' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
441
src/components/DebugPage.js
Normal file
441
src/components/DebugPage.js
Normal file
@@ -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 <span className={`${base} bg-purple-100 text-purple-700`}>Outgoing</span>;
|
||||||
|
}
|
||||||
|
return <span className={`${base} bg-blue-100 text-blue-700`}>Incoming</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <span className={`px-2 py-1 text-xs rounded-full font-semibold ${color}`}>{status ?? '—'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={column.getFilterValue() ?? ''}
|
||||||
|
onChange={(event) => 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 }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-between text-left font-semibold whitespace-nowrap"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<ColumnTextFilter column={column} placeholder={placeholder} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor('timestamp', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Zeitpunkt" placeholder="z. B. 2024" />,
|
||||||
|
cell: ({ getValue }) => formatDateTime(getValue()),
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('direction', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label="Richtung" placeholder="incoming/outgoing" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DirectionBadge value={row.original.direction} />
|
||||||
|
{row.original.target && <span className="text-xs text-gray-500">{row.original.target}</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('method', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Methode" placeholder="GET, POST…" />,
|
||||||
|
cell: ({ getValue }) => <span className="font-mono text-xs uppercase">{getValue()}</span>,
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('path', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Pfad / Ziel" placeholder="/api/…" />,
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
const value = getValue();
|
||||||
|
const href = buildRequestLink(value, row.original.target);
|
||||||
|
if (!href) {
|
||||||
|
return (
|
||||||
|
<span className="max-w-xs truncate block whitespace-nowrap" title={value}>
|
||||||
|
{value || '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className="max-w-xs truncate block whitespace-nowrap text-blue-600 hover:underline"
|
||||||
|
title={href}
|
||||||
|
>
|
||||||
|
{value || href}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('status', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Status" placeholder="200, 500…" />,
|
||||||
|
cell: ({ getValue }) => <StatusBadge status={getValue()} />,
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('durationMs', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Dauer (ms)" placeholder="z. B. >100" />,
|
||||||
|
cell: ({ getValue }) => (getValue() != null ? getValue() : '—'),
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('profileId', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Profil" placeholder="Profil-ID" />,
|
||||||
|
cell: ({ getValue }) => <span className="text-xs text-gray-600">{getValue() || '—'}</span>,
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('sessionId', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Session" placeholder="Session-ID" />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-gray-600 max-w-xs truncate block whitespace-nowrap" title={value || ''}>
|
||||||
|
{value || '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('error', {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="Fehler" placeholder="Fehlermeldung" />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-gray-600 max-w-xs truncate block whitespace-nowrap" title={value || ''}>
|
||||||
|
{value || '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<div className="p-4 max-w-7xl w-full mx-auto bg-white shadow-lg rounded-lg mt-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Request-Debug</h1>
|
||||||
|
<p className="text-gray-500 text-sm">Alle Requests der letzten 14 Tage</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Lade…' : 'Neu laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded border border-red-200 bg-red-50 text-red-700 px-4 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id} className="px-4 py-2 text-left text-gray-600 align-top whitespace-nowrap">
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-4 py-4 text-center text-gray-500">
|
||||||
|
Keine Einträge vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => {
|
||||||
|
const isExpanded = expandedId === row.original.id;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={row.id}>
|
||||||
|
<tr
|
||||||
|
className={`border-b hover:bg-gray-50 cursor-pointer ${isExpanded ? 'bg-blue-50/40' : ''}`}
|
||||||
|
onClick={() => setExpandedId((prev) => (prev === row.original.id ? null : row.original.id))}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-4 py-2 align-top whitespace-nowrap">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b bg-gray-50">
|
||||||
|
<td colSpan={table.getVisibleLeafColumns().length} className="px-4 py-3">
|
||||||
|
<div className="text-sm text-gray-800">
|
||||||
|
<div className="flex flex-wrap gap-4 mb-2">
|
||||||
|
<span className="font-semibold">Details</span>
|
||||||
|
<span>Richtung: {row.original.direction}</span>
|
||||||
|
<span>Methode: {row.original.method}</span>
|
||||||
|
<span>Status: {row.original.status ?? '—'}</span>
|
||||||
|
<span>Dauer: {row.original.durationMs != null ? `${row.original.durationMs} ms` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold mb-1">Response-Body</p>
|
||||||
|
<pre className="bg-white border rounded p-3 text-xs whitespace-pre-wrap max-h-80 overflow-auto">
|
||||||
|
{row.original.responseBody || 'Keine Details erfasst.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-4 text-sm text-gray-700 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Zeilen pro Seite:</span>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
value={pagination.pageSize}
|
||||||
|
onChange={(event) => table.setPageSize(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{[25, 50, 100, 200].map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Seite {table.getState().pagination.pageIndex + 1} von {table.getPageCount() || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DebugPage;
|
||||||
@@ -9,6 +9,7 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
|||||||
{ to: '/store-watch', label: 'Betriebs-Monitoring' }
|
{ to: '/store-watch', label: 'Betriebs-Monitoring' }
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
|
tabs.push({ to: '/debug', label: 'Debug' });
|
||||||
tabs.push({ to: '/admin', label: 'Admin' });
|
tabs.push({ to: '/admin', label: 'Admin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user