Skip to content

Commit ac2ae3f

Browse files
committed
feat(ui): add logs page
1 parent 42cd686 commit ac2ae3f

File tree

5 files changed

+342
-3
lines changed

5 files changed

+342
-3
lines changed

ui/.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# Logs
2-
logs
32
*.log
43
npm-debug.log*
54
yarn-debug.log*

ui/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MainPage } from "@/pages/main"
22
import { SettingsPage } from "@/pages/settings"
33
import { ConnectionPage } from "@/pages/connection"
4+
import { LogsPage } from "@/pages/logs"
45
import { InstanceProvider } from "@/contexts/instance-provider"
56
import { Toaster } from "@/components/ui/sonner"
67
import { Navbar } from "@/components/Navbar"
@@ -26,6 +27,7 @@ function App() {
2627
<Route path="/" element={<MainPage />} />
2728
<Route path="/settings" element={<SettingsPage />} />
2829
<Route path="/connection" element={<ConnectionPage />} />
30+
<Route path="/logs" element={<LogsPage />} />
2931
</Routes>
3032
</Layout>
3133
</BrowserRouter>

ui/src/api/v1.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import useSWR from 'swr'
22
import useSWRMutation from 'swr/mutation'
3-
import { useState, useMemo, useRef } from 'react'
3+
import { useState, useMemo, useRef, useCallback } from 'react'
44
import { applyPatch } from 'fast-json-patch'
55
import useWebSocket, { ReadyState } from 'react-use-websocket';
66

@@ -454,3 +454,101 @@ export function useConnectionsStream(baseUrl?: string) {
454454
isConnected: readyState === ReadyState.OPEN,
455455
};
456456
}
457+
458+
interface SpanInfo {
459+
addr: string;
460+
name: string;
461+
self?: string;
462+
}
463+
464+
export interface LogContext {
465+
dest_socket_addr?: string;
466+
src_socket_addr?: string;
467+
dest_domain?: string;
468+
net_list?: string[];
469+
[key: string]: unknown;
470+
}
471+
472+
export interface LogFields {
473+
message?: string;
474+
ctx?: string;
475+
parsedCtx?: LogContext;
476+
span?: SpanInfo;
477+
spans?: SpanInfo[];
478+
[key: string]: unknown;
479+
}
480+
481+
export interface LogEntry {
482+
timestamp: string;
483+
level: string;
484+
message?: string;
485+
target?: string;
486+
fields: LogFields;
487+
[key: string]: unknown;
488+
}
489+
490+
// Add new logs stream hook
491+
function formatLogContext(ctx: string): LogContext {
492+
try {
493+
return JSON.parse(ctx);
494+
} catch {
495+
return {};
496+
}
497+
}
498+
499+
export function useLogsStream(baseUrl?: string) {
500+
const [state, setState] = useState<LogEntry[]>([]);
501+
const [isPaused, setIsPaused] = useState(false);
502+
const lastState = useRef<LogEntry[]>([]);
503+
504+
const wsUrl = useMemo(() => {
505+
const url = new URL('/api/stream/logs', baseUrl || window.location.origin);
506+
url.protocol = url.protocol.replace('http', 'ws');
507+
return url.toString();
508+
}, [baseUrl]);
509+
510+
const { readyState } = useWebSocket(wsUrl, {
511+
shouldReconnect: () => true,
512+
reconnectAttempts: 10,
513+
reconnectInterval: 3000,
514+
retryOnError: true,
515+
onMessage: (event) => {
516+
if (isPaused) return;
517+
518+
try {
519+
const log = JSON.parse(event.data as string) as LogEntry;
520+
// 预处理 ctx 字段
521+
if (log.fields?.ctx) {
522+
log.fields.parsedCtx = formatLogContext(log.fields.ctx);
523+
}
524+
const newState = [...(lastState.current || []), log];
525+
// Keep only last 1000 logs to prevent memory issues
526+
if (newState.length > 1000) {
527+
newState.shift();
528+
}
529+
setState(newState);
530+
lastState.current = newState;
531+
} catch (e) {
532+
console.error('Failed to parse log message:', e);
533+
}
534+
},
535+
});
536+
537+
const togglePause = useCallback(() => {
538+
setIsPaused(prev => !prev);
539+
}, []);
540+
541+
const clearLogs = useCallback(() => {
542+
setState([]);
543+
lastState.current = [];
544+
}, []);
545+
546+
return {
547+
logs: state,
548+
isPaused,
549+
togglePause,
550+
clearLogs,
551+
readyState,
552+
isConnected: readyState === ReadyState.OPEN,
553+
};
554+
}

