Skip to content

Commit cd5ea65

Browse files
committed
Optimize panel queries and runtime stability
1 parent 9659ef0 commit cd5ea65

2 files changed

Lines changed: 214 additions & 46 deletions

File tree

src/routes/panel.js

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const config = require('../../config');
3737
const logger = require('../utils/logger');
3838
const path = require('path');
3939
const fs = require('fs');
40+
const fsp = fs.promises;
41+
const { createReadStream } = require('fs');
42+
const readline = require('readline');
4043
const { exec } = require('child_process');
4144
const { promisify } = require('util');
4245
const execAsync = promisify(exec);
@@ -1388,28 +1391,14 @@ router.get('/logs', requireAuth, async (req, res) => {
13881391
try {
13891392
const logsDir = path.join(__dirname, '../../logs');
13901393
let logs = [];
1391-
1392-
// Winston с maxFiles создаёт файлы combined1.log, combined2.log и т.д.
1393-
// Ищем все combined*.log файлы
1394-
if (fs.existsSync(logsDir)) {
1395-
const files = fs.readdirSync(logsDir)
1396-
.filter(f => f.startsWith('combined') && f.endsWith('.log'))
1397-
.map(f => ({
1398-
name: f,
1399-
path: path.join(logsDir, f),
1400-
mtime: fs.statSync(path.join(logsDir, f)).mtime
1401-
}))
1402-
.sort((a, b) => b.mtime - a.mtime); // Сортируем по времени изменения
1403-
1404-
// Берём самый свежий файл
1405-
if (files.length > 0) {
1406-
const latestFile = files[0].path;
1407-
const content = fs.readFileSync(latestFile, 'utf8');
1408-
// Берём последние 100 строк, новые сверху
1409-
logs = content.split('\n').filter(Boolean).slice(-100).reverse();
1410-
}
1394+
1395+
const files = await getCombinedLogFiles(logsDir);
1396+
1397+
// Берём самый свежий файл
1398+
if (files.length > 0) {
1399+
logs = await readLastLogLines(files[0].path, 100);
14111400
}
1412-
1401+
14131402
res.json({ logs });
14141403
} catch (error) {
14151404
logger.error(`[Panel] Logs read error: ${error.message}`);
@@ -1428,34 +1417,37 @@ router.get('/logs/search', requireAuth, async (req, res) => {
14281417
}
14291418

14301419
const logsDir = path.join(__dirname, '../../logs');
1431-
if (!fs.existsSync(logsDir)) {
1432-
return res.json({ matches: [], total: 0 });
1433-
}
1434-
1435-
const files = fs.readdirSync(logsDir)
1436-
.filter(f => f.startsWith('combined') && f.endsWith('.log'))
1437-
.map(f => ({
1438-
name: f,
1439-
path: path.join(logsDir, f),
1440-
mtime: fs.statSync(path.join(logsDir, f)).mtime
1441-
}))
1442-
.sort((a, b) => b.mtime - a.mtime);
1420+
const files = await getCombinedLogFiles(logsDir);
14431421

14441422
const qLower = q.toLowerCase();
14451423
const matches = [];
14461424
let total = 0;
14471425

14481426
for (const file of files) {
1449-
const content = fs.readFileSync(file.path, 'utf8');
1450-
const lines = content.split('\n').filter(Boolean).reverse();
1451-
for (const line of lines) {
1427+
const fileMatches = [];
1428+
const rl = readline.createInterface({
1429+
input: createReadStream(file.path, { encoding: 'utf8' }),
1430+
crlfDelay: Infinity,
1431+
});
1432+
1433+
for await (const line of rl) {
1434+
if (!line) continue;
1435+
14521436
if (line.toLowerCase().includes(qLower)) {
1453-
total++;
1454-
if (matches.length < limit) {
1455-
matches.push(line);
1437+
total += 1;
1438+
fileMatches.push(line);
1439+
1440+
// Держим только последние limit совпадений в текущем файле
1441+
if (fileMatches.length > limit) {
1442+
fileMatches.shift();
14561443
}
14571444
}
14581445
}
1446+
1447+
// Отдаём новые строки сверху (как и раньше)
1448+
for (let i = fileMatches.length - 1; i >= 0 && matches.length < limit; i -= 1) {
1449+
matches.push(fileMatches[i]);
1450+
}
14591451
}
14601452

14611453
res.json({ matches, total });
@@ -1465,6 +1457,51 @@ router.get('/logs/search', requireAuth, async (req, res) => {
14651457
}
14661458
});
14671459

1460+
async function getCombinedLogFiles(logsDir) {
1461+
try {
1462+
await fsp.access(logsDir);
1463+
} catch {
1464+
return [];
1465+
}
1466+
1467+
const dirEntries = await fsp.readdir(logsDir, { withFileTypes: true });
1468+
const logEntries = dirEntries.filter(
1469+
(entry) => entry.isFile() && entry.name.startsWith('combined') && entry.name.endsWith('.log')
1470+
);
1471+
1472+
const files = await Promise.all(
1473+
logEntries.map(async (entry) => {
1474+
const fullPath = path.join(logsDir, entry.name);
1475+
const stat = await fsp.stat(fullPath);
1476+
return {
1477+
name: entry.name,
1478+
path: fullPath,
1479+
mtime: stat.mtime,
1480+
};
1481+
})
1482+
);
1483+
1484+
return files.sort((a, b) => b.mtime - a.mtime);
1485+
}
1486+
1487+
async function readLastLogLines(filePath, maxLines) {
1488+
const tail = [];
1489+
const rl = readline.createInterface({
1490+
input: createReadStream(filePath, { encoding: 'utf8' }),
1491+
crlfDelay: Infinity,
1492+
});
1493+
1494+
for await (const line of rl) {
1495+
if (!line) continue;
1496+
tail.push(line);
1497+
if (tail.length > maxLines) {
1498+
tail.shift();
1499+
}
1500+
}
1501+
1502+
return tail.reverse();
1503+
}
1504+
14681505
// POST /panel/backup - Backup MongoDB и скачать
14691506
router.post('/backup', requireAuth, async (req, res) => {
14701507
try {

views/dashboard.ejs

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,11 @@
174174
<i class="ti ti-file-text"></i> <%= t('dashboard.recentLogs') %>
175175
</h2>
176176
<div class="logs-controls">
177-
<div class="logs-search-wrap">
177+
<div class="logs-search-wrap" id="logsSearchWrap">
178+
<span class="logs-search-icon"><i class="ti ti-search"></i></span>
178179
<input type="text" class="logs-search-input" id="logsSearch" placeholder="<%= t('common.search') %>..." oninput="onLogsSearchInput(this.value)">
179180
<span class="logs-search-count" id="logsSearchCount"></span>
181+
<span class="logs-search-loading" id="logsSearchLoading" aria-hidden="true"><i class="ti ti-loader-2"></i></span>
180182
<button class="logs-search-clear" id="logsSearchClear" onclick="clearLogsSearch()" title="<%= t('common.clear') %>"></button>
181183
</div>
182184
<button class="btn btn-sm" id="logsToggleBtn" onclick="toggleLogs()" title="<%= t('dashboard.pauseLogs') %>"><i class="ti ti-player-pause"></i></button>
@@ -300,10 +302,105 @@
300302
display: flex;
301303
gap: 8px;
302304
}
305+
.logs-search-wrap {
306+
display: flex;
307+
align-items: center;
308+
gap: 8px;
309+
min-width: 300px;
310+
padding: 0 10px;
311+
border: 1px solid var(--border);
312+
border-radius: 10px;
313+
background: var(--bg-tertiary);
314+
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
315+
}
316+
.logs-search-wrap:focus-within {
317+
border-color: var(--primary);
318+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
319+
background: var(--bg-secondary);
320+
}
321+
.logs-search-wrap.searching .logs-search-loading {
322+
display: inline-flex;
323+
}
324+
.logs-search-wrap.searching .logs-search-icon {
325+
color: var(--primary);
326+
}
327+
.logs-search-icon {
328+
display: inline-flex;
329+
color: var(--text-muted);
330+
}
331+
.logs-search-input {
332+
flex: 1;
333+
min-width: 140px;
334+
border: none;
335+
background: transparent;
336+
color: var(--text-primary);
337+
outline: none;
338+
height: 36px;
339+
font-size: 0.9rem;
340+
}
341+
.logs-search-input::placeholder {
342+
color: var(--text-muted);
343+
}
344+
.logs-search-count {
345+
display: inline-flex;
346+
align-items: center;
347+
justify-content: center;
348+
min-width: 22px;
349+
height: 22px;
350+
padding: 0 6px;
351+
border-radius: 999px;
352+
font-size: 0.75rem;
353+
color: var(--text-primary);
354+
background: var(--bg-card);
355+
border: 1px solid var(--border);
356+
}
357+
.logs-search-loading {
358+
display: none;
359+
align-items: center;
360+
color: var(--text-muted);
361+
}
362+
.logs-search-loading .ti-loader-2 {
363+
animation: spin 0.8s linear infinite;
364+
}
365+
.logs-search-clear {
366+
display: none;
367+
align-items: center;
368+
justify-content: center;
369+
width: 22px;
370+
height: 22px;
371+
border: none;
372+
border-radius: 6px;
373+
background: transparent;
374+
color: var(--text-muted);
375+
cursor: pointer;
376+
}
377+
.logs-search-clear:hover {
378+
background: var(--bg-card);
379+
color: var(--text-primary);
380+
}
381+
.logs-hit {
382+
background: rgba(99, 102, 241, 0.22);
383+
color: inherit;
384+
border-radius: 4px;
385+
padding: 0 2px;
386+
}
387+
@media (max-width: 768px) {
388+
.logs-controls {
389+
width: 100%;
390+
flex-wrap: wrap;
391+
}
392+
.logs-search-wrap {
393+
min-width: 0;
394+
width: 100%;
395+
}
396+
}
303397
@keyframes pulse {
304398
0%, 100% { opacity: 1; }
305399
50% { opacity: 0.5; }
306400
}
401+
@keyframes spin {
402+
to { transform: rotate(360deg); }
403+
}
307404
308405
.progress-bar-wrap {
309406
width: 60px;
@@ -373,6 +470,7 @@ let logsPaused = false;
373470
let logsAutoScroll = true;
374471
let logsSearchDebounce = null;
375472
let logsSearchMode = false;
473+
let logsSearchRequestId = 0;
376474
377475
// Load on page load
378476
document.addEventListener('DOMContentLoaded', () => {
@@ -432,12 +530,22 @@ function formatLogLine(log) {
432530
return `<div class="${cls}">${escapeHtml(message)}</div>`;
433531
}
434532
435-
function formatRawLine(line) {
533+
function formatRawLine(line, query = '') {
436534
let cls = 'log-line';
437535
if (line.includes('"level":"error"') || line.includes('[ERROR]')) cls += ' log-error';
438536
else if (line.includes('"level":"warn"') || line.includes('[WARN]')) cls += ' log-warn';
439537
else if (line.includes('') || line.includes('')) cls += ' log-success';
440-
return `<div class="${cls}">${escapeHtml(line)}</div>`;
538+
539+
const escapedLine = escapeHtml(line);
540+
if (!query) {
541+
return `<div class="${cls}">${escapedLine}</div>`;
542+
}
543+
544+
const escapedQuery = escapeHtml(query);
545+
const highlightRegex = new RegExp(`(${escapeRegExp(escapedQuery)})`, 'ig');
546+
const highlighted = escapedLine.replace(highlightRegex, '<mark class="logs-hit">$1</mark>');
547+
548+
return `<div class="${cls}">${highlighted}</div>`;
441549
}
442550
443551
function appendLog(log) {
@@ -457,7 +565,9 @@ function appendLog(log) {
457565
458566
function onLogsSearchInput(value) {
459567
const clearBtn = document.getElementById('logsSearchClear');
460-
clearBtn.style.display = value.trim() ? 'flex' : 'none';
568+
const hasValue = value.trim().length > 0;
569+
clearBtn.style.display = hasValue ? 'flex' : 'none';
570+
setLogsSearchUiState(hasValue ? 'typing' : 'idle');
461571
462572
clearTimeout(logsSearchDebounce);
463573
logsSearchDebounce = setTimeout(() => {
@@ -471,43 +581,50 @@ function onLogsSearchInput(value) {
471581
}
472582
473583
async function execLogsSearch(q) {
584+
const requestId = ++logsSearchRequestId;
474585
logsSearchMode = true;
475586
const container = document.getElementById('logsContainer');
476587
const countEl = document.getElementById('logsSearchCount');
588+
setLogsSearchUiState('searching');
477589
478-
container.innerHTML = '<div class="logs-loading">...</div>';
590+
container.innerHTML = '<div class="logs-loading">' + i18n.loading + '</div>';
479591
if (countEl) countEl.textContent = '';
480592
481593
try {
482594
const res = await fetch(`/panel/logs/search?q=${encodeURIComponent(q)}&limit=200`, { credentials: 'include' });
483595
const data = await res.json();
484596
485-
if (!logsSearchMode) return;
597+
if (!logsSearchMode || requestId !== logsSearchRequestId) return;
486598
487599
if (data.matches && data.matches.length > 0) {
488-
container.innerHTML = data.matches.map(line => formatRawLine(line)).join('');
600+
container.innerHTML = data.matches.map(line => formatRawLine(line, q)).join('');
489601
if (countEl) {
490602
const shown = data.matches.length;
491603
const total = data.total;
492604
countEl.textContent = shown < total
493605
? `${shown} / ${total}`
494606
: `${total}`;
495607
}
608+
setLogsSearchUiState('active');
496609
} else {
497610
container.innerHTML = '<div class="logs-empty">' + i18n.noLogs + '</div>';
498611
if (countEl) countEl.textContent = '0';
612+
setLogsSearchUiState('active');
499613
}
500614
} catch (e) {
501615
if (logsSearchMode) {
502616
container.innerHTML = '<div class="logs-empty">—</div>';
617+
setLogsSearchUiState('typing');
503618
}
504619
}
505620
}
506621
507622
function exitLogsSearchMode() {
508623
logsSearchMode = false;
624+
logsSearchRequestId += 1;
509625
const countEl = document.getElementById('logsSearchCount');
510626
if (countEl) countEl.textContent = '';
627+
setLogsSearchUiState('idle');
511628
const container = document.getElementById('logsContainer');
512629
container.innerHTML = '<div class="logs-loading">' + i18n.loading + '</div>';
513630
if (logsWs) {
@@ -525,6 +642,16 @@ function clearLogsSearch() {
525642
onLogsSearchInput('');
526643
}
527644
645+
function setLogsSearchUiState(state) {
646+
const wrap = document.getElementById('logsSearchWrap');
647+
if (!wrap) return;
648+
649+
wrap.classList.remove('searching');
650+
if (state === 'searching') {
651+
wrap.classList.add('searching');
652+
}
653+
}
654+
528655
function scrollLogsToBottom() {
529656
if (logsAutoScroll) {
530657
const container = document.getElementById('logsContainer');
@@ -552,6 +679,10 @@ function escapeHtml(str) {
552679
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
553680
}
554681
682+
function escapeRegExp(str) {
683+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
684+
}
685+
555686
async function loadSystemStats() {
556687
try {
557688
const res = await fetch('/panel/system-stats', { credentials: 'include' });

0 commit comments

Comments
 (0)