Skip to content

Commit 4812524

Browse files
committed
feat(ui): optimize logs page performance with virtual scroll and search
1 parent ac2ae3f commit 4812524

File tree

3 files changed

+135
-55
lines changed

3 files changed

+135
-55
lines changed

ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"@radix-ui/react-toggle": "^1.1.1",
3939
"@radix-ui/react-toggle-group": "^1.1.1",
4040
"@radix-ui/react-tooltip": "^1.1.7",
41+
"@tanstack/react-virtual": "^3.13.2",
42+
"@types/lodash": "^4.17.16",
4143
"autoprefixer": "^10.4.20",
4244
"class-variance-authority": "^0.7.1",
4345
"clsx": "^2.1.1",
@@ -46,6 +48,7 @@
4648
"embla-carousel-react": "^8.5.2",
4749
"fast-json-patch": "^3.1.1",
4850
"input-otp": "^1.4.2",
51+
"lodash": "^4.17.21",
4952
"lucide-react": "^0.474.0",
5053
"next-themes": "^0.4.4",
5154
"p-limit": "^6.2.0",

ui/pnpm-lock.yaml

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/pages/logs/index.tsx

Lines changed: 101 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Badge } from '@/components/ui/badge';
66
import { Search, Play, Pause, Trash2, AlertCircle } from 'lucide-react';
77
import { useLogsStream, LogContext } from '@/api/v1';
88
import { useInstance } from '@/contexts/instance';
9+
import { useVirtualizer } from '@tanstack/react-virtual';
10+
import debounce from 'lodash/debounce';
911
import clsx from 'clsx';
1012

1113
// 日志级别颜色映射(全部转为小写处理)
@@ -57,6 +59,11 @@ export const LogsPage = () => {
5759
const scrollAreaRef = useRef<HTMLDivElement>(null);
5860
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
5961

62+
// 使用防抖来处理搜索,避免频繁过滤
63+
const debouncedSetSearchQuery = debounce((value: string) => {
64+
setSearchQuery(value);
65+
}, 300);
66+
6067
// 监听滚动事件
6168
const handleScroll = () => {
6269
const scrollArea = scrollAreaRef.current;
@@ -74,23 +81,45 @@ export const LogsPage = () => {
7481
const scrollArea = scrollAreaRef.current;
7582
scrollArea.scrollTop = scrollArea.scrollHeight;
7683
}
77-
}, [logs, shouldAutoScroll, isPaused]);
84+
}, [logs.length, shouldAutoScroll, isPaused]);
7885

7986
const filteredLogs = useMemo(() => {
8087
if (!searchQuery) return logs;
8188

8289
const searchLower = searchQuery.toLowerCase();
8390
return logs.filter(log => {
84-
const fieldsStr = log.fields?.ctx ? JSON.stringify(log.fields) : '';
85-
return (
86-
log.message?.toLowerCase().includes(searchLower) ||
87-
log.level?.toLowerCase().includes(searchLower) ||
88-
log.target?.toLowerCase().includes(searchLower) ||
89-
fieldsStr.toLowerCase().includes(searchLower)
90-
);
91+
// 避免不必要的 JSON.stringify
92+
const basicMatch =
93+
(log.message?.toLowerCase().includes(searchLower) ||
94+
log.level?.toLowerCase().includes(searchLower) ||
95+
log.target?.toLowerCase().includes(searchLower));
96+
97+
if (basicMatch) return true;
98+
99+
// 只在必要时检查 fields
100+
if (log.fields) {
101+
const fieldsMatch = Object.entries(log.fields).some(([, value]) => {
102+
if (typeof value === 'string') {
103+
return value.toLowerCase().includes(searchLower);
104+
}
105+
return false;
106+
});
107+
return fieldsMatch;
108+
}
109+
110+
return false;
91111
});
92112
}, [logs, searchQuery]);
93113

