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>
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;
373470let logsAutoScroll = true ;
374471let logsSearchDebounce = null ;
375472let logsSearchMode = false ;
473+ let logsSearchRequestId = 0 ;
376474
377475// Load on page load
378476document .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
443551function appendLog (log ) {
@@ -457,7 +565,9 @@ function appendLog(log) {
457565
458566function 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
473583async 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
507622function 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+
528655function scrollLogsToBottom () {
529656 if (logsAutoScroll) {
530657 const container = document .getElementById (' logsContainer' );
@@ -552,6 +679,10 @@ function escapeHtml(str) {
552679 return String (str).replace (/ &/ g , ' &' ).replace (/ </ g , ' <' ).replace (/ >/ g , ' >' );
553680}
554681
682+ function escapeRegExp (str ) {
683+ return str .replace (/ [. *+?^${}()|[\]\\ ] / g , ' \\ $&' );
684+ }
685+
555686async function loadSystemStats () {
556687 try {
557688 const res = await fetch (' /panel/system-stats' , { credentials: ' include' });
0 commit comments