mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 20:34:19 +08:00
498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
import fs from "fs";
|
|
import uci from "uci";
|
|
import { LuciFlied } from "../../../enum/hijpass";
|
|
|
|
function createLogFileOptions() {
|
|
let options = [];
|
|
|
|
let hijpassLog = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.CONF_NAME, 'log_path');
|
|
options.push(E('option', {'value': hijpassLog}, _('Main Log')));
|
|
|
|
let shuntLog = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'log_path');
|
|
if (shuntLog) {
|
|
options.push(E('option', {'value': shuntLog}, _('Shunt Log')));
|
|
}
|
|
|
|
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (proxySection) => {
|
|
let proxyName = proxySection['.name'];
|
|
let displayName = proxySection.name || proxyName;
|
|
let log_path = proxySection.log_path;
|
|
|
|
if (log_path) {
|
|
options.push(E('option', {'value': log_path}, _('Proxy Log') + ' - ' + displayName));
|
|
}
|
|
});
|
|
|
|
uci.sections(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE, (serverSection) => {
|
|
let proxyName = serverSection['.name'];
|
|
let displayName = serverSection.name || proxyName;
|
|
let log_path = serverSection.log_path;
|
|
|
|
if (log_path) {
|
|
options.push(E('option', {'value': log_path}, _('Server Log') + ' - ' + displayName));
|
|
}
|
|
});
|
|
|
|
return options;
|
|
}
|
|
|
|
function loadLogs(
|
|
logFileSelect: HTMLSelectElement,
|
|
levelFilterSelect: HTMLSelectElement,
|
|
linesCountSelect: HTMLSelectElement,
|
|
statusElement: HTMLElement,
|
|
logContentElement: HTMLElement
|
|
) {
|
|
let logFile = logFileSelect.value;
|
|
let levelFilter = levelFilterSelect.value;
|
|
let linesCount = linesCountSelect.value;
|
|
|
|
updateLogStatus(statusElement, _('Loading Logs'));
|
|
|
|
fs.exec('/usr/bin/tail', ['-n', linesCount, logFile]).then(function (result) {
|
|
let logContent = result.stdout || '';
|
|
|
|
if (levelFilter) {
|
|
let upperFilter = levelFilter.toUpperCase();
|
|
let filteredLines = logContent.split('\n').filter(function (line) {
|
|
let upperLine = line.toUpperCase();
|
|
if (upperFilter === 'WARN') {
|
|
return upperLine.includes('WARN') || upperLine.includes('WARNING');
|
|
}
|
|
|
|
return upperLine.includes(upperFilter);
|
|
});
|
|
logContent = filteredLines.join('\n');
|
|
}
|
|
|
|
logContent = highlightLogContent(logContent);
|
|
|
|
logContentElement.innerHTML = logContent;
|
|
logContentElement.scrollTop = logContentElement.scrollHeight;
|
|
|
|
updateLogStatus(statusElement, _('Log Updated') + ' - ' + new Date().toLocaleTimeString());
|
|
}).catch(function (error) {
|
|
updateLogStatus(statusElement, _('Failed to Load Log') + error.message);
|
|
logContentElement.textContent = _('Failed to Load Log File') + logFile;
|
|
});
|
|
}
|
|
|
|
function highlightLogContent(content) {
|
|
content = content.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
content = convertAnsiToHtml(content);
|
|
|
|
content = content.replace(/\s+\[?(ERROR|WARN|WARNING|INFO|DEBUG)\]?\s+/gmi, function (match, level) {
|
|
if (match.includes('<span')) return match;
|
|
let color = getLogLevelColor(level);
|
|
return '<span style="color: ' + color + '; font-weight: bold;">' + match + '</span>';
|
|
});
|
|
|
|
return content;
|
|
}
|
|
|
|
function getLogLevelColor(level) {
|
|
switch (level.toUpperCase()) {
|
|
case 'ERROR':
|
|
return '#ff6b6b';
|
|
case 'WARN':
|
|
case 'WARNING':
|
|
return '#ffa500';
|
|
case 'INFO':
|
|
return '#4ecdc4';
|
|
case 'DEBUG':
|
|
return '#95a5a6';
|
|
default:
|
|
return '#ffffff';
|
|
}
|
|
}
|
|
|
|
function convertAnsiToHtml(text) {
|
|
const ansiColorMap = {
|
|
'30': '#2e3436',
|
|
'31': '#cc0000',
|
|
'32': '#4e9a06',
|
|
'33': '#c4a000',
|
|
'34': '#3465a4',
|
|
'35': '#75507b',
|
|
'36': '#06989a',
|
|
'37': '#d3d7cf',
|
|
'90': '#555753',
|
|
'91': '#ef2929',
|
|
'92': '#8ae234',
|
|
'93': '#fce94f',
|
|
'94': '#729fcf',
|
|
'95': '#ad7fa8',
|
|
'96': '#34e2e2',
|
|
'97': '#eeeeec'
|
|
};
|
|
|
|
const ansiBgColorMap = {
|
|
'40': '#2e3436',
|
|
'41': '#cc0000',
|
|
'42': '#4e9a06',
|
|
'43': '#c4a000',
|
|
'44': '#3465a4',
|
|
'45': '#75507b',
|
|
'46': '#06989a',
|
|
'47': '#d3d7cf',
|
|
'100': '#555753',
|
|
'101': '#ef2929',
|
|
'102': '#8ae234',
|
|
'103': '#fce94f',
|
|
'104': '#729fcf',
|
|
'105': '#ad7fa8',
|
|
'106': '#34e2e2',
|
|
'107': '#eeeeec'
|
|
};
|
|
|
|
let result = text;
|
|
let openTags = [];
|
|
|
|
result = result.replace(/\x1b\[([0-9;]*)m/g, function (match, codes) {
|
|
if (!codes) codes = '0';
|
|
|
|
let codeArray = codes.split(';');
|
|
let html = '';
|
|
|
|
for (let code of codeArray) {
|
|
code = code.trim();
|
|
|
|
if (code === '0' || code === '') {
|
|
while (openTags.length > 0) {
|
|
html += '</span>';
|
|
openTags.pop();
|
|
}
|
|
} else if (code === '1') {
|
|
html += '<span style="font-weight: bold;">';
|
|
openTags.push('bold');
|
|
} else if (code === '4') {
|
|
html += '<span style="text-decoration: underline;">';
|
|
openTags.push('underline');
|
|
} else if (ansiColorMap[code]) {
|
|
html += '<span style="color: ' + ansiColorMap[code] + ';">';
|
|
openTags.push('color');
|
|
} else if (ansiBgColorMap[code]) {
|
|
html += '<span style="background-color: ' + ansiBgColorMap[code] + ';">';
|
|
openTags.push('bgcolor');
|
|
}
|
|
}
|
|
|
|
return html;
|
|
});
|
|
|
|
while (openTags.length > 0) {
|
|
result += '</span>';
|
|
openTags.pop();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function downloadLogs(logFileSelect: HTMLSelectElement, statusElement: HTMLElement) {
|
|
let logFile = logFileSelect.value;
|
|
|
|
updateLogStatus(statusElement, _('Preparing Download'));
|
|
|
|
fs.read(logFile).then(function (content) {
|
|
let blob = new Blob([content], {type: 'text/plain'});
|
|
let url = window.URL.createObjectURL(blob);
|
|
let a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = logFile.split('/').pop() + '_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.log';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
updateLogStatus(statusElement, _('Log Downloaded'));
|
|
}).catch(function (error) {
|
|
updateLogStatus(statusElement, _('Download Failed') + error.message);
|
|
});
|
|
}
|
|
|
|
function clearLogs(
|
|
logFileSelect: HTMLSelectElement,
|
|
levelFilterSelect: HTMLSelectElement,
|
|
linesCountSelect: HTMLSelectElement,
|
|
statusElement: HTMLElement,
|
|
logContentElement: HTMLElement
|
|
) {
|
|
let logFile = logFileSelect.value;
|
|
|
|
updateLogStatus(statusElement, _('Clearing Logs'));
|
|
|
|
fs.write(logFile, '').then(function () {
|
|
updateLogStatus(statusElement, _('Log Cleared'));
|
|
loadLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
|
}).catch(function (error) {
|
|
updateLogStatus(statusElement, _('Clear Failed') + error.message);
|
|
});
|
|
}
|
|
|
|
function updateLogStatus(statusElement: HTMLElement, message: string) {
|
|
statusElement.textContent = message;
|
|
statusElement.style.color = '#666';
|
|
}
|
|
|
|
type TraceDiagnosticsValues = {
|
|
url: string;
|
|
mode: string;
|
|
clientIp: string;
|
|
method: string;
|
|
timeout: string;
|
|
tcpdump: boolean;
|
|
tcpdumpInterface: string;
|
|
verbose: boolean;
|
|
};
|
|
|
|
function setTraceStatus(statusElement: HTMLElement, message: string, color?: string) {
|
|
statusElement.textContent = message;
|
|
statusElement.style.display = message ? 'block' : 'none';
|
|
statusElement.style.color = color || '#666';
|
|
}
|
|
|
|
function setTraceOutput(outputElement: HTMLElement, content: string) {
|
|
outputElement.innerHTML = highlightLogContent(content);
|
|
outputElement.scrollTop = 0;
|
|
}
|
|
|
|
function buildTraceArgs(values: TraceDiagnosticsValues) {
|
|
const url = values.url.trim();
|
|
const timeout = values.timeout.trim();
|
|
const method = values.method || 'HEAD';
|
|
const args = [];
|
|
|
|
if (!/^https?:\/\/[A-Za-z0-9_.:\-/?#%=&+~[\]]+$/.test(url)) {
|
|
throw new Error(_('Please enter a valid HTTP or HTTPS URL'));
|
|
}
|
|
|
|
if (!/^[0-9]+$/.test(timeout) || parseInt(timeout, 10) < 1 || parseInt(timeout, 10) > 120) {
|
|
throw new Error(_('Timeout must be an integer between 1 and 120'));
|
|
}
|
|
|
|
args.push('-t', timeout);
|
|
args.push('-X', method);
|
|
|
|
if (values.mode === 'client') {
|
|
const clientIp = values.clientIp.trim();
|
|
if (!/^[A-Fa-f0-9:.]+$/.test(clientIp)) {
|
|
throw new Error(_('Please enter a valid client IP address'));
|
|
}
|
|
args.push('-c', clientIp);
|
|
}
|
|
|
|
if (values.tcpdump) {
|
|
const iface = values.tcpdumpInterface.trim() || 'any';
|
|
args.push('-p');
|
|
if (!/^[A-Za-z0-9_.:@-]+$/.test(iface)) {
|
|
throw new Error(_('Please enter a valid tcpdump interface'));
|
|
}
|
|
args.push('-i', iface);
|
|
}
|
|
|
|
if (values.verbose) {
|
|
args.push('-v');
|
|
}
|
|
|
|
args.push(url);
|
|
return args;
|
|
}
|
|
|
|
function runTraceDiagnostics(
|
|
values: TraceDiagnosticsValues,
|
|
statusElement: HTMLElement,
|
|
outputElement: HTMLElement,
|
|
runButton: HTMLButtonElement
|
|
) {
|
|
let args;
|
|
|
|
try {
|
|
args = buildTraceArgs(values);
|
|
} catch (error) {
|
|
setTraceStatus(statusElement, (error as Error).message, '#b00020');
|
|
return;
|
|
}
|
|
|
|
outputElement.innerHTML = '';
|
|
runButton.disabled = true;
|
|
setTraceStatus(statusElement, _('Running Trace Diagnostics'));
|
|
|
|
fs.exec('/usr/lib/hijpass/trace-url.sh', args).then(function (result) {
|
|
const stdout = result.stdout || '';
|
|
const stderr = result.stderr || '';
|
|
setTraceOutput(outputElement, stdout || stderr || _('No Output'));
|
|
|
|
if (result.code === 0) {
|
|
setTraceStatus(statusElement, _('Trace Completed') + ' - ' + new Date().toLocaleTimeString());
|
|
} else {
|
|
setTraceStatus(statusElement, _('Trace Failed') + ' - ' + (stderr || _('Exit Code') + ': ' + result.code), '#b00020');
|
|
}
|
|
}).catch(function (error) {
|
|
setTraceOutput(outputElement, error.message || String(error));
|
|
setTraceStatus(statusElement, _('Trace Failed') + ' - ' + error.message, '#b00020');
|
|
}).finally(function () {
|
|
runButton.disabled = false;
|
|
});
|
|
}
|
|
|
|
function createTraceOutputPanel() {
|
|
const statusElement = E('div', {
|
|
'style': 'display: none; font-size: 12px; color: #666; margin-bottom: 6px;'
|
|
}) as HTMLElement;
|
|
const outputElement = E('pre', {
|
|
'style': 'width: 100%; min-height: 260px; max-height: 520px; box-sizing: border-box; margin: 0; font-family: monospace; font-size: 12px; background: #101010; color: #eee; border: 1px solid #ccc; padding: 10px; overflow: auto; white-space: pre-wrap; line-height: 1.45;'
|
|
}, _('Trace output will be shown here')) as HTMLElement;
|
|
|
|
return {
|
|
statusElement,
|
|
outputElement,
|
|
node: E('div', {}, [
|
|
statusElement,
|
|
outputElement
|
|
]) as HTMLElement
|
|
};
|
|
}
|
|
|
|
const LogPanelUtils = {
|
|
createLogControlPanel: function () {
|
|
const logFileSelect = E('select', {
|
|
'class': 'cbi-input-select',
|
|
'style': 'margin-right: 10px;'
|
|
}, createLogFileOptions()) as HTMLSelectElement;
|
|
const levelFilterSelect = E('select', {
|
|
'class': 'cbi-input-select',
|
|
'style': 'margin-right: 10px;'
|
|
}, [
|
|
E('option', {'value': ''}, _('All')),
|
|
E('option', {'value': 'DEBUG'}, _('Debug')),
|
|
E('option', {'value': 'INFO'}, _('Info')),
|
|
E('option', {'value': 'WARN'}, _('Warning')),
|
|
E('option', {'value': 'ERROR'}, _('Error'))
|
|
]) as HTMLSelectElement;
|
|
const linesCountSelect = E('select', {
|
|
'class': 'cbi-input-select',
|
|
'style': 'margin-right: 10px;'
|
|
}, [
|
|
E('option', {'value': '50'}, '50'),
|
|
E('option', {'value': '100', 'selected': 'selected'}, '100'),
|
|
E('option', {'value': '200'}, '200'),
|
|
E('option', {'value': '500'}, '500'),
|
|
E('option', {'value': '1000'}, '1000')
|
|
]) as HTMLSelectElement;
|
|
const statusElement = E('div', {
|
|
'style': 'font-size: 12px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'
|
|
}) as HTMLElement;
|
|
const logContentElement = E('div', {
|
|
'class': 'log-display',
|
|
'style': 'width: 100%; height: 500px; font-family: monospace; font-size: 12px; background: #101010; color: #eee; border: 1px solid #ccc; padding: 10px; overflow: auto; white-space: pre; line-height: 1.4;'
|
|
}, _('Log Content')) as HTMLElement;
|
|
let panel: HTMLElement;
|
|
let autoRefreshInterval: number | null = null;
|
|
|
|
const load = function () {
|
|
loadLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
|
};
|
|
const stopAutoRefresh = function (button: HTMLElement) {
|
|
if (autoRefreshInterval !== null) {
|
|
clearInterval(autoRefreshInterval);
|
|
autoRefreshInterval = null;
|
|
}
|
|
button.textContent = _('Auto Refresh');
|
|
button.className = 'cbi-button cbi-button-neutral';
|
|
};
|
|
const autoRefreshButton = E('button', {
|
|
'class': 'cbi-button cbi-button-neutral',
|
|
'style': 'margin-right: 5px;',
|
|
'type': 'button',
|
|
'click': function (ev: Event) {
|
|
ev.preventDefault();
|
|
if (autoRefreshInterval === null) {
|
|
autoRefreshButton.textContent = _('Stop Auto Refresh');
|
|
autoRefreshButton.className = 'cbi-button cbi-button-negative';
|
|
autoRefreshInterval = window.setInterval(function () {
|
|
if (!panel.isConnected) {
|
|
stopAutoRefresh(autoRefreshButton);
|
|
return;
|
|
}
|
|
load();
|
|
}, 3000);
|
|
updateLogStatus(statusElement, _('Auto Refresh Enabled'));
|
|
} else {
|
|
stopAutoRefresh(autoRefreshButton);
|
|
updateLogStatus(statusElement, _('Auto Refresh Disabled'));
|
|
}
|
|
}
|
|
}, _('Auto Refresh')) as HTMLElement;
|
|
|
|
logFileSelect.addEventListener('change', load);
|
|
levelFilterSelect.addEventListener('change', load);
|
|
linesCountSelect.addEventListener('change', load);
|
|
|
|
panel = E('div', {'class': 'cbi-section'}, [
|
|
E('div', {'class': 'cbi-section-node'}, [
|
|
E('div', {'class': 'cbi-value'}, [
|
|
E('label', {'class': 'cbi-value-title'}, _('Log File')),
|
|
E('div', {'class': 'cbi-value-field'}, [logFileSelect])
|
|
]),
|
|
E('div', {'class': 'cbi-value'}, [
|
|
E('label', {'class': 'cbi-value-title'}, _('Log Level Filter')),
|
|
E('div', {'class': 'cbi-value-field'}, [levelFilterSelect])
|
|
]),
|
|
E('div', {'class': 'cbi-value'}, [
|
|
E('label', {'class': 'cbi-value-title'}, _('Display Lines')),
|
|
E('div', {'class': 'cbi-value-field'}, [linesCountSelect])
|
|
]),
|
|
E('div', {'class': 'cbi-value'}, [
|
|
E('label', {'class': 'cbi-value-title'}, _('Actions')),
|
|
E('div', {'class': 'cbi-value-field'}, [
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-action',
|
|
'style': 'margin-right: 5px;',
|
|
'type': 'button',
|
|
'click': function (ev: Event) {
|
|
ev.preventDefault();
|
|
load();
|
|
}
|
|
}, _('Refresh')),
|
|
autoRefreshButton,
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-positive',
|
|
'style': 'margin-right: 5px;',
|
|
'type': 'button',
|
|
'click': function (ev: Event) {
|
|
ev.preventDefault();
|
|
downloadLogs(logFileSelect, statusElement);
|
|
}
|
|
}, _('Download')),
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-negative',
|
|
'type': 'button',
|
|
'click': function (ev: Event) {
|
|
ev.preventDefault();
|
|
if (confirm(_('Clear Log Confirmation'))) {
|
|
clearLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
|
}
|
|
}
|
|
}, _('Clear'))
|
|
])
|
|
]),
|
|
E('div', {'class': 'cbi-value'}, [
|
|
statusElement,
|
|
logContentElement
|
|
])
|
|
])
|
|
]) as HTMLElement;
|
|
|
|
load();
|
|
return panel;
|
|
},
|
|
|
|
createTraceOutputPanel: createTraceOutputPanel,
|
|
runTraceDiagnostics: runTraceDiagnostics,
|
|
}
|
|
|
|
export { LogPanelUtils }
|