114+
const parentRef = useRef<HTMLDivElement>(null);
115+
116+
const rowVirtualizer = useVirtualizer({
117+
count: filteredLogs.length,
118+
getScrollElement: () => parentRef.current,
119+
estimateSize: () => 100, // 预估每行高度
120+
overscan: 5, // 预加载的行数
121+
});
122+
94123
const formatTime = (timestamp: string) => {
95124
try {
96125
const date = new Date(timestamp);
@@ -151,65 +180,82 @@ export const LogsPage = () => {
151180
<Input
152181
placeholder="搜索日志..."
153182
className="pl-8 bg-white border-gray-300"
154-
value={searchQuery}
155-
onChange={(e) => setSearchQuery(e.target.value)}
183+
onChange={(e) => debouncedSetSearchQuery(e.target.value)}
156184
/>
157185
</div>
158186
</CardHeader>
159187
<CardContent className="p-0 flex-1 overflow-hidden">
160188
<div
161-
ref={scrollAreaRef}
189+
ref={parentRef}
162190
className="h-full overflow-auto"
163191
onScroll={handleScroll}
164192
>
165-
<div className="p-4 space-y-3">
166-
{filteredLogs.map((log, index) => (
167-
<div
168-
key={index}
169-
className={clsx(
170-
'text-sm rounded-lg p-2 transition-colors',
171-
log.level?.toLowerCase() === 'error' && 'bg-red-50',
172-
(log.level?.toLowerCase() === 'warn' || log.level?.toLowerCase() === 'warning') && 'bg-amber-50',
173-
)}
174-
>
175-
<div className="flex items-start gap-2">
176-
<span className="text-gray-500 shrink-0 font-mono">
177-
{formatTime(log.timestamp)}
178-
</span>
179-
<Badge
180-
variant="outline"
181-
className={LOG_LEVEL_COLORS[log.level?.toLowerCase()] || LOG_LEVEL_COLORS.trace}
182-
>
183-
{log.level?.toLowerCase() === 'error' && <AlertCircle className="h-3 w-3 mr-1" />}
184-
{log.level}
185-
</Badge>
186-
{log.target && (
187-
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
188-
{log.target}
189-
</Badge>
193+
<div
194+
style={{
195+
height: `${rowVirtualizer.getTotalSize()}px`,
196+
width: '100%',
197+
position: 'relative',
198+
}}
199+
>
200+
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
201+
const log = filteredLogs[virtualRow.index];
202+
return (
203+
<div
204+
key={virtualRow.index}
205+
data-index={virtualRow.index}
206+
ref={rowVirtualizer.measureElement}
207+
style={{
208+
position: 'absolute',
209+
top: 0,
210+
left: 0,
211+
width: '100%',
212+
transform: `translateY(${virtualRow.start}px)`,
213+
}}
214+
className={clsx(
215+
'p-4',
216+
log.level?.toLowerCase() === 'error' && 'bg-red-50',
217+
(log.level?.toLowerCase() === 'warn' || log.level?.toLowerCase() === 'warning') && 'bg-amber-50',
190218
)}
191-
</div>
192-
193-
<div className="mt-1 text-gray-700 font-medium pl-[84px]">
194-
{log.fields?.message || log.message}
195-
</div>
219+
>
220+
<div className="flex items-start gap-2">
221+
<span className="text-gray-500 shrink-0 font-mono">
222+
{formatTime(log.timestamp)}
223+
</span>
224+
<Badge
225+
variant="outline"
226+
className={LOG_LEVEL_COLORS[log.level?.toLowerCase()] || LOG_LEVEL_COLORS.trace}
227+
>
228+
{log.level?.toLowerCase() === 'error' && <AlertCircle className="h-3 w-3 mr-1" />}
229+
{log.level}
230+
</Badge>
231+
{log.target && (
232+
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
233+
{log.target}
234+
</Badge>
235+
)}
236+
</div>
196237

197-
{log.fields?.parsedCtx && (
198-
<div className="pl-[84px]">
199-
<ConnectionDetails ctx={log.fields.parsedCtx} />
238+
<div className="mt-1 text-gray-700 font-medium pl-[84px]">
239+
{log.fields?.message || log.message}
200240
</div>
201-
)}
202-
203-
{/* 显示其他字段信息 */}
204-
{log.fields && Object.entries(log.fields)
205-
.filter(([key]) => !['message', 'ctx', 'parsedCtx'].includes(key))
206-
.map(([key, value]) => (
207-
<div key={key} className="pl-[84px] mt-1 text-xs text-gray-500">
208-
<span className="font-medium">{key}:</span> {JSON.stringify(value)}
241+
242+
{log.fields?.parsedCtx && (
243+
<div className="pl-[84px]">
244+
<ConnectionDetails ctx={log.fields.parsedCtx} />
209245
</div>
210-
))}
211-
</div>
212-
))}
246+
)}
247+
248+
{/* 显示其他字段信息 */}
249+
{log.fields && Object.entries(log.fields)
250+
.filter(([key]) => !['message', 'ctx', 'parsedCtx'].includes(key))
251+
.map(([key, value]) => (
252+
<div key={key} className="pl-[84px] mt-1 text-xs text-gray-500">
253+
<span className="font-medium">{key}:</span> {JSON.stringify(value)}
254+
</div>
255+
))}
256+
</div>
257+
);
258+
})}
213259
</div>
214260
</div>
215261
</CardContent>

0 commit comments

Comments
 (0)