diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index d19f930..9b4f4c3 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -34,6 +34,16 @@ import { normalizeAuthIndex, type UsageDetailWithEndpoint } from '@/utils/usage'; +import { + HTTP_METHODS, + STATUS_GROUPS, + resolveStatusGroup, + type HttpMethod, + type LogLevel, + type LogState, + type ParsedLogLine, +} from './hooks/logTypes'; +import { useLogFilters } from './hooks/useLogFilters'; import styles from './LogsPage.module.scss'; interface ErrorLogItem { @@ -42,13 +52,6 @@ interface ErrorLogItem { 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; @@ -57,12 +60,8 @@ const LOAD_MORE_THRESHOLD_PX = 72; const LONG_PRESS_MS = 650; const LONG_PRESS_MOVE_THRESHOLD = 10; -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 STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const; -type StatusGroup = (typeof STATUS_GROUPS)[number]; -const PATH_FILTER_LIMIT = 12; 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; @@ -95,15 +94,6 @@ const detectHttpStatusCode = (text: string): number | undefined => { return undefined; }; -const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => { - if (typeof statusCode !== 'number') return undefined; - if (statusCode >= 200 && statusCode < 300) return '2xx'; - if (statusCode >= 300 && statusCode < 400) return '3xx'; - if (statusCode >= 400 && statusCode < 500) return '4xx'; - if (statusCode >= 500 && statusCode < 600) return '5xx'; - return undefined; -}; - const extractIp = (text: string): string | undefined => { const ipv4Match = text.match(LOG_IPV4_REGEX); if (ipv4Match) return ipv4Match[0]; @@ -135,20 +125,6 @@ const extractLatency = (text: string): string | undefined => { return match[0].replace(/\s+/g, ''); }; -type ParsedLogLine = { - raw: string; - timestamp?: string; - level?: LogLevel; - source?: string; - requestId?: string; - statusCode?: number; - latency?: string; - ip?: string; - method?: HttpMethod; - path?: string; - message: string; -}; - type TraceConfidence = 'high' | 'medium' | 'low'; type TraceCandidate = { @@ -486,9 +462,6 @@ export function LogsPage() { const deferredSearchQuery = useDeferredValue(searchQuery); const [hideManagementLogs, setHideManagementLogs] = useState(true); const [showRawLogs, setShowRawLogs] = useState(false); - const [methodFilters, setMethodFilters] = useState([]); - const [statusFilters, setStatusFilters] = useState([]); - const [pathFilters, setPathFilters] = useState([]); const [errorLogs, setErrorLogs] = useState([]); const [loadingErrors, setLoadingErrors] = useState(false); const [errorLogsError, setErrorLogsError] = useState(''); @@ -785,17 +758,7 @@ export function LogsPage() { const isSearching = trimmedSearchQuery.length > 0; const baseLines = isSearching ? logState.buffer : visibleLines; - const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]); - const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]); - const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]); - const hasStructuredFilters = methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0; - - const { - parsedSearchLines, - filteredParsedLines, - filteredLines, - removedCount, - } = useMemo(() => { + const parsedSearchLines = useMemo(() => { let working = baseLines; if (hideManagementLogs) { @@ -807,18 +770,29 @@ export function LogsPage() { working = working.filter((line) => line.toLowerCase().includes(queryLowered)); } - const parsed = working.map((line) => parseLogLine(line)); - const filteredParsed = parsed.filter((line) => { - if (methodFilterSet.size > 0 && (!line.method || !methodFilterSet.has(line.method))) { + return working.map((line) => parseLogLine(line)); + }, [baseLines, hideManagementLogs, trimmedSearchQuery]); + + const filters = useLogFilters({ parsedLines: parsedSearchLines }); + + 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 (statusFilterSet.size > 0 && (!statusGroup || !statusFilterSet.has(statusGroup))) { + if ( + filters.statusFilterSet.size > 0 && + (!statusGroup || !filters.statusFilterSet.has(statusGroup)) + ) { return false; } - if (pathFilterSet.size > 0 && (!line.path || !pathFilterSet.has(line.path))) { + if (filters.pathFilterSet.size > 0 && (!line.path || !filters.pathFilterSet.has(line.path))) { return false; } @@ -826,18 +800,16 @@ export function LogsPage() { }); return { - parsedSearchLines: parsed, filteredParsedLines: filteredParsed, filteredLines: filteredParsed.map((line) => line.raw), removedCount: Math.max(baseLines.length - filteredParsed.length, 0) }; }, [ baseLines, - hideManagementLogs, - methodFilterSet, - pathFilterSet, - statusFilterSet, - trimmedSearchQuery + filters.methodFilterSet, + filters.pathFilterSet, + filters.statusFilterSet, + parsedSearchLines ]); const parsedVisibleLines = useMemo( @@ -867,70 +839,6 @@ export function LogsPage() { const canLoadMore = !isSearching && logState.visibleFrom > 0; - const methodCounts = useMemo(() => { - const counts: Partial> = {}; - parsedSearchLines.forEach((line) => { - if (!line.method) return; - counts[line.method] = (counts[line.method] ?? 0) + 1; - }); - return counts; - }, [parsedSearchLines]); - - const statusCounts = useMemo(() => { - const counts: Partial> = {}; - parsedSearchLines.forEach((line) => { - const statusGroup = resolveStatusGroup(line.statusCode); - if (!statusGroup) return; - counts[statusGroup] = (counts[statusGroup] ?? 0) + 1; - }); - return counts; - }, [parsedSearchLines]); - - const pathOptions = useMemo(() => { - const counts = new Map(); - parsedSearchLines.forEach((line) => { - if (!line.path) return; - counts.set(line.path, (counts.get(line.path) ?? 0) + 1); - }); - return Array.from(counts.entries()) - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, PATH_FILTER_LIMIT) - .map(([path, count]) => ({ path, count })); - }, [parsedSearchLines]); - - useEffect(() => { - const validPathSet = new Set(pathOptions.map((item) => item.path)); - setPathFilters((prev) => { - if (prev.length === 0) return prev; - const next = prev.filter((path) => validPathSet.has(path)); - return next.length === prev.length ? prev : next; - }); - }, [pathOptions]); - - const toggleMethodFilter = (method: HttpMethod) => { - setMethodFilters((prev) => - prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method] - ); - }; - - const toggleStatusFilter = (statusGroup: StatusGroup) => { - setStatusFilters((prev) => - prev.includes(statusGroup) ? prev.filter((item) => item !== statusGroup) : [...prev, statusGroup] - ); - }; - - const togglePathFilter = (path: string) => { - setPathFilters((prev) => - prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path] - ); - }; - - const clearStructuredFilters = () => { - setMethodFilters([]); - setStatusFilters([]); - setPathFilters([]); - }; - const prependVisibleLines = useCallback(() => { const node = logViewerRef.current; if (!node) return; @@ -1176,14 +1084,14 @@ export function LogsPage() { {t('logs.filter_method')}
{HTTP_METHODS.map((method) => { - const active = methodFilters.includes(method); - const count = methodCounts[method] ?? 0; + const active = filters.methodFilters.includes(method); + const count = filters.methodCounts[method] ?? 0; return ( diff --git a/src/pages/hooks/logTypes.ts b/src/pages/hooks/logTypes.ts new file mode 100644 index 0000000..a6066fa --- /dev/null +++ b/src/pages/hooks/logTypes.ts @@ -0,0 +1,35 @@ +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const; +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +export const STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const; +export type StatusGroup = (typeof STATUS_GROUPS)[number]; + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +export type LogState = { + buffer: string[]; + visibleFrom: number; +}; + +export type ParsedLogLine = { + raw: string; + timestamp?: string; + level?: LogLevel; + source?: string; + requestId?: string; + statusCode?: number; + latency?: string; + ip?: string; + method?: HttpMethod; + path?: string; + message: string; +}; + +export const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => { + if (typeof statusCode !== 'number') return undefined; + if (statusCode >= 200 && statusCode < 300) return '2xx'; + if (statusCode >= 300 && statusCode < 400) return '3xx'; + if (statusCode >= 400 && statusCode < 500) return '4xx'; + if (statusCode >= 500 && statusCode < 600) return '5xx'; + return undefined; +}; diff --git a/src/pages/hooks/useLogFilters.ts b/src/pages/hooks/useLogFilters.ts new file mode 100644 index 0000000..d6d0aaa --- /dev/null +++ b/src/pages/hooks/useLogFilters.ts @@ -0,0 +1,122 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { HttpMethod, ParsedLogLine, StatusGroup } from './logTypes'; +import { resolveStatusGroup } from './logTypes'; + +const PATH_FILTER_LIMIT = 12; + +interface UseLogFiltersOptions { + parsedLines: ParsedLogLine[]; +} + +interface UseLogFiltersReturn { + methodFilters: HttpMethod[]; + statusFilters: StatusGroup[]; + pathFilters: string[]; + methodFilterSet: Set; + statusFilterSet: Set; + pathFilterSet: Set; + hasStructuredFilters: boolean; + methodCounts: Partial>; + statusCounts: Partial>; + pathOptions: Array<{ path: string; count: number }>; + toggleMethodFilter: (method: HttpMethod) => void; + toggleStatusFilter: (group: StatusGroup) => void; + togglePathFilter: (path: string) => void; + clearStructuredFilters: () => void; +} + +export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersReturn { + const { parsedLines } = options; + + const [methodFilters, setMethodFilters] = useState([]); + const [statusFilters, setStatusFilters] = useState([]); + const [pathFilters, setPathFilters] = useState([]); + + const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]); + const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]); + const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]); + const hasStructuredFilters = + methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0; + + const methodCounts = useMemo(() => { + const counts: Partial> = {}; + parsedLines.forEach((line) => { + if (!line.method) return; + counts[line.method] = (counts[line.method] ?? 0) + 1; + }); + return counts; + }, [parsedLines]); + + const statusCounts = useMemo(() => { + const counts: Partial> = {}; + parsedLines.forEach((line) => { + const statusGroup = resolveStatusGroup(line.statusCode); + if (!statusGroup) return; + counts[statusGroup] = (counts[statusGroup] ?? 0) + 1; + }); + return counts; + }, [parsedLines]); + + const pathOptions = useMemo(() => { + const counts = new Map(); + parsedLines.forEach((line) => { + if (!line.path) return; + counts.set(line.path, (counts.get(line.path) ?? 0) + 1); + }); + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, PATH_FILTER_LIMIT) + .map(([path, count]) => ({ path, count })); + }, [parsedLines]); + + useEffect(() => { + const validPathSet = new Set(pathOptions.map((item) => item.path)); + setPathFilters((prev) => { + if (prev.length === 0) return prev; + const next = prev.filter((path) => validPathSet.has(path)); + return next.length === prev.length ? prev : next; + }); + }, [pathOptions]); + + const toggleMethodFilter = (method: HttpMethod) => { + setMethodFilters((prev) => + prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method] + ); + }; + + const toggleStatusFilter = (group: StatusGroup) => { + setStatusFilters((prev) => + prev.includes(group) ? prev.filter((item) => item !== group) : [...prev, group] + ); + }; + + const togglePathFilter = (path: string) => { + setPathFilters((prev) => + prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path] + ); + }; + + const clearStructuredFilters = () => { + setMethodFilters([]); + setStatusFilters([]); + setPathFilters([]); + }; + + return { + methodFilters, + statusFilters, + pathFilters, + methodFilterSet, + statusFilterSet, + pathFilterSet, + hasStructuredFilters, + methodCounts, + statusCounts, + pathOptions, + toggleMethodFilter, + toggleStatusFilter, + togglePathFilter, + clearStructuredFilters, + }; +} +