add logger vireer

This commit is contained in:
musistudio
2025-09-05 21:36:21 +08:00
parent 0152af5db9
commit e5741ae470
10 changed files with 942 additions and 9 deletions

View File

@@ -20,7 +20,7 @@
"license": "MIT",
"dependencies": {
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.31",
"@musistudio/llms": "file:../../llms",
"dotenv": "^16.4.7",
"json5": "^2.2.3",
"openurl": "^1.1.1",

10
pnpm-lock.yaml generated
View File

@@ -12,8 +12,8 @@ importers:
specifier: ^8.2.0
version: 8.2.0
'@musistudio/llms':
specifier: ^1.0.31
version: 1.0.31(ws@8.18.3)
specifier: file:../../llms
version: file:../../llms(ws@8.18.3)
dotenv:
specifier: ^16.4.7
version: 16.6.1
@@ -266,8 +266,8 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@musistudio/llms@1.0.31':
resolution: {integrity: sha512-zPzGAnpB60g6iGldfxzkzohTbUtrg7y1VnTNORRESnC2Fd/4XiSdIHoaURzp7RJ4hnTYkolDLMfvlmHUmdr9AA==}
'@musistudio/llms@file:../../llms':
resolution: {directory: ../../llms, type: directory}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -1108,7 +1108,7 @@ snapshots:
'@lukeed/ms@2.0.2': {}
'@musistudio/llms@1.0.31(ws@8.18.3)':
'@musistudio/llms@file:../../llms(ws@8.18.3)':
dependencies:
'@anthropic-ai/sdk': 0.54.0
'@fastify/cors': 11.1.0

View File

@@ -118,7 +118,8 @@ async function run(options: RunOptions = {}) {
path: HOME_DIR,
maxFiles: 3,
interval: "1d",
compress: 'gzip'
compress: 'gzip',
maxSize: "50M"
}),
}
: false;

View File

