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

1310 lines
48 KiB
TypeScript

import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } 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 { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { lockScroll, unlockScroll } from '@/components/ui/scrollLock';
import {
IconChevronDown,
IconChevronUp,
IconCode,
IconDownload,
IconEye,
IconEyeOff,
IconMaximize2,
IconMinimize2,
IconRefreshCw,
IconSearch,
IconSlidersHorizontal,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi, type LogsQuery } from '@/services/api/logs';
import { versionApi } from '@/services/api/version';
import { copyToClipboard } from '@/utils/clipboard';
import { getErrorMessage } from '@/utils/helpers';
import { downloadBlob } from '@/utils/download';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import { HTTP_METHODS, STATUS_GROUPS, resolveStatusGroup, type LogState } from './hooks/logTypes';
import { parseLogLine } from './hooks/logParsing';
import { useLogFilters } from './hooks/useLogFilters';
import { isNearBottom, useLogScroller } from './hooks/useLogScroller';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
name: string;
size?: number;
modified?: number;
}
// 初始只渲染最近 100 行,滚动到顶部再逐步加载更多(避免一次性渲染过多导致卡顿)
const INITIAL_DISPLAY_LINES = 100;
const MAX_BUFFER_LINES = 10000;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
type LogPosition = Pick<LogsQuery, 'after' | 'cursor'>;
const getIncrementalAfter = (after: LogsQuery['after']): LogsQuery['after'] => {
if (typeof after !== 'number') return after;
return after > 1 ? after - 1 : undefined;
};
const buildLogsQuery = (incremental: boolean, position: LogPosition): LogsQuery => {
const params: LogsQuery = { limit: MAX_BUFFER_LINES };
if (!incremental) return params;
if (position.cursor) {
params.cursor = position.cursor;
}
const after = getIncrementalAfter(position.after);
if (after !== undefined) {
params.after = after;
}
return params;
};
const findLineOverlap = (currentLines: string[], incomingLines: string[]): number => {
const maxOverlap = Math.min(currentLines.length, incomingLines.length);
for (let size = maxOverlap; size > 0; size -= 1) {
let matched = true;
for (let i = 0; i < size; i += 1) {
if (currentLines[currentLines.length - size + i] !== incomingLines[i]) {
matched = false;
break;
}
}
if (matched) return size;
}
return 0;
};
const mergeIncrementalLines = (currentLines: string[], incomingLines: string[]): string[] => {
if (currentLines.length === 0 || incomingLines.length === 0) {
return [...currentLines, ...incomingLines];
}
const overlap = findLineOverlap(currentLines, incomingLines);
return [...currentLines, ...incomingLines.slice(overlap)];
};
const getErrorPayloadText = (err: unknown): string => {
if (typeof err !== 'object' || err === null) return '';
const payloads = [
(err as { data?: unknown }).data,
(err as { details?: unknown }).details,
].filter((payload) => payload !== undefined);
return payloads
.map((payload) => {
if (typeof payload === 'string') return payload;
try {
return JSON.stringify(payload);
} catch {
return '';
}
})
.join(' ');
};
const isLoggingToFileDisabledError = (err: unknown): boolean => {
const text = `${getErrorMessage(err)} ${getErrorPayloadText(err)}`.toLowerCase();
return text.includes('logging to file disabled');
};
const responseDataToText = async (data: unknown): Promise<string> => {
if (data instanceof Blob) return data.text();
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
if (typeof data === 'string') return data;
if (data === undefined || data === null) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const serverRuntimeKind = useAuthStore((state) => state.serverRuntimeKind);
const updateServerRuntimeKind = useAuthStore((state) => state.updateServerRuntimeKind);
const config = useConfigStore((state) => state.config);
const requestLogEnabled = config?.requestLog ?? false;
const loggingToFileEnabled = config?.loggingToFile ?? false;
const cpaNeedsFileLogging = serverRuntimeKind === 'cpa' && !loggingToFileEnabled;
const isHomeRuntime = serverRuntimeKind === 'home';
const [fileLoggingRequired, setFileLoggingRequired] = useState(false);
const showFileLoggingRequired = cpaNeedsFileLogging || fileLoggingRequired;
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] = useLocalStorage('logsPage.autoRefresh', false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useLocalStorage(
'logsPage.hideManagementLogs',
true
);
const [showRawLogs, setShowRawLogs] = useLocalStorage('logsPage.showRawLogs', false);
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
'logsPage.structuredFiltersExpanded',
true
);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const [selectedErrorLog, setSelectedErrorLog] = useState<ErrorLogItem | null>(null);
const [selectedErrorLogText, setSelectedErrorLogText] = useState('');
const [selectedErrorLogError, setSelectedErrorLogError] = useState('');
const [selectedErrorLogLoading, setSelectedErrorLogLoading] = useState(false);
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const [fullscreenLogs, setFullscreenLogs] = useState(false);
const logScrollerRef = useRef<ReturnType<typeof useLogScroller> | null>(null);
const requestLogHomeIpByIdRef = useRef<Record<string, string>>({});
const errorLogViewRequestRef = useRef(0);
const longPressRef = useRef<{
timer: number | null;
startX: number;
startY: number;
fired: boolean;
} | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新游标用于增量获取;新 CPA 后端优先使用 cursor,旧接口和 Home 继续使用 after。
const logPositionRef = useRef<LogPosition>({});
const resetLogPosition = () => {
logPositionRef.current = {};
};
const updateLogPosition = (
data: Awaited<ReturnType<typeof logsApi.fetchLogs>>,
incremental: boolean
) => {
const currentPosition = logPositionRef.current;
const nextPosition: LogPosition = {};
if (data.nextCursor) {
nextPosition.cursor = data.nextCursor;
}
if (data.latestAfter !== undefined) {
nextPosition.after = data.latestAfter;
} else if (incremental && currentPosition.after !== undefined) {
nextPosition.after = currentPosition.after;
}
logPositionRef.current = nextPosition;
};
const disableControls = connectionStatus !== 'connected';
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
const autoRefreshDisabled = disableControls || showFileLoggingRequired;
const clearDisabled = disableControls || showFileLoggingRequired || isHomeRuntime;
const loadLogs = async (incremental = false) => {
if (connectionStatus !== 'connected') {
setLoading(false);
return;
}
if (cpaNeedsFileLogging) {
if (!incremental) {
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
setLoading(false);
}
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
}
return;
}
logRequestInFlightRef.current = true;
if (!incremental) {
setLoading(true);
}
setError('');
try {
const scrollerInstance = logScrollerRef.current;
const stickToBottom =
!incremental || isNearBottom(scrollerInstance?.logViewerRef.current ?? null);
if (stickToBottom) {
scrollerInstance?.requestScrollToBottom();
}
const params = buildLogsQuery(incremental, logPositionRef.current);
const data = await logsApi.fetchLogs(params);
setFileLoggingRequired(false);
updateLogPosition(data, incremental);
if (data.requestLogHomeIpById) {
requestLogHomeIpByIdRef.current = incremental
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
: data.requestLogHomeIpById;
} else if (!incremental) {
requestLogHomeIpByIdRef.current = {};
}
const newLines = Array.isArray(data.lines) ? data.lines : [];
if (incremental && data.cursorReset) {
const buffer = newLines.slice(-MAX_BUFFER_LINES);
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom });
} else if (incremental && newLines.length > 0) {
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
setLogState((prev) => {
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
const combined = mergeIncrementalLines(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 (stickToBottom) {
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 (isLoggingToFileDisabledError(err)) {
if (!incremental) {
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(true);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
}
return;
}
if (!incremental) {
setError(getErrorMessage(err) || t('logs.load_error'));
}
} finally {
if (!incremental) {
setLoading(false);
}
logRequestInFlightRef.current = false;
if (pendingFullReloadRef.current) {
pendingFullReloadRef.current = false;
void loadLogs(false);
}
}
};
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (isHomeRuntime) {
showNotification(t('logs.home_clear_unavailable'), 'warning');
return;
}
if (cpaNeedsFileLogging) {
showNotification(t('logs.cpa_file_logging_required'), 'warning');
return;
}
if (fileLoggingRequired) {
showNotification(t('logs.file_logging_required'), 'warning');
return;
}
showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
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');
downloadBlob({ filename: 'logs.txt', blob: new Blob([text], { type: 'text/plain' }) });
showNotification(t('logs.download_success'), 'success');
};
const loadErrorLogs = async () => {
if (connectionStatus !== 'connected') {
setLoadingErrors(false);
return;
}
if (isHomeRuntime) {
setLoadingErrors(false);
setErrorLogs([]);
setErrorLogsError('');
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);
downloadBlob({ filename: name, blob: new Blob([response.data], { type: 'text/plain' }) });
showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
const openErrorLog = async (item: ErrorLogItem) => {
const requestId = errorLogViewRequestRef.current + 1;
errorLogViewRequestRef.current = requestId;
setSelectedErrorLog(item);
setSelectedErrorLogText('');
setSelectedErrorLogError('');
setSelectedErrorLogLoading(true);
try {
const response = await logsApi.downloadErrorLog(item.name);
const text = await responseDataToText(response.data);
if (errorLogViewRequestRef.current !== requestId) return;
setSelectedErrorLogText(text);
} catch (err: unknown) {
if (errorLogViewRequestRef.current !== requestId) return;
const message = getErrorMessage(err);
setSelectedErrorLogError(
message ? `${t('logs.error_log_open_failed')}: ${message}` : t('logs.error_log_open_failed')
);
} finally {
if (errorLogViewRequestRef.current === requestId) {
setSelectedErrorLogLoading(false);
}
}
};
const closeErrorLogViewer = () => {
errorLogViewRequestRef.current += 1;
setSelectedErrorLog(null);
setSelectedErrorLogText('');
setSelectedErrorLogError('');
setSelectedErrorLogLoading(false);
};
const copySelectedErrorLog = async () => {
const ok = await copyToClipboard(selectedErrorLogText);
showNotification(
ok
? t('logs.error_log_copy_success')
: t('logs.copy_failed', { defaultValue: 'Copy failed' }),
ok ? 'success' : 'error'
);
};
useEffect(() => {
if (connectionStatus === 'connected') {
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
loadLogs(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus, loggingToFileEnabled]);
useEffect(() => {
if (connectionStatus !== 'connected' || serverRuntimeKind !== 'unknown') return;
let cancelled = false;
const detectRuntime = async () => {
const runtimeKind = await versionApi.detectRuntimeKind();
if (!cancelled && (runtimeKind === 'cpa' || runtimeKind === 'home')) {
updateServerRuntimeKind(runtimeKind);
}
};
void detectRuntime();
return () => {
cancelled = true;
};
}, [connectionStatus, serverRuntimeKind, updateServerRuntimeKind]);
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' || showFileLoggingRequired) {
return;
}
const id = window.setInterval(() => {
loadLogs(true);
}, 8000);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, connectionStatus, showFileLoggingRequired]);
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 parsedSearchLines = useMemo(() => {
let working = baseLines;
if (hideManagementLogs) {
working = working.filter((line) => !line.includes(MANAGEMENT_API_PREFIX));
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
working = working.filter((line) => line.toLowerCase().includes(queryLowered));
}
return working.map((line) => parseLogLine(line));
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const filters = useLogFilters({ parsedLines: parsedSearchLines });
const structuredFiltersPanelId = 'logs-structured-filters';
const structuredFilterCount =
filters.methodFilters.length + filters.statusFilters.length + filters.pathFilters.length;
const { filteredParsedLines, filteredLines, removedCount } = useMemo(() => {
const filteredParsed = parsedSearchLines.filter((line) => {
if (
filters.methodFilterSet.size > 0 &&
(!line.method || !filters.methodFilterSet.has(line.method))
) {
return false;
}
const statusGroup = resolveStatusGroup(line.statusCode);
if (
filters.statusFilterSet.size > 0 &&
(!statusGroup || !filters.statusFilterSet.has(statusGroup))
) {
return false;
}
if (filters.pathFilterSet.size > 0 && (!line.path || !filters.pathFilterSet.has(line.path))) {
return false;
}
return true;
});
return {
filteredParsedLines: filteredParsed,
filteredLines: filteredParsed.map((line) => line.raw),
removedCount: Math.max(baseLines.length - filteredParsed.length, 0),
};
}, [
baseLines,
filters.methodFilterSet,
filters.pathFilterSet,
filters.statusFilterSet,
parsedSearchLines,
]);
const parsedVisibleLines = useMemo(
() => (showRawLogs ? [] : filteredParsedLines),
[filteredParsedLines, showRawLogs]
);
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
const scroller = useLogScroller({
logState,
setLogState,
loading,
isSearching,
filteredLineCount: filteredLines.length,
hasStructuredFilters: filters.hasStructuredFilters,
showRawLogs,
});
logScrollerRef.current = scroller;
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');
}
};
const clearLongPressTimer = () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
if (!requestLogEnabled) return;
if (!id) return;
if (requestLogId) return;
clearLongPressTimer();
longPressRef.current = {
timer: window.setTimeout(() => {
setRequestLogId(id);
if (longPressRef.current) {
longPressRef.current.fired = true;
longPressRef.current.timer = null;
}
}, LONG_PRESS_MS),
startX: event.clientX,
startY: event.clientY,
fired: false,
};
};
const cancelLongPress = () => {
clearLongPressTimer();
longPressRef.current = null;
};
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const current = longPressRef.current;
if (!current || current.timer === null || current.fired) return;
const deltaX = Math.abs(event.clientX - current.startX);
const deltaY = Math.abs(event.clientY - current.startY);
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
cancelLongPress();
}
};
const closeRequestLogModal = () => {
if (requestLogDownloading) return;
setRequestLogId(null);
};
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(
id,
requestLogHomeIpByIdRef.current[id]
);
downloadBlob({
filename: `request-${id}.log`,
blob: new Blob([response.data], { type: 'text/plain' }),
});
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogDownloading(false);
}
};
useEffect(() => {
return () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
}, []);
useEffect(() => {
if (!fullscreenLogs) return;
document.body.classList.add('logs-fullscreen-active');
lockScroll();
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (document.querySelector('.modal-overlay')) return;
setFullscreenLogs(false);
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.classList.remove('logs-fullscreen-active');
unlockScroll();
};
}, [fullscreenLogs]);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.runtimeNotice}>{t(`logs.runtime_${serverRuntimeKind}`)}</div>
</div>
<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={() => {
setFullscreenLogs(false);
setActiveTab('errors');
}}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}>
{activeTab === 'logs' && (
<Card
className={[styles.logCard, fullscreenLogs ? styles.logCardFullscreen : '']
.filter(Boolean)
.join(' ')}
>
{showFileLoggingRequired && (
<div className="status-badge warning">
{t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required'
: 'logs.file_logging_required'
)}
</div>
)}
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
{!fullscreenLogs && (
<>
<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>
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
</Button>
</div>
</>
)}
{!fullscreenLogs && structuredFiltersExpanded && (
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
<div className={styles.filterChipList}>
{HTTP_METHODS.map((method) => {
const active = filters.methodFilters.includes(method);
const count = filters.methodCounts[method] ?? 0;
return (
<button
key={method}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleMethodFilter(method)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{method} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
<div className={styles.filterChipList}>
{STATUS_GROUPS.map((statusGroup) => {
const active = filters.statusFilters.includes(statusGroup);
const count = filters.statusCounts[statusGroup] ?? 0;
return (
<button
key={statusGroup}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleStatusFilter(statusGroup)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{t(`logs.filter_status_${statusGroup}`)} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
<div className={styles.filterChipList}>
{filters.pathOptions.length === 0 ? (
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
) : (
filters.pathOptions.map(({ path, count }) => {
const active = filters.pathFilters.includes(path);
return (
<button
key={path}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.togglePathFilter(path)}
aria-pressed={active}
title={path}
>
{path} ({count})
</button>
);
})
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={filters.clearStructuredFilters}
disabled={!filters.hasStructuredFilters}
>
{t('logs.clear_filters')}
</Button>
</div>
)}
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<ToggleSwitch
checked={showRawLogs}
onChange={setShowRawLogs}
label={
<span
className={styles.switchLabel}
title={t('logs.show_raw_logs_hint', {
defaultValue: 'Show original log text for easier multi-line copy',
})}
>
<IconCode size={16} />
{t('logs.show_raw_logs', { defaultValue: 'Show raw logs' })}
</span>
}
/>
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={refreshDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={autoRefreshDisabled}
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={clearDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setFullscreenLogs((prev) => !prev)}
className={styles.actionButton}
aria-pressed={fullscreenLogs}
title={
fullscreenLogs ? t('logs.exit_fullscreen_button') : t('logs.fullscreen_button')
}
>
<span className={styles.buttonContent}>
{fullscreenLogs ? <IconMinimize2 size={16} /> : <IconMaximize2 size={16} />}
{fullscreenLogs
? t('logs.exit_fullscreen_button')
: t('logs.fullscreen_button')}
</span>
</Button>
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && filteredLines.length > 0 ? (
<div
ref={scroller.logViewerRef}
className={[styles.logPanel, fullscreenLogs ? styles.logPanelFullscreen : '']
.filter(Boolean)
.join(' ')}
onScroll={scroller.handleLogScroll}
>
{scroller.canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>{t('logs.loaded_lines', { count: filteredLines.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>
)}
{showRawLogs ? (
<pre className={styles.rawLog} spellCheck={false}>
{rawVisibleText}
</pre>
) : (
<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);
}}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
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')}
/>
) : showFileLoggingRequired ? (
<EmptyState
title={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_title'
: 'logs.file_logging_required_title'
)}
description={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_desc'
: 'logs.file_logging_required_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>
{isHomeRuntime && (
<div className="status-badge warning">{t('logs.error_logs_home_unavailable')}</div>
)}
{requestLogEnabled && !isHomeRuntime && (
<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={() => {
void openErrorLog(item);
}}
disabled={disableControls}
>
<span className={styles.buttonContent}>
<IconEye size={16} />
{t('logs.error_logs_open')}
</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
disabled={disableControls}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Card>
)}
</div>
<Modal
open={Boolean(selectedErrorLog)}
onClose={closeErrorLogViewer}
title={selectedErrorLog?.name ?? t('logs.error_log_view_title')}
width={960}
footer={
<>
<Button variant="secondary" onClick={closeErrorLogViewer}>
{t('common.close')}
</Button>
<Button
variant="secondary"
onClick={() => {
void copySelectedErrorLog();
}}
disabled={!selectedErrorLogText || selectedErrorLogLoading}
>
{t('common.copy')}
</Button>
<Button
onClick={() => {
if (selectedErrorLog) {
void downloadErrorLog(selectedErrorLog.name);
}
}}
disabled={!selectedErrorLog || selectedErrorLogLoading}
>
{t('logs.error_logs_download')}
</Button>
</>
}
>
<div className={styles.errorLogViewer}>
{selectedErrorLog && (
<div className={styles.errorLogViewerMeta}>
<span>
{t('logs.error_logs_size')}:{' '}
{selectedErrorLog.size ? `${(selectedErrorLog.size / 1024).toFixed(1)} KB` : '-'}
</span>
<span>
{t('logs.error_logs_modified')}:{' '}
{selectedErrorLog.modified ? formatUnixTimestamp(selectedErrorLog.modified) : '-'}
</span>
</div>
)}
{selectedErrorLogError && <div className="error-box">{selectedErrorLogError}</div>}
{selectedErrorLogLoading ? (
<div className="hint">{t('common.loading')}</div>
) : selectedErrorLogText ? (
<pre className={styles.errorLogContent} spellCheck={false}>
{selectedErrorLogText}
</pre>
) : !selectedErrorLogError ? (
<div className="hint">{t('logs.error_log_empty_content')}</div>
) : null}
</div>
</Modal>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
title={t('logs.request_log_download_title')}
footer={
<>
<Button
variant="secondary"
onClick={closeRequestLogModal}
disabled={requestLogDownloading}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
if (requestLogId) {
void downloadRequestLog(requestLogId);
}
}}
loading={requestLogDownloading}
disabled={!requestLogId}
>
{t('common.confirm')}
</Button>
</>
}
>
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
</Modal>
</div>
);
}