远程读取日志
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Filter } from 'lucide-react';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/ConsolePanel.css';
|
||||
|
||||
interface ConsolePanelProps {
|
||||
@@ -19,6 +20,8 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
LogLevel.Fatal
|
||||
]));
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,6 +113,110 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleLogExpand = (logId: number) => {
|
||||
const newExpanded = new Set(expandedLogs);
|
||||
if (newExpanded.has(logId)) {
|
||||
newExpanded.delete(logId);
|
||||
} else {
|
||||
newExpanded.add(logId);
|
||||
}
|
||||
setExpandedLogs(newExpanded);
|
||||
};
|
||||
|
||||
const extractJSON = (message: string): { prefix: string; json: string; suffix: string } | null => {
|
||||
const jsonStartChars = ['{', '['];
|
||||
let startIndex = -1;
|
||||
|
||||
for (const char of jsonStartChars) {
|
||||
const index = message.indexOf(char);
|
||||
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
|
||||
startIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) return null;
|
||||
|
||||
for (let endIndex = message.length; endIndex > startIndex; endIndex--) {
|
||||
const possibleJson = message.substring(startIndex, endIndex);
|
||||
try {
|
||||
JSON.parse(possibleJson);
|
||||
return {
|
||||
prefix: message.substring(0, startIndex).trim(),
|
||||
json: possibleJson,
|
||||
suffix: message.substring(endIndex).trim()
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryParseJSON = (message: string): { isJSON: boolean; parsed?: any; jsonStr?: string } => {
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
return { isJSON: true, parsed, jsonStr: message };
|
||||
} catch {
|
||||
const extracted = extractJSON(message);
|
||||
if (extracted) {
|
||||
try {
|
||||
const parsed = JSON.parse(extracted.json);
|
||||
return { isJSON: true, parsed, jsonStr: extracted.json };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
return { isJSON: false };
|
||||
}
|
||||
};
|
||||
|
||||
const openJsonViewer = (jsonStr: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
setJsonViewerData(parsed);
|
||||
} catch {
|
||||
console.error('Failed to parse JSON:', jsonStr);
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessage = (message: string, isExpanded: boolean): JSX.Element => {
|
||||
const MAX_PREVIEW_LENGTH = 200;
|
||||
const { isJSON, jsonStr } = tryParseJSON(message);
|
||||
const extracted = extractJSON(message);
|
||||
|
||||
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
|
||||
|
||||
return (
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate ? (
|
||||
<>
|
||||
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
|
||||
<span className="log-message-preview">
|
||||
{message.substring(0, MAX_PREVIEW_LENGTH)}...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{message}</span>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && jsonStr && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openJsonViewer(jsonStr);
|
||||
}}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const levelCounts = {
|
||||
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
|
||||
@@ -184,24 +291,46 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
<p>No logs to display</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map(log => (
|
||||
<div key={log.id} className={`log-entry ${getLevelClass(log.level)}`}>
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
filteredLogs.map(log => {
|
||||
const isExpanded = expandedLogs.has(log.id);
|
||||
const shouldShowExpander = log.message.length > 200;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
|
||||
>
|
||||
{shouldShowExpander && (
|
||||
<div
|
||||
className="log-entry-expander"
|
||||
onClick={() => toggleLogExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
<div className="log-entry-message">
|
||||
{formatMessage(log.message, isExpanded)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className="log-entry-source">
|
||||
[{log.source}]
|
||||
</div>
|
||||
<div className="log-entry-message">
|
||||
{log.message}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="console-scroll-to-bottom"
|
||||
|
||||
139
packages/editor-app/src/components/JsonViewer.tsx
Normal file
139
packages/editor-app/src/components/JsonViewer.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { X, ChevronRight, ChevronDown, Copy, Check } from 'lucide-react';
|
||||
import '../styles/JsonViewer.css';
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function JsonViewer({ data, onClose }: JsonViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="json-viewer-overlay" onClick={onClose}>
|
||||
<div className="json-viewer-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="json-viewer-header">
|
||||
<h3>JSON Viewer</h3>
|
||||
<div className="json-viewer-actions">
|
||||
<button
|
||||
className="json-viewer-btn"
|
||||
onClick={handleCopy}
|
||||
title="Copy JSON"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="json-viewer-btn"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="json-viewer-content">
|
||||
<JsonTree data={data} name="root" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsonTreeProps {
|
||||
data: any;
|
||||
name: string;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
function JsonTree({ data, name, level = 0 }: JsonTreeProps) {
|
||||
const [expanded, setExpanded] = useState(level < 2);
|
||||
|
||||
const getValueType = (value: any): string => {
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
const getValueColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'string': return 'json-string';
|
||||
case 'number': return 'json-number';
|
||||
case 'boolean': return 'json-boolean';
|
||||
case 'null': return 'json-null';
|
||||
case 'array': return 'json-array';
|
||||
case 'object': return 'json-object';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (value: any): JSX.Element => {
|
||||
const type = getValueType(value);
|
||||
const colorClass = getValueColor(type);
|
||||
|
||||
if (type === 'object' || type === 'array') {
|
||||
const isArray = Array.isArray(value);
|
||||
const keys = Object.keys(value);
|
||||
const preview = isArray
|
||||
? `Array(${value.length})`
|
||||
: `Object {${keys.length} ${keys.length === 1 ? 'key' : 'keys'}}`;
|
||||
|
||||
return (
|
||||
<div className="json-tree-node">
|
||||
<div
|
||||
className="json-tree-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="json-tree-expander">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="json-tree-key">{name}:</span>
|
||||
<span className={`json-tree-preview ${colorClass}`}>
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="json-tree-children">
|
||||
{isArray ? (
|
||||
value.map((item: any, index: number) => (
|
||||
<JsonTree
|
||||
key={index}
|
||||
data={item}
|
||||
name={`[${index}]`}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
Object.entries(value).map(([key, val]) => (
|
||||
<JsonTree
|
||||
key={key}
|
||||
data={val}
|
||||
name={key}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="json-tree-leaf">
|
||||
<span className="json-tree-key">{name}:</span>
|
||||
<span className={`json-tree-value ${colorClass}`}>
|
||||
{type === 'string' ? `"${value}"` : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return renderValue(data);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2 } from 'lucide-react';
|
||||
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2, Pause, Play } from 'lucide-react';
|
||||
import { ProfilerService, ProfilerData } from '../services/ProfilerService';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
@@ -11,6 +11,7 @@ export function ProfilerDockPanel() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
@@ -42,7 +43,9 @@ export function ProfilerDockPanel() {
|
||||
|
||||
// 订阅数据更新
|
||||
const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
|
||||
setProfilerData(data);
|
||||
if (!isPaused) {
|
||||
setProfilerData(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 定期检查连接状态
|
||||
@@ -58,7 +61,7 @@ export function ProfilerDockPanel() {
|
||||
unsubscribe();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [isPaused]);
|
||||
|
||||
const fps = profilerData?.fps || 0;
|
||||
const totalFrameTime = profilerData?.totalFrameTime || 0;
|
||||
@@ -74,19 +77,32 @@ export function ProfilerDockPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profiler-dock-panel">
|
||||
<div className="profiler-dock-header">
|
||||
<h3>Performance Monitor</h3>
|
||||
<div className="profiler-dock-header-actions">
|
||||
{isConnected && (
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenDetails}
|
||||
title="Open detailed profiler"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="profiler-dock-pause-btn"
|
||||
onClick={handleTogglePause}
|
||||
title={isPaused ? 'Resume data updates' : 'Pause data updates'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenDetails}
|
||||
title="Open detailed profiler"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="profiler-dock-status">
|
||||
{isConnected ? (
|
||||
|
||||
@@ -56,8 +56,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
setIsRemoteConnected(connected);
|
||||
|
||||
if (connected && data.entities && data.entities.length > 0) {
|
||||
setRemoteEntities(data.entities);
|
||||
} else {
|
||||
// 只在实体列表发生实质性变化时才更新
|
||||
setRemoteEntities(prev => {
|
||||
if (prev.length !== data.entities!.length) {
|
||||
return data.entities!;
|
||||
}
|
||||
|
||||
// 检查实体ID和名称是否变化
|
||||
const hasChanged = data.entities!.some((entity, index) => {
|
||||
const prevEntity = prev[index];
|
||||
return !prevEntity ||
|
||||
prevEntity.id !== entity.id ||
|
||||
prevEntity.name !== entity.name ||
|
||||
prevEntity.componentCount !== entity.componentCount;
|
||||
});
|
||||
|
||||
return hasChanged ? data.entities! : prev;
|
||||
});
|
||||
} else if (!connected) {
|
||||
setRemoteEntities([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import '../styles/StartupPage.css';
|
||||
interface StartupPageProps {
|
||||
onOpenProject: () => void;
|
||||
onCreateProject: () => void;
|
||||
onOpenRecentProject?: (projectPath: string) => void;
|
||||
onProfilerMode?: () => void;
|
||||
recentProjects?: string[];
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [], locale }: StartupPageProps) {
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
|
||||
const translations = {
|
||||
@@ -17,18 +19,22 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create New Project',
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
version: 'Version 1.0.0'
|
||||
version: 'Version 1.0.0',
|
||||
comingSoon: 'Coming Soon'
|
||||
},
|
||||
zh: {
|
||||
title: 'ECS 框架编辑器',
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
version: '版本 1.0.0'
|
||||
version: '版本 1.0.0',
|
||||
comingSoon: '即将推出'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,18 +49,19 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
|
||||
|
||||
<div className="startup-content">
|
||||
<div className="startup-actions">
|
||||
<button className="startup-action-btn primary" onClick={onOpenProject}>
|
||||
<button className="startup-action-btn primary" onClick={onProfilerMode}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.openProject}</span>
|
||||
<span>{t.profilerMode}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onCreateProject}>
|
||||
<button className="startup-action-btn disabled" disabled title={t.comingSoon}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.createProject}</span>
|
||||
<span className="badge-coming-soon">{t.comingSoon}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +77,8 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
|
||||
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredProject(project)}
|
||||
onMouseLeave={() => setHoveredProject(null)}
|
||||
onClick={() => onOpenRecentProject?.(project)}
|
||||
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
|
||||
>
|
||||
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
||||
|
||||
Reference in New Issue
Block a user