|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<script src="assets/tailwindcss.js"></script>
|
|
<style>
|
|
.log-container {
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.forum-preview {
|
|
background-color: #f8f9fa;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: 4px;
|
|
padding: 15px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.loading-spinner {
|
|
border: 2px solid #f3f3f3;
|
|
border-top: 2px solid #3498db;
|
|
border-radius: 50%;
|
|
width: 20px;
|
|
height: 20px;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% {
|
|
transform: rotate(0deg);
|
|
}
|
|
|
|
100% {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="bg-gray-100 font-sans">
|
|
<div id="app"></div>
|
|
|
|
<script src="assets/vendor-bundle.umd.js"></script>
|
|
<script src="assets/babel.min.js"></script>
|
|
<script type="text/babel">
|
|
|
|
// ===================================================================
|
|
// =================== FRAMEWORK CODE ========================
|
|
// ===================================================================
|
|
// This section contains the reusable, application-agnostic core.
|
|
// You should not need to modify this section to add new features.
|
|
// -------------------------------------------------------------------
|
|
|
|
// 等价于
|
|
// import * as React from 'react';
|
|
// import ReactDOM from 'react-dom/client';
|
|
// import * as redi from '@wendellhu/redi';
|
|
// import * as rediReact from '@wendellhu/redi/react-bindings';
|
|
// import * as rxjs from 'rxjs';
|
|
// import * as reactSpring from '@react-spring/web';
|
|
const { React, ReactDOM, redi, rediReact, rxjs, reactSpring } = globalThis.__tmp;
|
|
delete globalThis.__tmp;
|
|
|
|
// keep unused import for redi and rediReact
|
|
const {
|
|
Inject,
|
|
Injector,
|
|
LookUp,
|
|
Many,
|
|
Optional,
|
|
Quantity,
|
|
RediError,
|
|
Self,
|
|
SkipSelf,
|
|
WithNew,
|
|
createIdentifier,
|
|
forwardRef,
|
|
isAsyncDependencyItem,
|
|
isAsyncHook,
|
|
isClassDependencyItem,
|
|
isCtor,
|
|
isDisposable,
|
|
isFactoryDependencyItem,
|
|
isValueDependencyItem,
|
|
setDependencies,
|
|
} = redi;
|
|
const {
|
|
RediConsumer,
|
|
RediContext,
|
|
RediProvider,
|
|
WithDependency,
|
|
connectDependencies,
|
|
connectInjector,
|
|
useDependency,
|
|
useInjector,
|
|
useObservable,
|
|
useUpdateBinder,
|
|
} = rediReact;
|
|
const {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useReducer,
|
|
Fragment,
|
|
useCallback,
|
|
createContext,
|
|
useContext,
|
|
} = React;
|
|
const { BehaviorSubject, Subject, fromEvent, map, filter } = rxjs;
|
|
const { useSpring, useTransition, animated } = reactSpring;
|
|
|
|
// 1. FRAMEWORK: CORE COMMUNICATION SERVICES
|
|
|
|
const IApiService = createIdentifier('IApiService');
|
|
|
|
class ApiService {
|
|
constructor() {
|
|
this.reqIdCounter = 0;
|
|
this.pendingRpcs = new Map();
|
|
this.activeJobs = new Map();
|
|
this.activeSessions = new Map();
|
|
this.broadcastChannels = new Map();
|
|
|
|
fromEvent(window, 'message')
|
|
.pipe(filter(event => event.data && event.data.type === 'backendResponse'))
|
|
.pipe(map(event => JSON.parse(event.data.data)))
|
|
.subscribe(msg => this._handleBackendMessage(msg));
|
|
}
|
|
|
|
// --- Public Methods for Hooks ---
|
|
|
|
rpc(method, params) {
|
|
return new Promise((resolve, reject) => {
|
|
const reqId = `req-${this.reqIdCounter++}`;
|
|
this.pendingRpcs.set(reqId, { resolve, reject });
|
|
this._sendMessage({ type: 'RPC', reqId, method, params });
|
|
setTimeout(() => {
|
|
if (this.pendingRpcs.has(reqId)) {
|
|
this.pendingRpcs.delete(reqId);
|
|
reject(new Error(`RPC call '${method}' timed out.`));
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
startJob(method, params) {
|
|
const subject = new Subject();
|
|
const reqId = `req-${this.reqIdCounter++}`;
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
this.pendingRpcs.set(reqId, {
|
|
resolve: ({ jobId }) => {
|
|
this.activeJobs.set(jobId, subject);
|
|
resolve({ jobId }); // Resolve promise with jobId once started
|
|
},
|
|
reject
|
|
});
|
|
});
|
|
|
|
this._sendMessage({ type: 'JOB_START', reqId, method, params });
|
|
return { startPromise: promise, updates$: subject.asObservable() };
|
|
}
|
|
|
|
connectSession(channel, payload) {
|
|
const sessionId = `${channel}-${Math.random().toString(36).substr(2, 9)}`;
|
|
if (!this.activeSessions.has(sessionId)) {
|
|
const subject = new Subject();
|
|
this.activeSessions.set(sessionId, subject);
|
|
this._sendMessage({ type: 'SESSION_CONNECT', sessionId, channel, payload });
|
|
}
|
|
return { sessionId, messages$: this.activeSessions.get(sessionId).asObservable() };
|
|
}
|
|
|
|
disconnectSession(sessionId, channel) {
|
|
if (this.activeSessions.has(sessionId)) {
|
|
this.activeSessions.get(sessionId).complete();
|
|
this.activeSessions.delete(sessionId);
|
|
this._sendMessage({ type: 'SESSION_DISCONNECT', sessionId, channel });
|
|
}
|
|
}
|
|
|
|
subscribeBroadcast(channel) {
|
|
if (!this.broadcastChannels.has(channel)) {
|
|
this.broadcastChannels.set(channel, new Subject());
|
|
}
|
|
return this.broadcastChannels.get(channel).asObservable();
|
|
}
|
|
|
|
// --- Internal Message Handling ---
|
|
|
|
_sendMessage(data) {
|
|
window.parent.sendMessage('frontend:message', data);
|
|
}
|
|
|
|
_handleBackendMessage(msg) {
|
|
const { type, reqId, jobId, sessionId, channel } = msg;
|
|
|
|
switch (type) {
|
|
case 'RPC_SUCCESS':
|
|
this.pendingRpcs.get(reqId)?.resolve(msg.data);
|
|
this.pendingRpcs.delete(reqId);
|
|
break;
|
|
case 'RPC_ERROR':
|
|
this.pendingRpcs.get(reqId)?.reject({ message: msg.message, traceback: msg.traceback });
|
|
this.pendingRpcs.delete(reqId);
|
|
break;
|
|
case 'JOB_STARTED':
|
|
this.pendingRpcs.get(reqId)?.resolve({ jobId });
|
|
this.pendingRpcs.delete(reqId);
|
|
break;
|
|
case 'JOB_PROGRESS':
|
|
this.activeJobs.get(jobId)?.next({ type: 'progress', ...msg });
|
|
break;
|
|
case 'JOB_SUCCESS':
|
|
this.activeJobs.get(jobId)?.next({ type: 'success', ...msg });
|
|
this.activeJobs.get(jobId)?.complete();
|
|
this.activeJobs.delete(jobId);
|
|
break;
|
|
case 'JOB_ERROR':
|
|
this.activeJobs.get(jobId)?.next({ type: 'error', ...msg });
|
|
this.activeJobs.get(jobId)?.complete();
|
|
this.activeJobs.delete(jobId);
|
|
break;
|
|
case 'EVENT_BROADCAST':
|
|
this.broadcastChannels.get(channel)?.next(msg.data);
|
|
break;
|
|
case 'EVENT_SESSION':
|
|
this.activeSessions.get(sessionId)?.next(msg.data);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
setDependencies(ApiService, []);
|
|
|
|
// --- High-Level React Hooks (Simplifying Boilerplate) ---
|
|
|
|
function useRpc(method) {
|
|
const api = useDependency(IApiService);
|
|
const [state, setState] = useState({ isLoading: false, data: null, error: null });
|
|
|
|
const execute = useCallback(async (params) => {
|
|
setState({ isLoading: true, data: null, error: null });
|
|
try {
|
|
const result = await api.rpc(method, params);
|
|
setState({ isLoading: false, data: result, error: null });
|
|
return result;
|
|
} catch (err) {
|
|
setState({ isLoading: false, data: null, error: err });
|
|
throw err;
|
|
}
|
|
}, [api, method]);
|
|
return { ...state, execute };
|
|
}
|
|
|
|
const initialJobState = { status: 'idle', progress: { percent: 0, message: '' }, data: null, error: null };
|
|
function useJob(method) {
|
|
const api = useDependency(IApiService);
|
|
const [state, setState] = useState(initialJobState);
|
|
const subRef = useRef(null);
|
|
|
|
const start = useCallback((params) => {
|
|
setState({ ...initialJobState, status: 'starting' });
|
|
const { startPromise, updates$ } = api.startJob(method, params);
|
|
|
|
startPromise.then(({ jobId }) => {
|
|
subRef.current = updates$.subscribe({
|
|
next: (update) => {
|
|
if (update.type === "JOB_PROGRESS") setState(s => ({ ...s, status: 'running', progress: update.progress }));
|
|
else if (update.type === "JOB_SUCCESS") setState(s => {
|
|
s.progress.percent = 100;
|
|
return { ...s, status: 'success', data: update.data };
|
|
})
|
|
else if (update.type === 'JOB_ERROR') setState(s => ({ ...s, status: 'error', error: { message: update.message, traceback: update.traceback } }));
|
|
},
|
|
error: (err) => setState(s => ({ ...s, status: 'error', error: err })),
|
|
complete: () => {
|
|
// Handled by success/error messages
|
|
}
|
|
});
|
|
}).catch(err => {
|
|
setState({ ...initialJobState, status: 'error', error: err });
|
|
});
|
|
}, [api, method]);
|
|
|
|
useEffect(() => {
|
|
// Cleanup subscription on unmount
|
|
return () => subRef.current?.unsubscribe();
|
|
}, []);
|
|
|
|
return { ...state, start };
|
|
}
|
|
|
|
function usePush(channel, options = {}) {
|
|
const api = useDependency(IApiService);
|
|
const [latestMessage, setLatestMessage] = useState(null);
|
|
|
|
useEffect(() => {
|
|
let sub;
|
|
let sessionId;
|
|
|
|
if (options.isSession) {
|
|
const session = api.connectSession(channel, options.payload);
|
|
sessionId = session.sessionId;
|
|
sub = session.messages$.subscribe(setLatestMessage);
|
|
} else {
|
|
sub = api.subscribeBroadcast(channel).subscribe(setLatestMessage);
|
|
}
|
|
|
|
return () => {
|
|
sub?.unsubscribe();
|
|
if (sessionId) {
|
|
api.disconnectSession(sessionId, channel);
|
|
}
|
|
};
|
|
}, [api, channel, options.isSession, JSON.stringify(options.payload)]); // re-run if payload changes
|
|
|
|
return { latestMessage };
|
|
}
|
|
|
|
// 3. NEW: I18N Service for multi-language support
|
|
const II18nService = createIdentifier('II18nService');
|
|
const ITranslationSource = createIdentifier('ITranslationSource');
|
|
|
|
class I18nService {
|
|
constructor(apiService, translationSources) {
|
|
this.apiService = apiService;
|
|
this._mergedTranslations = this._mergeSources(translationSources);
|
|
this.language$ = new BehaviorSubject('en'); // Default to English
|
|
|
|
this.translations$ = this.language$.pipe(
|
|
map(lang => this._mergedTranslations[lang] || this._mergedTranslations['en'])
|
|
);
|
|
}
|
|
|
|
// Deep merge an array of translation source objects
|
|
_mergeSources(sources) {
|
|
const merged = {};
|
|
for (const source of sources) {
|
|
for (const lang in source) {
|
|
if (!merged[lang]) {
|
|
merged[lang] = {};
|
|
}
|
|
// Simple, non-recursive merge is sufficient here
|
|
Object.assign(merged[lang], source[lang]);
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
const env = await this.apiService.rpc('app:getEnvironment', {});
|
|
// Use only the primary language code (e.g., 'en' from 'en-US')
|
|
const lang = env.language.split('-')[0].toLowerCase();
|
|
this.language$.next(lang);
|
|
} catch (e) {
|
|
console.error("Failed to fetch environment info for language.", e);
|
|
// Keep the default 'en'
|
|
}
|
|
}
|
|
}
|
|
setDependencies(I18nService, [IApiService, [new Many(), ITranslationSource]]);
|
|
|
|
const coreTranslations = {
|
|
en: {
|
|
'Refresh': 'Refresh',
|
|
'Mendix Log Extractor': 'Mendix Log Extractor',
|
|
'Overview': 'Overview',
|
|
'Studio Pro Logs': 'Studio Pro Logs',
|
|
'Git Logs': 'Git Logs',
|
|
'App Logs': 'App Logs',
|
|
'Modules': 'Modules',
|
|
'Dependencies': 'Dependencies',
|
|
'Forum Export Tab': 'Forum Export',
|
|
},
|
|
zh: {
|
|
'Refresh': '刷新',
|
|
'Mendix Log Extractor': 'Mendix 日志提取器',
|
|
'Overview': '概览',
|
|
'Studio Pro Logs': 'Studio Pro 日志',
|
|
'Git Logs': 'Git 日志',
|
|
'App Logs': '应用日志',
|
|
'Modules': '模块',
|
|
'Dependencies': '依赖项',
|
|
'Forum Export Tab': '论坛导出',
|
|
}
|
|
};
|
|
|
|
const sidebarTranslations = {
|
|
en: {
|
|
'Global Actions': 'Global Actions',
|
|
'Extract All Logs': 'Extract All Logs',
|
|
'Forum Export': 'Forum Export',
|
|
'Generate a complete diagnostic report...': 'Generate a complete diagnostic report formatted for forum posts. The result will be shown in the \'Forum Export\' tab.',
|
|
'Generate Export': 'Generate Export',
|
|
'Generating...': 'Generating...',
|
|
},
|
|
zh: {
|
|
'Global Actions': '全局操作',
|
|
'Extract All Logs': '提取所有日志',
|
|
'Forum Export': '论坛导出',
|
|
'Generate a complete diagnostic report...': '生成格式化为论坛帖子的完整诊断报告。结果将显示在“论坛导出”选项卡中。',
|
|
'Generate Export': '生成导出',
|
|
'Generating...': '生成中...',
|
|
}
|
|
};
|
|
|
|
const overviewPanelTranslations = {
|
|
en: {
|
|
'Mendix Log Extractor Overview': 'Mendix Log Extractor Overview',
|
|
'Mendix Version': 'Mendix Version',
|
|
'Project Path': 'Project Path',
|
|
'Click "Extract All Logs"...': 'Click "Extract All Logs" in the sidebar to begin diagnostics.',
|
|
},
|
|
zh: {
|
|
'Mendix Log Extractor Overview': 'Mendix 日志提取器概览',
|
|
'Mendix Version': 'Mendix 版本',
|
|
'Project Path': '项目路径',
|
|
'Click "Extract All Logs"...': '点击侧边栏的“提取所有日志”以开始诊断。',
|
|
}
|
|
};
|
|
|
|
const logViewerPanelTranslations = {
|
|
en: {
|
|
'Lines': 'Lines',
|
|
'Log Path': 'Log Path',
|
|
'Select a log file': 'Select a log file',
|
|
'No app logs found...': 'No app logs found in deployment/log directory.'
|
|
},
|
|
zh: {
|
|
'Lines': '行数',
|
|
'Log Path': '日志路径',
|
|
'Select a log file': '选择一个日志文件',
|
|
'No app logs found...': '在 deployment/log 目录中未找到应用日志。'
|
|
},
|
|
};
|
|
|
|
const modulesPanelTranslations = {
|
|
en: { 'Project Modules': 'Project Modules', 'Version': 'Version' },
|
|
zh: { 'Project Modules': '项目模块', 'Version': '版本' },
|
|
};
|
|
|
|
const forumPanelTranslations = {
|
|
en: {
|
|
'Forum Export Preview': 'Forum Export Preview',
|
|
'Copy to Clipboard': 'Copy to Clipboard',
|
|
'Copied!': '✓ Copied!',
|
|
'Go to Forum to Ask': 'Go to Forum to Ask',
|
|
'Click "Generate Export"...': 'Click "Generate Export" in the sidebar on the right to create forum-formatted content.',
|
|
'The generated report will appear here...': 'The generated report will appear here for you to review and copy.',
|
|
},
|
|
zh: {
|
|
'Forum Export Preview': '论坛导出预览',
|
|
'Copy to Clipboard': '复制到剪贴板',
|
|
'Copied!': '✓ 已复制!',
|
|
'Go to Forum to Ask': '前往论坛提问',
|
|
'Click "Generate Export"...': '点击右侧边栏的“生成导出”以创建论坛格式的内容。',
|
|
'The generated report will appear here...': '生成的报告将在此处显示,供您查看和复制。',
|
|
}
|
|
};
|
|
|
|
function useTranslation() {
|
|
const i18nService = useDependency(II18nService);
|
|
const currentTranslations = useObservable(i18nService.translations$);
|
|
|
|
const t = useCallback((key) => {
|
|
return currentTranslations[key] || key;
|
|
}, [currentTranslations]);
|
|
|
|
return { t };
|
|
}
|
|
const IPanel = createIdentifier("IPanel");
|
|
const IPanelService = createIdentifier("IPanelService");
|
|
class PanelService {
|
|
constructor(panels) {
|
|
this.allPanels = new Map(panels.map(p => [p.id, p]));
|
|
this._state$ = new BehaviorSubject(this.getInitialState());
|
|
this.state$ = this._state$.asObservable();
|
|
|
|
// For shared data from the extract-all job
|
|
this.extractedData$ = new BehaviorSubject(null);
|
|
}
|
|
|
|
setExtractedData = (data) => {
|
|
this.extractedData$.next(data);
|
|
}
|
|
|
|
getInitialState() {
|
|
const initialOpenIds = new Set(
|
|
Array.from(this.allPanels.values())
|
|
.filter(p => p.defaultActive)
|
|
.map(p => p.id)
|
|
);
|
|
const initialActiveId = Array.from(this.allPanels.values())
|
|
.find(p => p.defaultActive)?.id || null;
|
|
return this._computeState(initialOpenIds, initialActiveId);
|
|
}
|
|
|
|
// Private method to compute the full state object from IDs
|
|
_computeState(openPanelIds, activePanelId) {
|
|
const openPanels = Array.from(openPanelIds)
|
|
.map(id => this.allPanels.get(id))
|
|
.filter(Boolean); // Filter out potential undefineds
|
|
const activePanel = activePanelId ? this.allPanels.get(activePanelId) : null;
|
|
|
|
return { openPanels, activePanel };
|
|
}
|
|
|
|
// Private method to update the stream
|
|
_updateState(newOpenIds, newActiveId) {
|
|
const newState = this._computeState(newOpenIds, newActiveId);
|
|
this._state$.next(newState);
|
|
}
|
|
|
|
// --- Public API for Panel Control ---
|
|
openPanel(id) {
|
|
if (!this.allPanels.has(id)) return;
|
|
|
|
const currentState = this._state$.getValue();
|
|
const currentOpenIds = new Set(currentState.openPanels.map(p => p.id));
|
|
|
|
if (currentOpenIds.has(id)) {
|
|
this.setActivePanel(id);
|
|
return;
|
|
}
|
|
|
|
currentOpenIds.add(id);
|
|
this._updateState(currentOpenIds, id);
|
|
}
|
|
|
|
closePanel(id) {
|
|
const currentState = this._state$.getValue();
|
|
const currentOpenIds = new Set(currentState.openPanels.map(p => p.id));
|
|
if (!currentOpenIds.has(id)) return;
|
|
|
|
currentOpenIds.delete(id);
|
|
let newActiveId = currentState.activePanel?.id;
|
|
if (newActiveId === id) {
|
|
newActiveId = currentOpenIds.values().next().value || null;
|
|
}
|
|
this._updateState(currentOpenIds, newActiveId);
|
|
}
|
|
|
|
setActivePanel(id) {
|
|
const currentState = this._state$.getValue();
|
|
if (currentState.activePanel?.id === id) return;
|
|
|
|
const currentOpenIds = new Set(currentState.openPanels.map(p => p.id));
|
|
if (!currentOpenIds.has(id)) return; // Can't activate a panel that isn't open
|
|
|
|
this._updateState(currentOpenIds, id);
|
|
}
|
|
}
|
|
setDependencies(PanelService, [[new Many(), IPanel]]);
|
|
|
|
const ErrorDisplay = ({ error }) => {
|
|
if (!error) return null;
|
|
const [showTraceback, setShowTraceback] = useState(false);
|
|
|
|
return (
|
|
<div className="mt-4 p-3 bg-red-100 text-red-800 rounded-lg border border-red-200">
|
|
<div className="flex justify-between items-center">
|
|
<p className="font-semibold break-all">Error: {error.message}</p>
|
|
{error.traceback && (
|
|
<button
|
|
onClick={() => setShowTraceback(!showTraceback)}
|
|
className="text-xs text-red-600 hover:text-red-800 font-medium ml-4 flex-shrink-0"
|
|
>
|
|
{showTraceback ? 'Hide Details' : 'Show Details'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{showTraceback && error.traceback && (
|
|
<pre className="mt-3 p-2 bg-red-50 text-xs text-red-900 overflow-auto rounded font-mono">
|
|
<code>{error.traceback}</code>
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===================================================================
|
|
// =============== BUSINESS LOGIC CODE =======================
|
|
// ===================================================================
|
|
// This section contains your feature-specific components (Views).
|
|
// To add a new feature, add your new Component here and register
|
|
// it as an `IView` in the IOC Configuration section below.
|
|
// -------------------------------------------------------------------
|
|
const IDataService = createIdentifier('IDataService');
|
|
|
|
class DataService {
|
|
constructor(apiService) {
|
|
this.apiService = apiService;
|
|
|
|
// Observables for different data pieces
|
|
this.overviewData$ = new BehaviorSubject({ isLoading: true, data: null, error: null });
|
|
this.appLogSources$ = new BehaviorSubject({ isLoading: true, data: [], error: null });
|
|
this.logContentCache$ = new BehaviorSubject(new Map());
|
|
this.modules$ = new BehaviorSubject({ isLoading: true, data: null, error: null });
|
|
this.dependencies$ = new BehaviorSubject({ isLoading: true, data: null, error: null });
|
|
this.frontendComponents$ = new BehaviorSubject({ isLoading: true, data: null, error: null });
|
|
this.forumExport$ = new BehaviorSubject({ isLoading: false, data: null, error: null });
|
|
}
|
|
|
|
async _fetchData(method, params, subject$) {
|
|
subject$.next({ ...subject$.getValue(), isLoading: true, error: null });
|
|
try {
|
|
const data = await this.apiService.rpc(method, params);
|
|
subject$.next({ isLoading: false, data, error: null });
|
|
} catch (error) {
|
|
subject$.next({ isLoading: false, data: null, error });
|
|
}
|
|
}
|
|
|
|
_updateCache(logId, state) {
|
|
this.logContentCache$.next(
|
|
new Map(this.logContentCache$.getValue()).set(logId, state)
|
|
);
|
|
}
|
|
|
|
fetchLogContent = async (logId, params) => {
|
|
this._updateCache(logId, { isLoading: true, data: null, error: null });
|
|
try {
|
|
const data = await this.apiService.rpc('logs:getContent', { ...params, id: logId });
|
|
this._updateCache(logId, { isLoading: false, data, error: null });
|
|
} catch (error) {
|
|
this._updateCache(logId, { isLoading: false, data: null, error });
|
|
}
|
|
}
|
|
|
|
fetchOverview = () => this._fetchData('app:getEnvironment', {}, this.overviewData$);
|
|
listAppLogSources = () => this._fetchData('logs:listAppLogSources', {}, this.appLogSources$);
|
|
fetchModules = () => this._fetchData('logs:getModulesInfo', {}, this.modules$);
|
|
fetchJarDependencies = () => this._fetchData('logs:getJarDependencies', {}, this.dependencies$);
|
|
fetchFrontendComponents = () => this._fetchData('logs:getFrontendComponents', {}, this.frontendComponents$);
|
|
generateForumExport = () => this._fetchData('logs:generateCompleteForumExport', {}, this.forumExport$);
|
|
}
|
|
setDependencies(DataService, [IApiService]);
|
|
|
|
|
|
const OverviewPanel = () => {
|
|
const { t } = useTranslation();
|
|
const dataService = useDependency(IDataService);
|
|
const { isLoading, data, error } = useObservable(dataService.overviewData$, dataService.overviewData$.getValue());
|
|
const extractedData = useObservable(useDependency(IPanelService).extractedData$, null);
|
|
|
|
useEffect(() => {
|
|
if (dataService.overviewData$.getValue().data === null) {
|
|
// This RPC was changed in the previous step, but the old name is used here.
|
|
// Correcting it to 'app:getEnvironment' to align with previous changes.
|
|
dataService.apiService.rpc('app:getEnvironment', {}).then(envData => {
|
|
dataService.overviewData$.next({ isLoading: false, data: envData, error: null });
|
|
}).catch(err => {
|
|
dataService.overviewData$.next({ isLoading: false, data: null, error: err });
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
if (isLoading && !data) return <div>Loading project info...</div>;
|
|
if (error) return <ErrorDisplay error={error} />;
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-gray-700 mb-4">{t('Mendix Log Extractor Overview')}</h2>
|
|
{data && (
|
|
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
|
<p><span className="font-semibold">{t('Mendix Version')}:</span> {data.version}</p>
|
|
<p><span className="font-semibold">{t('Project Path')}:</span> {data.projectPath}</p>
|
|
</div>
|
|
)}
|
|
{extractedData ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* Cards showing summary, similar to original */}
|
|
</div>
|
|
) : (
|
|
<div className="text-center p-6 bg-gray-50 rounded-lg">
|
|
<p className="text-gray-600">
|
|
{t('Click "Extract All Logs"...')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
const LogViewerPanel = ({ title, logId, icon }) => {
|
|
const { t } = useTranslation();
|
|
const dataService = useDependency(IDataService);
|
|
const cache = useObservable(dataService.logContentCache$, dataService.logContentCache$.getValue());
|
|
const { isLoading, data, error } = cache.get(logId) || { isLoading: true, data: null, error: null };
|
|
|
|
const [lineCount, setLineCount] = useState(100);
|
|
|
|
const handleRefresh = () => {
|
|
dataService.fetchLogContent(logId, { limit: parseInt(lineCount, 10) || 100 });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!cache.has(logId)) {
|
|
handleRefresh();
|
|
}
|
|
}, [logId]);
|
|
|
|
const renderLogContent = () => {
|
|
if (isLoading && !data) return <div className="text-gray-500 italic">Loading...</div>;
|
|
if (error) return <ErrorDisplay error={error} />;
|
|
if (!data) return <div className="text-gray-500 italic">No data available.</div>;
|
|
if (data.error) return <div className="text-red-600">Error: {data.error}</div>;
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
|
<p><span className="font-semibold">{t('Log Path')}:</span> {data.logPath || 'N/A'}</p>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{data.exists ? `✅ File exists. Showing last ${data.lines?.length || 0} of ${data.totalLines} lines.` : '❌ File not found'}
|
|
</p>
|
|
</div>
|
|
{data.lines && data.lines.length > 0 ? (
|
|
<div className="log-container bg-gray-900 text-green-400 p-4 rounded-lg">
|
|
{data.lines.map((line, index) => <div key={index}>{line.trim()}</div>)}
|
|
</div>
|
|
) : (
|
|
<div className="text-gray-500 italic">No log entries found.</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-bold flex items-center space-x-2">
|
|
<span>{icon}</span><span>{t(title)}</span>
|
|
</h2>
|
|
<div className="flex items-center space-x-2">
|
|
<label htmlFor="line-count" className="text-sm">{t('Lines')}:</label>
|
|
<input
|
|
id="line-count"
|
|
type="number"
|
|
value={lineCount}
|
|
onChange={(e) => setLineCount(e.target.value)}
|
|
className="w-20 p-1 border rounded"
|
|
min="1"
|
|
max="5000"
|
|
/>
|
|
<button onClick={handleRefresh} disabled={isLoading} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400">
|
|
{t('Refresh')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{renderLogContent()}
|
|
</div>
|
|
);
|
|
};
|
|
const AppLogsPanel = () => {
|
|
const { t } = useTranslation();
|
|
const dataService = useDependency(IDataService);
|
|
const { isLoading: sourcesLoading, data: sources, error: sourcesError } = useObservable(dataService.appLogSources$, dataService.appLogSources$.getValue());
|
|
const [selectedLogId, setSelectedLogId] = useState('');
|
|
|
|
useEffect(() => {
|
|
dataService.listAppLogSources();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Auto-select the first log file if available and none is selected
|
|
if (sources && sources.length > 0 && !selectedLogId) {
|
|
setSelectedLogId(sources[0].id);
|
|
}
|
|
}, [sources, selectedLogId]);
|
|
|
|
const handleSelectionChange = (e) => {
|
|
const newId = e.target.value;
|
|
setSelectedLogId(newId);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex justify-between items-center mb-4 flex-shrink-0">
|
|
<h2 className="text-xl font-bold flex items-center space-x-2">
|
|
<span>🚀</span><span>{t('App Logs')}</span>
|
|
</h2>
|
|
{sources && sources.length > 0 && (
|
|
<select
|
|
value={selectedLogId}
|
|
onChange={handleSelectionChange}
|
|
className="p-2 border rounded bg-white"
|
|
>
|
|
<option value="" disabled>{t('Select a log file')}</option>
|
|
{sources.map(source => (
|
|
<option key={source.id} value={source.id}>{source.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-grow overflow-auto">
|
|
{sourcesLoading && <div>Loading log sources...</div>}
|
|
<ErrorDisplay error={sourcesError} />
|
|
{sources && sources.length === 0 && !sourcesLoading && (
|
|
<div className="text-gray-500 italic text-center p-4">{t('No app logs found...')}</div>
|
|
)}
|
|
|
|
{selectedLogId && (
|
|
<LogViewerPanel title="" logId={selectedLogId} icon=""/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ActionsSidebar = () => {
|
|
const { t } = useTranslation();
|
|
const dataService = useDependency(IDataService);
|
|
const panelService = useDependency(IPanelService);
|
|
const { isLoading: isExporting, data: exportData, error: exportError } = useObservable(dataService.forumExport$, dataService.forumExport$.getValue());
|
|
|
|
useEffect(() => {
|
|
if (exportData && !isExporting && !exportError) {
|
|
panelService.setActivePanel('forum');
|
|
}
|
|
}, [exportData, isExporting, exportError, panelService]);
|
|
|
|
return (
|
|
<div className="bg-white p-4 rounded-lg shadow-md space-y-6 h-full">
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-semibold text-lg mb-2">{t('Forum Export')}</h3>
|
|
<p className="text-sm text-gray-600 mb-3">
|
|
{t('Generate a complete diagnostic report...')}
|
|
</p>
|
|
<button
|
|
onClick={dataService.generateForumExport}
|
|
disabled={isExporting}
|
|
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 flex items-center justify-center space-x-2"
|
|
>
|
|
{isExporting && <div className="loading-spinner"></div>}
|
|
<span>{isExporting ? t('Generating...') : t('Generate Export')}</span>
|
|
</button>
|
|
<ErrorDisplay error={exportError} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
const ModulesPanel = () => {
|
|
const { t } = useTranslation();
|
|
const { isLoading, data, error, execute } = useRpc('logs:getModulesInfo');
|
|
|
|
useEffect(() => {
|
|
execute();
|
|
}, []);
|
|
|
|
const renderModules = () => {
|
|
if (isLoading) return <div className="text-gray-500 italic">Loading...</div>;
|
|
if (error) return <ErrorDisplay error={error} />;
|
|
if (!data || data.length === 0) return <div className="text-gray-500 italic">No modules found.</div>;
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{data.map((module, index) => (
|
|
<div key={index} className="bg-white p-4 rounded-lg shadow border">
|
|
<h4 className="font-bold text-lg mb-2">{module.name}</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span>{t('Version')}:</span>
|
|
<span className="font-semibold">{module.version}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Id:</span>
|
|
<span className="font-semibold">{module.id}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-bold">{t('Project Modules')}</h2>
|
|
<button
|
|
onClick={() => execute()}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:bg-gray-400"
|
|
>
|
|
{t('Refresh')}
|
|
</button>
|
|
</div>
|
|
{renderModules()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DependenciesPanel = () => {
|
|
const { t } = useTranslation();
|
|
const { isLoading: jarsLoading, data: jarsData, error: jarsError, execute: executeJars } = useRpc('logs:getJarDependencies');
|
|
const { isLoading: frontendLoading, data: frontendData, error: frontendError, execute: executeFrontend } = useRpc('logs:getFrontendComponents');
|
|
|
|
useEffect(() => {
|
|
executeJars();
|
|
executeFrontend();
|
|
}, []);
|
|
|
|
const widgets = frontendData?.filter(item => item.type === 'Widget') || [];
|
|
const jsActions = frontendData?.filter(item => item.type === 'JavaScript Action') || [];
|
|
|
|
const renderList = (items, titleIfEmpty) => {
|
|
if (!items || items.length === 0) {
|
|
return <div className="text-gray-500 italic p-4">No {titleIfEmpty.toLowerCase()} found.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2 max-h-60 overflow-y-auto p-2 border rounded-md bg-gray-50">
|
|
{items.map((item, index) => (
|
|
<div key={index} className="bg-white p-2 rounded-lg shadow-sm border flex justify-between items-center">
|
|
<div>
|
|
<div className="font-semibold text-sm">{item.name}</div>
|
|
<div className="text-xs text-gray-500 truncate" title={item.path}>{item.path}</div>
|
|
</div>
|
|
<div className="text-xs text-gray-500 flex-shrink-0 ml-2">
|
|
{item.size != null ? `${(item.size / 1024).toFixed(1)} KB` : 'N/A'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* JARs section */}
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h2 className="text-xl font-bold">{t('JAR Dependencies')} ({jarsData?.length || 0})</h2>
|
|
<button
|
|
onClick={() => executeJars()}
|
|
disabled={jarsLoading}
|
|
className="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 text-sm"
|
|
>
|
|
{t('Refresh')}
|
|
</button>
|
|
</div>
|
|
{jarsLoading ? (
|
|
<div className="text-gray-500 italic">Loading JAR dependencies...</div>
|
|
) : jarsError ? (
|
|
<ErrorDisplay error={jarsError} />
|
|
) : (
|
|
renderList(jarsData, t('JAR Dependencies'))
|
|
)}
|
|
</div>
|
|
|
|
{/* Frontend Components section */}
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h2 className="text-xl font-bold">{t('Frontend Components')}</h2>
|
|
<button
|
|
onClick={() => executeFrontend()}
|
|
disabled={frontendLoading}
|
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 text-sm"
|
|
>
|
|
{t('Refresh')}
|
|
</button>
|
|
</div>
|
|
{frontendLoading ? (
|
|
<div className="text-gray-500 italic">Loading frontend components...</div>
|
|
) : frontendError ? (
|
|
<ErrorDisplay error={frontendError} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="font-semibold text-lg">{t('Widgets')} ({widgets.length})</h3>
|
|
{renderList(widgets, t('Widgets'))}
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-lg">{t('JavaScript Actions')} ({jsActions.length})</h3>
|
|
{renderList(jsActions, t('JavaScript Actions'))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ForumExportPanel = () => {
|
|
const { t } = useTranslation();
|
|
const dataService = useDependency(IDataService);
|
|
const { isLoading, data, error } = useObservable(dataService.forumExport$, dataService.forumExport$.getValue());
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
|
|
const copyToClipboard = (text) => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setIsCopied(true);
|
|
setTimeout(() => setIsCopied(false), 3000);
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err);
|
|
alert('Failed to copy to clipboard');
|
|
});
|
|
};
|
|
|
|
const openForum = () => {
|
|
window.open('https://mendix.bbscloud.com/', '_blank');
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-bold">{t('Forum Export Preview')}</h2>
|
|
<div className="flex items-center space-x-2">
|
|
{isCopied && <span className="text-sm text-green-600 transition-opacity duration-300">{t('Copied!')}</span>}
|
|
{data?.formattedText && (
|
|
<button
|
|
onClick={() => copyToClipboard(data.formattedText)}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
{t('Copy to Clipboard')}
|
|
</button>
|
|
)}
|
|
{isCopied && (
|
|
<button
|
|
onClick={openForum}
|
|
className="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 animate-pulse"
|
|
>
|
|
{t('Go to Forum to Ask')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ErrorDisplay error={error} />
|
|
|
|
{isLoading ? (
|
|
<div className="flex justify-center items-center h-64">
|
|
<div className="loading-spinner" style={{ width: '40px', height: '40px' }}></div>
|
|
</div>
|
|
) : data?.formattedText ? (
|
|
<div className="forum-preview h-[calc(100vh-250px)]">
|
|
<pre>{data.formattedText}</pre>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-500 p-8 bg-gray-50 rounded-lg">
|
|
<p>{t('Click "Generate Export"...')}</p>
|
|
<p className="text-sm mt-2">{t('The generated report will appear here...')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===================================================================
|
|
// ============== IOC & APP INITIALIZATION ===================
|
|
// ===================================================================
|
|
|
|
const AppWithDependencies = connectDependencies(() => {
|
|
const i18nService = useDependency(II18nService);
|
|
const [isI18nReady, setI18nReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
await i18nService.initialize();
|
|
setI18nReady(true);
|
|
}
|
|
init();
|
|
}, [i18nService]);
|
|
|
|
const { t } = useTranslation();
|
|
const panelService = useDependency(IPanelService);
|
|
const { openPanels, activePanel } = useObservable(panelService.state$, panelService.getInitialState());
|
|
|
|
const panelTransitions = useTransition(activePanel, {
|
|
key: panel => panel?.id,
|
|
from: { opacity: 0, transform: 'translateY(10px)' },
|
|
enter: { opacity: 1, transform: 'translateY(0px)' },
|
|
leave: { opacity: 0, transform: 'translateY(-10px)', position: 'absolute', top: 0, left: 0, right: 0 },
|
|
config: { tension: 220, friction: 25 },
|
|
});
|
|
|
|
if (!isI18nReady) {
|
|
return <div className="flex items-center justify-center h-screen">Initializing...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 max-w-7xl mx-auto h-[100vh] flex flex-col">
|
|
<h1 className="text-3xl font-bold text-gray-800 mb-4 text-center">
|
|
{t('Mendix Log Extractor')}
|
|
</h1>
|
|
<div className="flex-grow grid grid-cols-1 lg:grid-cols-3 gap-4 overflow-hidden">
|
|
{/* Main Content Area */}
|
|
<div className="lg:col-span-2 bg-gray-50 shadow-lg rounded-lg flex flex-col">
|
|
<div className="flex border-b border-gray-300 flex-wrap">
|
|
{openPanels.map(panel => (
|
|
<button
|
|
key={panel.id}
|
|
onClick={() => panelService.setActivePanel(panel.id)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 ${panel.id === activePanel?.id
|
|
? 'border-blue-600 text-blue-700'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
{t(panel.title)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex-grow p-4 bg-white relative overflow-auto">
|
|
{panelTransitions((style, panel) =>
|
|
panel ? (
|
|
<animated.div style={style} className="w-full h-full">
|
|
<panel.component />
|
|
</animated.div>
|
|
) : (
|
|
<div>No panel selected.</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="lg:col-span-1">
|
|
<ActionsSidebar />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, [
|
|
// --- Framework Registrations ---
|
|
[IApiService, { useClass: ApiService }],
|
|
[IPanelService, { useClass: PanelService }],
|
|
[IDataService, { useClass: DataService }],
|
|
[II18nService, { useClass: I18nService }],
|
|
|
|
// --- Register all modular translation sources ---
|
|
[ITranslationSource, { useValue: coreTranslations }],
|
|
[ITranslationSource, { useValue: sidebarTranslations }],
|
|
[ITranslationSource, { useValue: overviewPanelTranslations }],
|
|
[ITranslationSource, { useValue: logViewerPanelTranslations }],
|
|
[ITranslationSource, { useValue: modulesPanelTranslations }],
|
|
[ITranslationSource, { useValue: forumPanelTranslations }],
|
|
|
|
// --- Business Logic Registrations (as Panels) ---
|
|
[IPanel, { useValue: { id: 'overview', title: '🏠 Overview', component: OverviewPanel, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'studio-pro', title: '📝 Studio Pro Logs', component: () => <LogViewerPanel title="Studio Pro Logs" logId="studio_pro" icon="📝" />, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'git', title: '🔀 Git Logs', component: () => <LogViewerPanel title="Git Logs" logId="git" icon="🔀" />, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'app-logs', title: '🚀 App Logs', component: AppLogsPanel, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'modules', title: '📦 Modules', component: ModulesPanel, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'dependencies', title: '🏗️ Dependencies', component: DependenciesPanel, defaultActive: true } }],
|
|
[IPanel, { useValue: { id: 'forum', title: '🌐 Forum Export Tab', component: ForumExportPanel, defaultActive: true } }],
|
|
]);
|
|
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById("app"));
|
|
root.render(<AppWithDependencies />);
|
|
</script>
|
|
</body>
|
|
|
|
</html> |