@@ -3,6 +3,8 @@ import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { checkForUpdates, performUpdate } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os";
export const createServer = (config: any): Server => {
const server = new Server(config);
@@ -102,5 +104,149 @@ export const createServer = (config: any): Server => {
}
});
// 获取日志文件列表端点
server.app.get("/api/logs/files", async (req, reply) => {
try {
const logDir = join(homedir(), ".claude-code-router", "logs");
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
if (existsSync(logDir)) {
const files = readdirSync(logDir);
for (const file of files) {
if (file.endsWith('.log')) {
const filePath = join(logDir, file);
const stats = statSync(filePath);
logFiles.push({
name: file,
path: filePath,
size: stats.size,
lastModified: stats.mtime.toISOString()
});
}
}
// 按修改时间倒序排列
logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
}
return logFiles;
} catch (error) {
console.error("Failed to get log files:", error);
reply.status(500).send({ error: "Failed to get log files" });
}
});
// 获取日志内容端点
server.app.get("/api/logs", async (req, reply) => {
try {
const filePath = (req.query as any).file as string;
const groupByReqId = (req.query as any).groupByReqId === 'true';
let logFilePath: string;
if (filePath) {
// 如果指定了文件路径,使用指定的路径
logFilePath = filePath;
} else {
// 如果没有指定文件路径,使用默认的日志文件路径
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
}
if (!existsSync(logFilePath)) {
return [];
}
const logContent = readFileSync(logFilePath, 'utf8');
const logLines = logContent.split('\n').filter(line => line.trim());
const logs = logLines.map(line => {
try {
// 尝试解析JSON格式的日志
const logEntry = JSON.parse(line);
return {
timestamp: logEntry.timestamp || logEntry.time || new Date(logEntry.time).toISOString() || new Date().toISOString(),
level: logEntry.level || 'info',
message: logEntry.message || logEntry.msg || line,
source: logEntry.source || undefined,
reqId: logEntry.reqId || undefined
};
} catch {
// 如果不是JSON格式创建一个基本的日志条目
return {
timestamp: new Date().toISOString(),
level: 'info',
message: line,
source: undefined,
reqId: undefined
};
}
});
// 如果需要按reqId聚合
if (groupByReqId) {
const groupedLogs: { [reqId: string]: typeof logs } = {};
logs.forEach(log => {
const reqId = log.reqId || 'no-req-id';
if (!groupedLogs[reqId]) {
groupedLogs[reqId] = [];
}
groupedLogs[reqId].push(log);
});
// 按时间戳排序每个组的日志
Object.keys(groupedLogs).forEach(reqId => {
groupedLogs[reqId].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
return {
grouped: true,
groups: groupedLogs,
summary: {
totalRequests: Object.keys(groupedLogs).length,
totalLogs: logs.length,
requests: Object.keys(groupedLogs).map(reqId => ({
reqId,
logCount: groupedLogs[reqId].length,
firstLog: groupedLogs[reqId][0]?.timestamp,
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.timestamp
}))
}
};
}
return logs;
} catch (error) {
console.error("Failed to get logs:", error);
reply.status(500).send({ error: "Failed to get logs" });
}
});
// 清除日志内容端点
server.app.delete("/api/logs", async (req, reply) => {
try {
const filePath = (req.query as any).file as string;
let logFilePath: string;
if (filePath) {
// 如果指定了文件路径,使用指定的路径
logFilePath = filePath;
} else {
// 如果没有指定文件路径,使用默认的日志文件路径
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
}
if (existsSync(logFilePath)) {
writeFileSync(logFilePath, '', 'utf8');
}
return { success: true, message: "Logs cleared successfully" };
} catch (error) {
console.error("Failed to clear logs:", error);
reply.status(500).send({ error: "Failed to clear logs" });
}
});
return server;
};

View File

@@ -6,10 +6,11 @@ import { Transformers } from "@/components/Transformers";
import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router";
import { JsonEditor } from "@/components/JsonEditor";
import { LogViewer } from "@/components/LogViewer";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp } from "lucide-react";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -32,6 +33,7 @@ function App() {
const { config, error } = useConfig();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// 版本检查状态
@@ -276,6 +278,9 @@ function App() {
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
@@ -350,6 +355,11 @@ function App() {
onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })}
/>
<LogViewer
open={isLogViewerOpen}
onOpenChange={setIsLogViewerOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{/* 版本更新对话框 */}
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
<DialogContent className="max-w-2xl">

View File

@@ -0,0 +1,683 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
interface LogViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}
interface LogEntry {
timestamp: string;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
source?: string;
reqId?: string;
}
interface LogFile {
name: string;
path: string;
size: number;
lastModified: string;
}
interface GroupedLogs {
[reqId: string]: LogEntry[];
}
interface LogGroupSummary {
reqId: string;
logCount: number;
firstLog: string;
lastLog: string;
}
interface GroupedLogsResponse {
grouped: boolean;
groups: { [reqId: string]: LogEntry[] };
summary: {
totalRequests: number;
totalLogs: number;
requests: LogGroupSummary[];
};
}
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [groupByReqId, setGroupByReqId] = useState(false);
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
if (open) {
loadLogFiles();
}
}, [open]);
// 创建内联 Web Worker
const createInlineWorker = (): Worker => {
const workerCode = `
// 日志聚合Web Worker
self.onmessage = function(event) {
const { type, data } = event.data;
if (type === 'groupLogsByReqId') {
try {
const { logs } = data;
// 按reqId聚合日志
const groupedLogs = {};
logs.forEach((log, index) => {
let reqId = log.reqId;
// 如果没有reqId尝试从message字段中的JSON解析
if (!reqId && log.message && log.message.startsWith('{')) {
try {
const messageObj = JSON.parse(log.message);
reqId = messageObj.reqId;
} catch (e) {
// 解析失败,忽略
}
}
reqId = reqId || 'no-req-id';
if (!groupedLogs[reqId]) {
groupedLogs[reqId] = [];
}
groupedLogs[reqId].push(log);
});
// 按时间戳排序每个组的日志
Object.keys(groupedLogs).forEach(reqId => {
groupedLogs[reqId].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
// 生成摘要信息
const summary = {
totalRequests: Object.keys(groupedLogs).length,
totalLogs: logs.length,
requests: Object.keys(groupedLogs).map(reqId => ({
reqId,
logCount: groupedLogs[reqId].length,
firstLog: groupedLogs[reqId][0]?.timestamp,
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.timestamp
}))
};
const response = {
grouped: true,
groups: groupedLogs,
summary
};
// 发送结果回主线程
self.postMessage({
type: 'groupLogsResult',
data: response
});
} catch (error) {
// 发送错误回主线程
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
return new Worker(workerUrl);
};
// 初始化Web Worker
useEffect(() => {
if (typeof Worker !== 'undefined') {
try {
// 创建内联Web Worker
workerRef.current = createInlineWorker();
// 监听Worker消息
workerRef.current.onmessage = (event) => {
const { type, data, error } = event.data;
if (type === 'groupLogsResult') {
setGroupedLogs(data);
} else if (type === 'error') {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
}
}
};
// 监听Worker错误
workerRef.current.onerror = (error) => {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
};
} catch (error) {
console.error('Failed to create worker:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
}
}
// 清理Worker
return () => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
};
}, [showToast, t]);
useEffect(() => {
if (autoRefresh && open && selectedFile) {
refreshInterval.current = setInterval(() => {
loadLogs();
}, 5000); // Refresh every 5 seconds
} else if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
return () => {
if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
};
}, [autoRefresh, open, selectedFile]);
// Load logs when selected file changes
useEffect(() => {
if (selectedFile && open) {
setLogs([]); // Clear existing logs
loadLogs();
}
}, [selectedFile, open]);
// Handle open/close animations
useEffect(() => {
if (open) {
setIsVisible(true);
// Trigger the animation after a small delay to ensure the element is rendered
requestAnimationFrame(() => {
setIsAnimating(true);
});
} else {
setIsAnimating(false);
// Wait for the animation to complete before hiding
const timer = setTimeout(() => {
setIsVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [open]);
const loadLogFiles = async () => {
try {
setIsLoading(true);
const response = await api.getLogFiles();
if (response && Array.isArray(response)) {
setLogFiles(response);
setSelectedFile(null);
setLogs([]);
} else {
setLogFiles([]);
if (showToast) {
showToast(t('log_viewer.no_log_files_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load log files:', error);
if (showToast) {
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const loadLogs = async () => {
if (!selectedFile) return;
try {
setIsLoading(true);
setGroupedLogs(null);
setSelectedReqId(null);
// 始终加载原始日志数据
const response = await api.getLogs(selectedFile.path);
if (response && Array.isArray(response)) {
const typedLogs: LogEntry[] = response.map(log => ({
...log,
level: (log.level as 'info' | 'warn' | 'error' | 'debug') || 'info'
}));
setLogs(typedLogs);
// 如果启用了分组使用Web Worker进行聚合
if (groupByReqId && workerRef.current) {
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs: typedLogs }
});
} else {
setGroupedLogs(null);
}
} else {
setLogs([]);
setGroupedLogs(null);
if (showToast) {
showToast(t('log_viewer.no_logs_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load logs:', error);
if (showToast) {
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const clearLogs = async () => {
if (!selectedFile) return;
try {
await api.clearLogs(selectedFile.path);
setLogs([]);
if (showToast) {
showToast(t('log_viewer.logs_cleared'), 'success');
}
} catch (error) {
console.error('Failed to clear logs:', error);
if (showToast) {
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
}
}
};
const selectFile = (file: LogFile) => {
setSelectedFile(file);
setAutoRefresh(false); // Reset auto refresh when changing files
};
const toggleGroupByReqId = () => {
const newValue = !groupByReqId;
setGroupByReqId(newValue);
if (newValue && selectedFile && logs.length > 0) {
// 启用聚合时如果已有日志则使用Worker进行聚合
if (workerRef.current) {
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs }
});
}
} else if (!newValue) {
// 禁用聚合时,清除聚合结果
setGroupedLogs(null);
setSelectedReqId(null);
}
};
const selectReqId = (reqId: string) => {
setSelectedReqId(reqId);
};
const getDisplayLogs = () => {
if (groupByReqId && groupedLogs) {
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
return groupedLogs.groups[selectedReqId];
}
return logs;
}
return logs;
};
const downloadLogs = () => {
if (!selectedFile || logs.length === 0) return;
const logText = logs.map(log =>
`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.source ? `[${log.source}] ` : ''}${log.message}`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (showToast) {
showToast(t('log_viewer.logs_downloaded'), 'success');
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// 面包屑导航项类型
interface BreadcrumbItem {
id: string;
label: string;
onClick: () => void;
}
// 获取面包屑导航项
const getBreadcrumbs = (): BreadcrumbItem[] => {
const breadcrumbs: BreadcrumbItem[] = [
{
id: 'root',
label: t('log_viewer.title'),
onClick: () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
}
}
];
if (selectedFile) {
breadcrumbs.push({
id: 'file',
label: selectedFile.name,
onClick: () => {
setSelectedReqId(null);
setGroupedLogs(null);
setGroupByReqId(false);
}
});
}
if (selectedReqId) {
breadcrumbs.push({
id: 'req',
label: `${t('log_viewer.request')} ${selectedReqId}`,
onClick: () => {
// 点击当前层级时不做任何操作
}
});
}
return breadcrumbs;
};
// 获取返回按钮的处理函数
const getBackAction = (): (() => void) | null => {
if (selectedReqId) {
return () => {
setSelectedReqId(null);
};
} else if (selectedFile) {
return () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
};
}
return null;
};
const formatLogsForEditor = () => {
const displayLogs = getDisplayLogs();
return JSON.stringify(displayLogs, null, 2);
};
if (!isVisible && !open) {
return null;
}
return (
<>
{(isVisible || open) && (
<div
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
}`}
onClick={() => onOpenChange(false)}
/>
)}
<div
ref={containerRef}
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
}`}
style={{
height: '100vh',
maxHeight: '100vh'
}}
>
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
{getBackAction() && (
<Button
variant="ghost"
size="sm"
onClick={getBackAction()!}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('log_viewer.back')}
</Button>
)}
{/* 面包屑导航 */}
<nav className="flex items-center space-x-1 text-sm">
{getBreadcrumbs().map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.id}>
{index > 0 && (
<span className="text-gray-400 mx-1">/</span>
)}
{index === getBreadcrumbs().length - 1 ? (
<span className="text-gray-900 font-medium">
{breadcrumb.label}
</span>
) : (
<button
onClick={breadcrumb.onClick}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
{breadcrumb.label}
</button>
)}
</React.Fragment>
))}
</nav>
</div>
<div className="flex gap-2">
{selectedFile && (
<>
<Button
variant="ghost"
size="sm"
onClick={toggleGroupByReqId}
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
>
<Layers className="h-4 w-4 mr-2" />
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
>
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
</Button>
<Button
variant="outline"
size="sm"
onClick={downloadLogs}
disabled={logs.length === 0}
>
<Download className="h-4 w-4 mr-2" />
{t('log_viewer.download')}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('log_viewer.clear')}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4 mr-2" />
{t('log_viewer.close')}
</Button>
</div>
</div>
<div className="flex-1 min-h-0 bg-gray-50">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : selectedFile ? (
<>
{groupByReqId && groupedLogs && !selectedReqId ? (
// 显示日志组列表
<div className="flex flex-col h-full p-6">
<div className="mb-4 flex-shrink-0">
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
<p className="text-sm text-gray-600">
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
{groupedLogs.summary.requests.map((request) => (
<div
key={request.reqId}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectReqId(request.reqId)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{request.reqId}</span>
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{request.logCount} {t('log_viewer.logs')}
</span>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
</div>
</div>
))}
</div>
</div>
) : (
// 显示日志内容
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
}}
/>
)}
</>
) : (
<div className="p-6">
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
{logFiles.length === 0 ? (
<div className="text-gray-500 text-center py-8">
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>{t('log_viewer.no_log_files_available')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logFiles.map((file) => (
<div
key={file.path}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectFile(file)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{file.name}</span>
</div>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{formatFileSize(file.size)}</div>
<div>{formatDate(file.lastModified)}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -1,5 +1,21 @@
import type { Config, Provider, Transformer } from '@/types';
// 日志聚合响应类型
interface GroupedLogsResponse {
grouped: boolean;
groups: { [reqId: string]: Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }> };
summary: {
totalRequests: number;
totalLogs: number;
requests: Array<{
reqId: string;
logCount: number;
firstLog: string;
lastLog: string;
}>;
};
}
// API Client Class for handling requests with baseUrl and apikey authentication
class ApiClient {
private baseUrl: string;
@@ -204,6 +220,21 @@ class ApiClient {
async performUpdate(): Promise<{ success: boolean; message: string }> {
return this.post<{ success: boolean; message: string }>('/api/update/perform', {});
}
// Get log files list
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
}
// Get logs from specific file
async getLogs(filePath: string): Promise<Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }>> {
return this.get<Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }>>(`/logs?file=${encodeURIComponent(filePath)}`);
}
// Clear logs from specific file
async clearLogs(filePath: string): Promise<void> {
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
}
}
// Create a default instance of the API client

View File

@@ -193,5 +193,36 @@
"template_download_success": "Template downloaded successfully",
"template_download_success_desc": "Configuration template has been downloaded to your device",
"template_download_failed": "Failed to download template"
},
"log_viewer": {
"title": "Log Viewer",
"close": "Close",
"download": "Download",
"clear": "Clear",
"auto_refresh_on": "Auto Refresh On",
"auto_refresh_off": "Auto Refresh Off",
"load_failed": "Failed to load logs",
"no_logs_available": "No logs available",
"logs_cleared": "Logs cleared successfully",
"clear_failed": "Failed to clear logs",
"logs_downloaded": "Logs downloaded successfully",
"back_to_files": "Back to Files",
"select_file": "Select a log file to view",
"no_log_files_available": "No log files available",
"load_files_failed": "Failed to load log files",
"group_by_req_id": "Group by Request ID",
"grouped_on": "Grouped",
"request_groups": "Request Groups",
"total_requests": "Total Requests",
"total_logs": "Total Logs",
"request": "Request",
"logs": "logs",
"first_log": "First Log",
"last_log": "Last Log",
"back_to_all_logs": "Back to All Logs",
"worker_error": "Worker error",
"worker_init_failed": "Failed to initialize worker",
"grouping_not_supported": "Log grouping not supported by server",
"back": "Back"
}
}

View File

@@ -193,5 +193,36 @@
"template_download_success": "模板下载成功",
"template_download_success_desc": "配置模板已下载到您的设备",
"template_download_failed": "模板下载失败"
},
"log_viewer": {
"title": "日志查看器",
"close": "关闭",
"download": "下载",
"clear": "清除",
"auto_refresh_on": "自动刷新开启",
"auto_refresh_off": "自动刷新关闭",
"load_failed": "加载日志失败",
"no_logs_available": "暂无日志",
"logs_cleared": "日志清除成功",
"clear_failed": "清除日志失败",
"logs_downloaded": "日志下载成功",
"back_to_files": "返回文件列表",
"select_file": "选择要查看的日志文件",
"no_log_files_available": "暂无日志文件",
"load_files_failed": "加载日志文件失败",
"group_by_req_id": "按请求ID分组",
"grouped_on": "已分组",
"request_groups": "请求组",
"total_requests": "总请求数",
"total_logs": "总日志数",
"request": "请求",
"logs": "条日志",
"first_log": "首条日志",
"last_log": "末条日志",
"back_to_all_logs": "返回所有日志",
"worker_error": "Worker错误",
"worker_init_failed": "Worker初始化失败",
"grouping_not_supported": "服务器不支持日志分组",
"back": "返回"
}
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}