ui/src/components/Navbar.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
SelectTrigger,
77
SelectValue,
88
} from "@/components/ui/select";
9-
import { Settings, AlertCircle, Loader2, Activity, Home, Menu } from "lucide-react";
9+
import { Settings, AlertCircle, Loader2, Activity, Home, Menu, ScrollText } from "lucide-react";
1010
import { NavLink } from "react-router";
1111
import { useInstanceStatuses } from "@/hooks/use-instance-statuses";
1212
import { cn } from "@/lib/utils";
@@ -58,6 +58,15 @@ export function Navbar() {
5858
连接管理
5959
</NavLink>
6060
</DropdownMenuItem>
61+
<DropdownMenuItem asChild>
62+
<NavLink
63+
to="/logs"
64+
className="w-full flex items-center gap-2"
65+
>
66+
<ScrollText className="h-4 w-4" />
67+
日志管理
68+
</NavLink>
69+
</DropdownMenuItem>
6170
<DropdownMenuItem asChild>
6271
<NavLink
6372
to="/settings"
@@ -105,6 +114,18 @@ export function Navbar() {
105114
<Activity className="h-4 w-4" />
106115
连接管理
107116
</NavLink>
117+
<NavLink
118+
to="/logs"
119+
className={({ isActive }) => cn(
120+
"flex items-center gap-2 px-2 py-1 rounded-md transition-colors",
121+
isActive
122+
? "text-primary font-medium"
123+
: "text-foreground/70 hover:text-foreground"
124+
)}
125+
>
126+
<ScrollText className="h-4 w-4" />
127+
日志管理
128+
</NavLink>
108129
</div>
109130
)}
110131
</div>

