refactor(logs): extract useLogFilters

This commit is contained in:
Supra4E8C
2026-02-22 14:26:40 +08:00
Unverified
parent 0842be2937
commit b492bfa8a6
3 changed files with 201 additions and 136 deletions
+44 -136
View File
@@ -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<HttpMethod[]>([]);
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
const [pathFilters, setPathFilters] = useState<string[]>([]);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
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<Record<HttpMethod, number>> = {};
parsedSearchLines.forEach((line) => {
if (!line.method) return;
counts[line.method] = (counts[line.method] ?? 0) + 1;
});
return counts;
}, [parsedSearchLines]);
const statusCounts = useMemo(() => {
const counts: Partial<Record<StatusGroup, number>> = {};
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<string, number>();
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() {
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
<div className={styles.filterChipList}>
{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 (
<button
key={method}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => toggleMethodFilter(method)}
onClick={() => filters.toggleMethodFilter(method)}
disabled={count === 0 && !active}
aria-pressed={active}
>
@@ -1198,14 +1106,14 @@ export function LogsPage() {
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
<div className={styles.filterChipList}>
{STATUS_GROUPS.map((statusGroup) => {
const active = statusFilters.includes(statusGroup);
const count = statusCounts[statusGroup] ?? 0;
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={() => toggleStatusFilter(statusGroup)}
onClick={() => filters.toggleStatusFilter(statusGroup)}
disabled={count === 0 && !active}
aria-pressed={active}
>
@@ -1219,17 +1127,17 @@ export function LogsPage() {
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
<div className={styles.filterChipList}>
{pathOptions.length === 0 ? (
{filters.pathOptions.length === 0 ? (
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
) : (
pathOptions.map(({ path, count }) => {
const active = pathFilters.includes(path);
filters.pathOptions.map(({ path, count }) => {
const active = filters.pathFilters.includes(path);
return (
<button
key={path}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => togglePathFilter(path)}
onClick={() => filters.togglePathFilter(path)}
aria-pressed={active}
title={path}
>
@@ -1244,8 +1152,8 @@ export function LogsPage() {
<Button
variant="ghost"
size="sm"
onClick={clearStructuredFilters}
disabled={!hasStructuredFilters}
onClick={filters.clearStructuredFilters}
disabled={!filters.hasStructuredFilters}
>
{t('logs.clear_filters')}
</Button>
+35
View File
@@ -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;
};
+122
View File
@@ -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<HttpMethod>;
statusFilterSet: Set<StatusGroup>;
pathFilterSet: Set<string>;
hasStructuredFilters: boolean;
methodCounts: Partial<Record<HttpMethod, number>>;
statusCounts: Partial<Record<StatusGroup, number>>;
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<HttpMethod[]>([]);
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
const [pathFilters, setPathFilters] = useState<string[]>([]);
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<Record<HttpMethod, number>> = {};
parsedLines.forEach((line) => {
if (!line.method) return;
counts[line.method] = (counts[line.method] ?? 0) + 1;
});
return counts;
}, [parsedLines]);
const statusCounts = useMemo(() => {
const counts: Partial<Record<StatusGroup, number>> = {};
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<string, number>();
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,
};
}