Files
Cli-Proxy-API-Management-Ce…/src/pages/LogsPage.tsx

952 lines
33 KiB
TypeScript

import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconDownload,
IconEyeOff,
IconRefreshCw,
IconSearch,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
name: string;
size?: number;
modified?: number;
}
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
type LogState = {
buffer: string[];
visibleFrom: number;
};
// 初始只渲染最近 100 行,滚动到顶部再逐步加载更多(避免一次性渲染过多导致卡顿)
const INITIAL_DISPLAY_LINES = 100;
const LOAD_MORE_LINES = 200;
const MAX_BUFFER_LINES = 10000;
const LOAD_MORE_THRESHOLD_PX = 72;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/,
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
];
const detectHttpStatusCode = (text: string): number | undefined => {
for (const pattern of HTTP_STATUS_PATTERNS) {
const match = text.match(pattern);
if (!match) continue;
const code = Number.parseInt(match[1], 10);
if (!Number.isFinite(code)) continue;
if (code >= 100 && code <= 599) return code;
}
return undefined;
};
const extractIp = (text: string): string | undefined => {
const ipv4Match = text.match(LOG_IPV4_REGEX);
if (ipv4Match) return ipv4Match[0];
const ipv6Match = text.match(LOG_IPV6_REGEX);
if (!ipv6Match) return undefined;
const candidate = ipv6Match[0];
// Avoid treating time strings like "12:34:56" as IPv6 addresses.
if (LOG_TIME_OF_DAY_REGEX.test(candidate)) return undefined;
// If no compression marker is present, a valid IPv6 address must contain 8 hextets.
if (!candidate.includes('::') && candidate.split(':').length !== 8) return undefined;
return candidate;
};
const normalizeTimestampToSeconds = (value: string): string => {
const trimmed = value.trim();
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (!match) return trimmed;
return `${match[1]} ${match[2]}`;
};
type ParsedLogLine = {
raw: string;
timestamp?: string;
level?: LogLevel;
source?: string;
requestId?: string;
statusCode?: number;
latency?: string;
ip?: string;
method?: HttpMethod;
path?: string;
message: string;
};
const extractLogLevel = (value: string): LogLevel | undefined => {
const normalized = value.trim().toLowerCase();
if (normalized === 'warning') return 'warn';
if (normalized === 'warn') return 'warn';
if (normalized === 'info') return 'info';
if (normalized === 'error') return 'error';
if (normalized === 'fatal') return 'fatal';
if (normalized === 'debug') return 'debug';
if (normalized === 'trace') return 'trace';
return undefined;
};
const inferLogLevel = (line: string): LogLevel | undefined => {
const lowered = line.toLowerCase();
if (/\bfatal\b/.test(lowered)) return 'fatal';
if (/\berror\b/.test(lowered)) return 'error';
if (/\bwarn(?:ing)?\b/.test(lowered) || line.includes('警告')) return 'warn';
if (/\binfo\b/.test(lowered)) return 'info';
if (/\bdebug\b/.test(lowered)) return 'debug';
if (/\btrace\b/.test(lowered)) return 'trace';
return undefined;
};
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
const match = text.match(HTTP_METHOD_REGEX);
if (!match) return {};
const method = match[1] as HttpMethod;
const index = match.index ?? 0;
const after = text.slice(index + match[0].length).trim();
const path = after ? after.split(/\s+/)[0] : undefined;
return { method, path };
};
const parseLogLine = (raw: string): ParsedLogLine => {
let remaining = raw.trim();
let timestamp: string | undefined;
const tsMatch = remaining.match(LOG_TIMESTAMP_REGEX);
if (tsMatch) {
timestamp = tsMatch[1];
remaining = remaining.slice(tsMatch[0].length).trim();
}
let requestId: string | undefined;
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
if (requestIdMatch) {
const id = requestIdMatch[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
remaining = remaining.slice(requestIdMatch[0].length).trim();
}
let level: LogLevel | undefined;
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
if (lvlMatch) {
level = extractLogLevel(lvlMatch[1]);
remaining = remaining.slice(lvlMatch[0].length).trim();
}
let source: string | undefined;
const sourceMatch = remaining.match(LOG_SOURCE_REGEX);
if (sourceMatch) {
source = sourceMatch[1];
remaining = remaining.slice(sourceMatch[0].length).trim();
}
let statusCode: number | undefined;
let latency: string | undefined;
let ip: string | undefined;
let method: HttpMethod | undefined;
let path: string | undefined;
let message = remaining;
if (remaining.includes('|')) {
const segments = remaining
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
const consumed = new Set<number>();
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
if (ginIndex >= 0) {
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
if (!timestamp) {
timestamp = ginTimestamp;
consumed.add(ginIndex);
} else if (normalizedParsed === normalizedGin) {
consumed.add(ginIndex);
}
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
// status code
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
if (statusIndex >= 0) {
const match = segments[statusIndex].match(/^(\d{3})$/);
if (match) {
const code = Number.parseInt(match[1], 10);
if (code >= 100 && code <= 599) {
statusCode = code;
consumed.add(statusIndex);
}
}
}
// latency
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
if (latencyIndex >= 0) {
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
if (match) {
latency = `${match[1]}${match[2]}`;
consumed.add(latencyIndex);
}
}
// ip
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
if (ipIndex >= 0) {
const extracted = extractIp(segments[ipIndex]);
if (extracted) {
ip = extracted;
consumed.add(ipIndex);
}
}
// method + path
const methodIndex = segments.findIndex((segment) => {
const { method: parsedMethod } = extractHttpMethodAndPath(segment);
return Boolean(parsedMethod);
});
if (methodIndex >= 0) {
const parsed = extractHttpMethodAndPath(segments[methodIndex]);
method = parsed.method;
path = parsed.path;
consumed.add(methodIndex);
}
// source (e.g. [gin_logger.go:94])
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
if (sourceIndex >= 0) {
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
if (match) {
source = match[1];
consumed.add(sourceIndex);
}
}
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
} else {
statusCode = detectHttpStatusCode(remaining);
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
ip = extractIp(remaining);
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
path = parsed.path;
}
if (!level) level = inferLogLevel(raw);
if (message) {
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
if (!timestamp) timestamp = ginTimestamp;
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
message = '';
}
}
}
return {
raw,
timestamp,
level,
source,
requestId,
statusCode,
latency,
ip,
method,
path,
message,
};
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
const disableControls = connectionStatus !== 'connected';
const isNearBottom = (node: HTMLDivElement | null) => {
if (!node) return true;
const threshold = 24;
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
};
const scrollToBottom = () => {
const node = logViewerRef.current;
if (!node) return;
node.scrollTop = node.scrollHeight;
};
const loadLogs = async (incremental = false) => {
if (connectionStatus !== 'connected') {
setLoading(false);
return;
}
if (!incremental) {
setLoading(true);
}
setError('');
try {
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
const params =
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
const data = await logsApi.fetchLogs(params);
// 更新时间戳
if (data['latest-timestamp']) {
latestTimestampRef.current = data['latest-timestamp'];
}
const newLines = Array.isArray(data.lines) ? data.lines : [];
if (incremental && newLines.length > 0) {
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
setLogState((prev) => {
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
const combined = [...prev.buffer, ...newLines];
const dropCount = Math.max(combined.length - MAX_BUFFER_LINES, 0);
const buffer = dropCount > 0 ? combined.slice(dropCount) : combined;
let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0);
// 若用户停留在底部(跟随最新日志),则保持“渲染窗口”大小不变,避免无限增长
if (pendingScrollToBottomRef.current) {
visibleFrom = Math.max(buffer.length - prevRenderedCount, 0);
}
return { buffer, visibleFrom };
});
} else if (!incremental) {
// 全量加载:默认只渲染最后 100 行,向上滚动再展开更多
const buffer = newLines.slice(-MAX_BUFFER_LINES);
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom });
}
} catch (err: unknown) {
console.error('Failed to load logs:', err);
if (!incremental) {
setError(getErrorMessage(err) || t('logs.load_error'));
}
} finally {
if (!incremental) {
setLoading(false);
}
}
};
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
const downloadLogs = () => {
const text = logState.buffer.join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'logs.txt';
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.download_success'), 'success');
};
const loadErrorLogs = async () => {
if (connectionStatus !== 'connected') {
setLoadingErrors(false);
return;
}
setLoadingErrors(true);
setErrorLogsError('');
try {
const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] }
setErrorLogs(Array.isArray(res.files) ? res.files : []);
} catch (err: unknown) {
console.error('Failed to load error logs:', err);
setErrorLogs([]);
const message = getErrorMessage(err);
setErrorLogsError(
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
);
} finally {
setLoadingErrors(false);
}
};
const downloadErrorLog = async (name: string) => {
try {
const response = await logsApi.downloadErrorLog(name);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
useEffect(() => {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
loadLogs(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
useEffect(() => {
if (activeTab !== 'errors') return;
if (connectionStatus !== 'connected') return;
void loadErrorLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, connectionStatus, requestLogEnabled]);
useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') {
return;
}
const id = window.setInterval(() => {
loadLogs(true);
}, 8000);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, connectionStatus]);
useEffect(() => {
if (!pendingScrollToBottomRef.current) return;
if (loading) return;
if (!logViewerRef.current) return;
scrollToBottom();
pendingScrollToBottomRef.current = false;
}, [loading, logState.buffer, logState.visibleFrom]);
const visibleLines = useMemo(
() => logState.buffer.slice(logState.visibleFrom),
[logState.buffer, logState.visibleFrom]
);
const trimmedSearchQuery = deferredSearchQuery.trim();
const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => {
let working = baseLines;
let removed = 0;
if (hideManagementLogs) {
const next: string[] = [];
for (const line of working) {
if (line.includes(MANAGEMENT_API_PREFIX)) {
removed += 1;
} else {
next.push(line);
}
}
working = next;
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
const next: string[] = [];
for (const line of working) {
if (line.toLowerCase().includes(queryLowered)) {
next.push(line);
} else {
removed += 1;
}
}
working = next;
}
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo(
() => filteredLines.map((line) => parseLogLine(line)),
[filteredLines]
);
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const handleLogScroll = () => {
const node = logViewerRef.current;
if (!node) return;
if (isSearching) return;
if (!canLoadMore) return;
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
}));
};
useLayoutEffect(() => {
const node = logViewerRef.current;
const pending = pendingPrependScrollRef.current;
if (!node || !pending) return;
const delta = node.scrollHeight - pending.scrollHeight;
node.scrollTop = pending.scrollTop + delta;
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {
showNotification(t('logs.copy_success', { defaultValue: 'Copied to clipboard' }), 'success');
} else {
showNotification(t('logs.copy_failed', { defaultValue: 'Copy failed' }), 'error');
}
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
</span>
)}
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
</div>
</div>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</div>
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
)}
{activeTab === 'errors' && (
<Card
extra={
<Button
variant="secondary"
size="sm"
onClick={loadErrorLogs}
loading={loadingErrors}
disabled={disableControls}
>
{t('common.refresh')}
</Button>
}
>
<div className="stack">
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
</div>
)}
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
<div className={styles.errorPanel}>
{loadingErrors ? (
<div className="hint">{t('common.loading')}</div>
) : errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
disabled={disableControls}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Card>
)}
</div>
</div>
);
}