frontend_nyla/src/pages/admin/AdminLogsPage.tsx
2026-04-11 19:44:52 +02:00

212 lines
6.8 KiB
TypeScript

/**
* AdminLogsPage
*
* SysAdmin-only page for viewing gateway application logs.
* Color-coded log levels (ERROR, WARNING, INFO, DEBUG).
* Auto-refresh with configurable entry count.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { FaSync, FaDownload } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import logStyles from './AdminLogsPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
const LOG_LEVEL_COLORS: Record<string, string> = {
ERROR: 'var(--log-error, #e53e3e)',
WARNING: 'var(--log-warning, #d69e2e)',
INFO: 'var(--log-info, #3182ce)',
DEBUG: 'var(--log-debug, #718096)',
};
const AUTO_REFRESH_INTERVAL = 5000;
function _parseLogLevel(line: string): string | null {
const match = line.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} - (\w+) -/);
return match ? match[1] : null;
}
export const AdminLogsPage: React.FC = () => {
const { t } = useLanguage();
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [count, setCount] = useState(200);
const [countInput, setCountInput] = useState('200');
const [autoRefresh, setAutoRefresh] = useState(false);
const [logDir, setLogDir] = useState<string>('');
const logContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const _fetchLogs = useCallback(async (entryCount?: number) => {
try {
setLoading(true);
setError(null);
const n = entryCount ?? count;
const response = await api.get(`/api/admin/logs?count=${n}`);
setLines(response.data.lines || []);
setLogDir(response.data.logDir || '');
} catch (err: any) {
setError(err.response?.data?.detail || t('Fehler beim Laden der Logs'));
} finally {
setLoading(false);
}
}, [count, t]);
const _handleLoad = () => {
const parsed = parseInt(countInput, 10);
if (isNaN(parsed) || parsed < 1) return;
setCount(parsed);
_fetchLogs(parsed);
};
const _handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') _handleLoad();
};
const _handleDownload = async () => {
try {
const response = await api.get(`/api/admin/logs/download?count=${count}`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `gateway_log_${new Date().toISOString().replace(/[:.]/g, '-')}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err: any) {
setError(err.response?.data?.detail || t('Fehler beim Download'));
}
};
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => _fetchLogs(), AUTO_REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [autoRefresh, _fetchLogs]);
useEffect(() => {
if (autoScrollRef.current && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [lines]);
const _handleScroll = () => {
if (!logContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 40;
};
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1>
<p className={styles.pageSubtitle}>
{lines.length > 0 ? t('{count} Einträge', { count: lines.length }) : t('Keine Logs geladen')}
{logDir && <span className={logStyles.logDirHint}> {logDir}</span>}
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_handleDownload}
disabled={lines.length === 0}
title={t('Log herunterladen')}
>
<FaDownload /> {t('Download')}
</button>
</div>
</div>
<div className={logStyles.controls}>
<div className={logStyles.loadGroup}>
<label className={logStyles.controlLabel}>{t('Letzte')}</label>
<input
type="number"
className={logStyles.countInput}
value={countInput}
onChange={(e) => setCountInput(e.target.value)}
onKeyDown={_handleKeyDown}
min={1}
max={50000}
/>
<label className={logStyles.controlLabel}>{t('Einträge')}</label>
<button
className={styles.primaryButton}
onClick={_handleLoad}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Laden')}
</button>
</div>
<div className={logStyles.refreshGroup}>
<label className={logStyles.toggleLabel}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
{t('Auto-Refresh (5s)')}
</label>
</div>
</div>
{error && (
<div
className={styles.infoBox}
style={{
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
}}
>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
)}
<div
ref={logContainerRef}
className={logStyles.logContainer}
onScroll={_handleScroll}
>
{lines.length === 0 && !loading && (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<p className={styles.emptyTitle}>{t('Keine Logs geladen')}</p>
<p className={styles.emptyDescription}>{t('Gib die gewünschte Anzahl Einträge ein und klicke auf „Laden“.')}</p>
</div>
)}
{loading && lines.length === 0 && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Logs werden geladen')}</span>
</div>
)}
{lines.map((line, idx) => {
const level = _parseLogLevel(line);
const color = level ? LOG_LEVEL_COLORS[level] : undefined;
return (
<div
key={idx}
className={logStyles.logLine}
style={color ? { color } : undefined}
>
{line}
</div>
);
})}
</div>
</div>
);
};
export default AdminLogsPage;