ui/src/pages/logs/index.tsx

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { useState, useMemo, useRef, useEffect } from 'react';
2+
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
3+
import { Button } from '@/components/ui/button';
4+
import { Input } from '@/components/ui/input';
5+
import { Badge } from '@/components/ui/badge';
6+
import { Search, Play, Pause, Trash2, AlertCircle } from 'lucide-react';
7+
import { useLogsStream, LogContext } from '@/api/v1';
8+
import { useInstance } from '@/contexts/instance';
9+
import clsx from 'clsx';
10+
11+
// 日志级别颜色映射(全部转为小写处理)
12+
const LOG_LEVEL_COLORS: Record<string, string> = {
13+
error: 'bg-red-100 text-red-800 border-red-200',
14+
warn: 'bg-amber-100 text-amber-800 border-amber-200',
15+
warning: 'bg-amber-100 text-amber-800 border-amber-200',
16+
info: 'bg-emerald-100 text-emerald-800 border-emerald-200',
17+
debug: 'bg-purple-100 text-purple-800 border-purple-200',
18+
trace: 'bg-gray-100 text-gray-600 border-gray-200',
19+
} as const;
20+
21+
// 格式化连接信息组件
22+
function ConnectionDetails({ ctx }: { ctx: LogContext }) {
23+
if (!ctx.dest_socket_addr && !ctx.src_socket_addr && !ctx.dest_domain) {
24+
return null;
25+
}
26+
27+
return (
28+
<div className="flex flex-wrap gap-2 mt-1">
29+
{ctx.src_socket_addr && (
30+
<Badge variant="outline" className="bg-indigo-50 text-indigo-700 border-indigo-200">
31+
来源: {ctx.src_socket_addr}
32+
</Badge>
33+
)}
34+
{ctx.dest_domain && (
35+
<Badge variant="outline" className="bg-violet-50 text-violet-700 border-violet-200">
36+
域名: {ctx.dest_domain}
37+
</Badge>
38+
)}
39+
{ctx.dest_socket_addr && (
40+
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
41+
目标: {ctx.dest_socket_addr}
42+
</Badge>
43+
)}
44+
{ctx.net_list && ctx.net_list.length > 0 && (
45+
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
46+
路由: {ctx.net_list.join(' → ')}
47+
</Badge>
48+
)}
49+
</div>
50+
);
51+
}
52+
53+
export const LogsPage = () => {
54+
const { currentInstance } = useInstance();
55+
const { logs, isPaused, togglePause, clearLogs, isConnected } = useLogsStream(currentInstance?.url);
56+
const [searchQuery, setSearchQuery] = useState('');
57+
const scrollAreaRef = useRef<HTMLDivElement>(null);
58+
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
59+
60+
// 监听滚动事件
61+
const handleScroll = () => {
62+
const scrollArea = scrollAreaRef.current;
63+
if (!scrollArea) return;
64+
65+
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
66+
// 判断是否在底部附近(距离底部30px以内)
67+
const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
68+
setShouldAutoScroll(isNearBottom);
69+
};
70+
71+
// 自动滚动到底部
72+
useEffect(() => {
73+
if (shouldAutoScroll && !isPaused && scrollAreaRef.current) {
74+
const scrollArea = scrollAreaRef.current;
75+
scrollArea.scrollTop = scrollArea.scrollHeight;
76+
}
77+
}, [logs, shouldAutoScroll, isPaused]);
78+
79+
const filteredLogs = useMemo(() => {
80+
if (!searchQuery) return logs;
81+
82+
const searchLower = searchQuery.toLowerCase();
83+
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+
});
92+
}, [logs, searchQuery]);
93+
94+
const formatTime = (timestamp: string) => {
95+
try {
96+
const date = new Date(timestamp);
97+
// 手动格式化毫秒,因为 Intl.DateTimeFormat 不支持毫秒的显示
98+
const formatter = new Intl.DateTimeFormat('zh-CN', {
99+
hour: '2-digit',
100+
minute: '2-digit',
101+
second: '2-digit',
102+
});
103+
const ms = date.getMilliseconds().toString().padStart(3, '0');
104+
return `${formatter.format(date)}.${ms}`;
105+
} catch {
106+
return timestamp;
107+
}
108+
};
109+
110+
return (
111+
<div className="h-[calc(100vh-56px)] p-4">
112+
<Card className="h-full flex flex-col">
113+
<CardHeader className="pb-2 shrink-0">
114+
<div className="flex justify-between items-center">
115+
<div className="flex items-center gap-2">
116+
<CardTitle className="text-xl text-gray-800">日志</CardTitle>
117+
<Badge
118+
variant="outline"
119+
className={clsx(
120+
'transition-colors',
121+
isConnected
122+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
123+
: 'bg-amber-50 text-amber-700 border-amber-200'
124+
)}
125+
>
126+
{isConnected ? '已连接' : '连接中...'}
127+
</Badge>
128+
</div>
129+
<div className="flex gap-2">
130+
<Button
131+
variant="outline"
132+
size="sm"
133+
onClick={togglePause}
134+
className={clsx(isPaused && 'bg-amber-50 border-amber-200 text-amber-700')}
135+
>
136+
{isPaused ? <Play className="h-4 w-4 mr-1" /> : <Pause className="h-4 w-4 mr-1" />}
137+
{isPaused ? '继续' : '暂停'}
138+
</Button>
139+
<Button
140+
variant="outline"
141+
size="sm"
142+
onClick={clearLogs}
143+
>
144+
<Trash2 className="h-4 w-4 mr-1" />
145+
清空
146+
</Button>
147+
</div>
148+
</div>
149+
<div className="relative w-full max-w-md mt-2">
150+
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
151+
<Input
152+
placeholder="搜索日志..."
153+
className="pl-8 bg-white border-gray-300"
154+
value={searchQuery}
155+
onChange={(e) => setSearchQuery(e.target.value)}
156+
/>
157+
</div>
158+
</CardHeader>
159+
<CardContent className="p-0 flex-1 overflow-hidden">
160+
<div
161+
ref={scrollAreaRef}
162+
className="h-full overflow-auto"
163+
onScroll={handleScroll}
164+
>
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>
190+
)}
191+
</div>
192+
193+
<div className="mt-1 text-gray-700 font-medium pl-[84px]">
194+
{log.fields?.message || log.message}
195+
</div>
196+
197+
{log.fields?.parsedCtx && (
198+
<div className="pl-[84px]">
199+
<ConnectionDetails ctx={log.fields.parsedCtx} />
200+
</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)}
209+
</div>
210+
))}
211+
</div>
212+
))}
213+
</div>
214+
</div>
215+
</CardContent>
216+
</Card>
217+
</div>
218+
);
219+
};

0 commit comments

Comments
 (0)