Add live log viewer on Settings → Logs tab
Terminal-style real-time log viewer with: - Log file selector (app, post-processing, acoustid, source reuse) - Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red) - Level filter buttons (All/Debug/Info/Warn/Error) - Auto-scroll with toggle, copy and clear buttons - Live updates via WebSocket (2s polling, pushes new lines) - Initial load fetches last 200 lines via REST API - 1000-line display cap with oldest lines trimmed Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being hidden inside collapsed Library Preferences section body — misplaced closing div caused them to be invisible.
This commit is contained in:
parent
c0c38268f5
commit
8b0e619fa1
5 changed files with 450 additions and 3 deletions
136
web_server.py
136
web_server.py
|
|
@ -6256,6 +6256,75 @@ def handle_log_level():
|
||||||
logger.error(f"Error getting log level: {e}")
|
logger.error(f"Error getting log level: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# LIVE LOG VIEWER API
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
# In-memory ring buffer for live log streaming via WebSocket
|
||||||
|
_live_log_buffer = []
|
||||||
|
_live_log_buffer_lock = threading.Lock()
|
||||||
|
_LIVE_LOG_BUFFER_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/logs/tail', methods=['GET'])
|
||||||
|
def get_log_tail():
|
||||||
|
"""Return the last N lines from a log file, optionally filtered by level."""
|
||||||
|
log_source = request.args.get('source', 'app')
|
||||||
|
lines = request.args.get('lines', 200, type=int)
|
||||||
|
lines = max(10, min(lines, 1000))
|
||||||
|
level_filter = request.args.get('level', '').upper() # DEBUG, INFO, WARNING, ERROR or empty
|
||||||
|
|
||||||
|
log_map = {
|
||||||
|
'app': os.path.join('logs', 'app.log'),
|
||||||
|
'post_processing': os.path.join('logs', 'post_processing.log'),
|
||||||
|
'acoustid': os.path.join('logs', 'acoustid.log'),
|
||||||
|
'source_reuse': os.path.join('logs', 'source_reuse.log'),
|
||||||
|
}
|
||||||
|
log_path = log_map.get(log_source, log_map['app'])
|
||||||
|
|
||||||
|
result_lines = []
|
||||||
|
if os.path.exists(log_path):
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
# Read more lines than requested so filtering has enough to work with
|
||||||
|
tail = all_lines[-(lines * 3):] if level_filter else all_lines[-lines:]
|
||||||
|
for line in tail:
|
||||||
|
stripped = line.rstrip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'):
|
||||||
|
# Match lines like "2026-04-18 12:00:00 - name - INFO - message"
|
||||||
|
if f' - {level_filter} - ' not in stripped:
|
||||||
|
continue
|
||||||
|
result_lines.append(stripped)
|
||||||
|
# Trim to requested count after filtering
|
||||||
|
result_lines = result_lines[-lines:]
|
||||||
|
except Exception as e:
|
||||||
|
result_lines = [f'Error reading log file: {e}']
|
||||||
|
|
||||||
|
# Available log files
|
||||||
|
available = []
|
||||||
|
logs_dir = 'logs'
|
||||||
|
if os.path.isdir(logs_dir):
|
||||||
|
for fname in sorted(os.listdir(logs_dir)):
|
||||||
|
if fname.endswith('.log'):
|
||||||
|
fpath = os.path.join(logs_dir, fname)
|
||||||
|
size_kb = os.path.getsize(fpath) / 1024
|
||||||
|
available.append({
|
||||||
|
'key': fname.replace('.log', ''),
|
||||||
|
'file': fname,
|
||||||
|
'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB",
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'lines': result_lines,
|
||||||
|
'source': log_source,
|
||||||
|
'total': len(result_lines),
|
||||||
|
'available_logs': available,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# AUTOMATIONS API
|
# AUTOMATIONS API
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
@ -54390,6 +54459,69 @@ def _emit_tool_progress_loop():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error emitting logs: {e}")
|
logger.debug(f"Error emitting logs: {e}")
|
||||||
|
|
||||||
|
def _emit_live_log_loop():
|
||||||
|
"""Background thread that tails app.log and pushes new lines via WebSocket."""
|
||||||
|
_last_pos = {} # {source: file_position}
|
||||||
|
_active_source = 'app'
|
||||||
|
log_map = {
|
||||||
|
'app': os.path.join('logs', 'app.log'),
|
||||||
|
'post_processing': os.path.join('logs', 'post_processing.log'),
|
||||||
|
'acoustid': os.path.join('logs', 'acoustid.log'),
|
||||||
|
'source_reuse': os.path.join('logs', 'source_reuse.log'),
|
||||||
|
}
|
||||||
|
while not globals().get('IS_SHUTTING_DOWN', False):
|
||||||
|
socketio.sleep(2)
|
||||||
|
try:
|
||||||
|
# Read which source clients want (stored by subscribe handler)
|
||||||
|
source = getattr(_emit_live_log_loop, '_source', 'app')
|
||||||
|
log_path = log_map.get(source, log_map['app'])
|
||||||
|
if not os.path.exists(log_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_size = os.path.getsize(log_path)
|
||||||
|
last_pos = _last_pos.get(source, 0)
|
||||||
|
|
||||||
|
# File was truncated or rotated
|
||||||
|
if file_size < last_pos:
|
||||||
|
last_pos = 0
|
||||||
|
|
||||||
|
if file_size == last_pos:
|
||||||
|
continue # No new data
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
f.seek(last_pos)
|
||||||
|
for line in f:
|
||||||
|
stripped = line.rstrip()
|
||||||
|
if stripped:
|
||||||
|
new_lines.append(stripped)
|
||||||
|
_last_pos[source] = f.tell()
|
||||||
|
|
||||||
|
if new_lines:
|
||||||
|
# Cap at 50 lines per push to avoid flooding
|
||||||
|
socketio.emit('logs:live', {
|
||||||
|
'lines': new_lines[-50:],
|
||||||
|
'source': source,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error in live log emitter: {e}")
|
||||||
|
|
||||||
|
_emit_live_log_loop._source = 'app'
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('logs:subscribe')
|
||||||
|
def handle_logs_subscribe(data):
|
||||||
|
"""Client subscribes to live log stream with optional source."""
|
||||||
|
source = data.get('source', 'app')
|
||||||
|
_emit_live_log_loop._source = source
|
||||||
|
join_room('logs:live')
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('logs:unsubscribe')
|
||||||
|
def handle_logs_unsubscribe(data):
|
||||||
|
leave_room('logs:live')
|
||||||
|
|
||||||
|
|
||||||
@socketio.on('sync:subscribe')
|
@socketio.on('sync:subscribe')
|
||||||
def handle_sync_subscribe(data):
|
def handle_sync_subscribe(data):
|
||||||
for pid in data.get('playlist_ids', []):
|
for pid in data.get('playlist_ids', []):
|
||||||
|
|
@ -54680,7 +54812,9 @@ def start_runtime_services():
|
||||||
socketio.start_background_task(_hydrabase_reconnect_loop)
|
socketio.start_background_task(_hydrabase_reconnect_loop)
|
||||||
# API Rate Monitor — 1s push for speedometer gauges
|
# API Rate Monitor — 1s push for speedometer gauges
|
||||||
socketio.start_background_task(_emit_rate_monitor_loop)
|
socketio.start_background_task(_emit_rate_monitor_loop)
|
||||||
print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)")
|
# Live log tail — streams new log lines to the log viewer
|
||||||
|
socketio.start_background_task(_emit_live_log_loop)
|
||||||
|
print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor + live logs)")
|
||||||
|
|
||||||
_runtime_started = True
|
_runtime_started = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3843,6 +3843,7 @@
|
||||||
<button class="stg-tab" data-tab="library" onclick="switchSettingsTab('library')">Library</button>
|
<button class="stg-tab" data-tab="library" onclick="switchSettingsTab('library')">Library</button>
|
||||||
<button class="stg-tab" data-tab="appearance" onclick="switchSettingsTab('appearance')">Appearance</button>
|
<button class="stg-tab" data-tab="appearance" onclick="switchSettingsTab('appearance')">Appearance</button>
|
||||||
<button class="stg-tab" data-tab="advanced" onclick="switchSettingsTab('advanced')">Advanced</button>
|
<button class="stg-tab" data-tab="advanced" onclick="switchSettingsTab('advanced')">Advanced</button>
|
||||||
|
<button class="stg-tab" data-tab="logs" onclick="switchSettingsTab('logs')">Logs</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Settings Panels -->
|
<!-- Settings Panels -->
|
||||||
<div class="settings-columns">
|
<div class="settings-columns">
|
||||||
|
|
@ -5484,6 +5485,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end Library Preferences body -->
|
||||||
|
|
||||||
<!-- Security Settings -->
|
<!-- Security Settings -->
|
||||||
<div class="settings-group" data-stg="advanced">
|
<div class="settings-group" data-stg="advanced">
|
||||||
<h3>🔒 Security</h3>
|
<h3>🔒 Security</h3>
|
||||||
|
|
@ -5750,8 +5753,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- end Library Preferences body -->
|
|
||||||
|
|
||||||
<!-- Logging Settings -->
|
<!-- Logging Settings -->
|
||||||
<div class="settings-group" data-stg="advanced">
|
<div class="settings-group" data-stg="advanced">
|
||||||
<h3>Logging</h3>
|
<h3>Logging</h3>
|
||||||
|
|
@ -5847,6 +5848,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ LOGS TAB ═══ -->
|
||||||
|
<div class="settings-group" data-stg="logs" style="max-width:100%;">
|
||||||
|
<div class="log-viewer-header">
|
||||||
|
<div class="log-viewer-controls">
|
||||||
|
<select id="log-viewer-source" class="log-viewer-select" onchange="_logViewerChangeSource()">
|
||||||
|
<option value="app">app.log</option>
|
||||||
|
<option value="post_processing">post_processing.log</option>
|
||||||
|
<option value="acoustid">acoustid.log</option>
|
||||||
|
<option value="source_reuse">source_reuse.log</option>
|
||||||
|
</select>
|
||||||
|
<div class="log-viewer-filters">
|
||||||
|
<button class="log-filter-btn active" data-level="" onclick="_logViewerFilterLevel(this)">All</button>
|
||||||
|
<button class="log-filter-btn lvl-debug" data-level="DEBUG" onclick="_logViewerFilterLevel(this)">Debug</button>
|
||||||
|
<button class="log-filter-btn lvl-info" data-level="INFO" onclick="_logViewerFilterLevel(this)">Info</button>
|
||||||
|
<button class="log-filter-btn lvl-warning" data-level="WARNING" onclick="_logViewerFilterLevel(this)">Warn</button>
|
||||||
|
<button class="log-filter-btn lvl-error" data-level="ERROR" onclick="_logViewerFilterLevel(this)">Error</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-viewer-actions">
|
||||||
|
<button class="log-action-btn" onclick="_logViewerCopy()" title="Copy visible logs">Copy</button>
|
||||||
|
<button class="log-action-btn" onclick="_logViewerClear()" title="Clear display">Clear</button>
|
||||||
|
<label class="log-autoscroll-label">
|
||||||
|
<input type="checkbox" id="log-viewer-autoscroll" checked>
|
||||||
|
Auto-scroll
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-viewer-terminal" id="log-viewer-terminal">
|
||||||
|
<div class="log-viewer-lines" id="log-viewer-lines">
|
||||||
|
<div class="log-line log-info">Initializing log viewer...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-viewer-status">
|
||||||
|
<span id="log-viewer-line-count">0 lines</span>
|
||||||
|
<span id="log-viewer-live-indicator" class="log-live-dot">● Live</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<button class="save-button" id="save-settings">Save Settings</button>
|
<button class="save-button" id="save-settings">Save Settings</button>
|
||||||
|
|
|
||||||
|
|
@ -3602,6 +3602,11 @@ const WHATS_NEW = {
|
||||||
'2.32': [
|
'2.32': [
|
||||||
// --- April 18, 2026 ---
|
// --- April 18, 2026 ---
|
||||||
{ date: 'April 18, 2026' },
|
{ date: 'April 18, 2026' },
|
||||||
|
{ title: 'Live Log Viewer', desc: 'New Logs tab on the Settings page — real-time terminal-style log viewer with color-coded log levels. Filter by DEBUG/INFO/WARNING/ERROR, switch between log files (app, post-processing, acoustid, source reuse), auto-scroll, copy, and clear. Live updates via WebSocket', page: 'settings' },
|
||||||
|
{ title: 'ReplayGain Post-Processing', desc: 'Optional ReplayGain tag analysis during post-processing. Enable in Settings → Library → Post-Processing. Analyzes loudness via ffmpeg and writes track-level gain/peak tags. Runs before lossy copy so both files get tagged. Off by default' },
|
||||||
|
{ title: 'Fix Your Albums Using Playlist Modal', desc: 'Albums in the Discover page "Your Albums" section now open with the proper album-style download modal instead of the playlist-style modal. Shows artist image, album art, and uses album download context for correct file organization', page: 'discover' },
|
||||||
|
{ title: 'Fix Tool Help Modal Not Closable', desc: 'The help "?" modal on automation triggers/actions could not be closed if the Tools page hadn\'t been visited first. Close button, backdrop click, and Escape key now work from any page' },
|
||||||
|
{ title: 'Fix Spotify OAuth Port Steal in Docker', desc: 'On fresh installs, Spotify auth probe silently started an HTTP server that stole port 8008 (crash loop) or bound loopback-only on 8888 (unreachable from host). Now skips the probe when no cached token exists' },
|
||||||
{ title: 'Genre Whitelist', desc: 'Filter junk genre tags (artist names, radio shows, playlist names) from enrichment. Enable strict mode in Settings → Library Preferences → Genre Whitelist. 272 curated default genres, fully customizable — add, remove, search, reset. Applied across all 10 enrichment sources. Off by default', page: 'settings' },
|
{ title: 'Genre Whitelist', desc: 'Filter junk genre tags (artist names, radio shows, playlist names) from enrichment. Enable strict mode in Settings → Library Preferences → Genre Whitelist. 272 curated default genres, fully customizable — add, remove, search, reset. Applied across all 10 enrichment sources. Off by default', page: 'settings' },
|
||||||
{ title: 'Per-Artist Watchlist Scan Source', desc: 'Override which metadata provider (Spotify, Deezer, Apple Music, Discogs) is used when scanning a specific watchlist artist for new releases. Source selector in the artist config modal only shows providers the artist has enrichment matches for. Global default unchanged unless explicitly overridden', page: 'watchlist' },
|
{ title: 'Per-Artist Watchlist Scan Source', desc: 'Override which metadata provider (Spotify, Deezer, Apple Music, Discogs) is used when scanning a specific watchlist artist for new releases. Source selector in the artist config modal only shows providers the artist has enrichment matches for. Global default unchanged unless explicitly overridden', page: 'watchlist' },
|
||||||
{ title: 'Standalone Full Refresh', desc: 'Full Refresh now works for SoulSync Standalone mode — clears all soulsync library records and rebuilds from audio file tags in the output folder. Previously did nothing for standalone users', page: 'tools' },
|
{ title: 'Standalone Full Refresh', desc: 'Full Refresh now works for SoulSync Standalone mode — clears all soulsync library records and rebuilds from audio file tags in the output folder. Previously did nothing for standalone users', page: 'tools' },
|
||||||
|
|
|
||||||
|
|
@ -5755,6 +5755,12 @@ function switchSettingsTab(tab) {
|
||||||
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
|
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
|
||||||
try { loadDbMaintenanceInfo(); } catch (e) { }
|
try { loadDbMaintenanceInfo(); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
// Initialize live log viewer when switching to Logs tab
|
||||||
|
if (tab === 'logs') {
|
||||||
|
_logViewerInit();
|
||||||
|
} else {
|
||||||
|
_logViewerStop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStgService(el) {
|
function toggleStgService(el) {
|
||||||
|
|
@ -6974,6 +6980,139 @@ function _collectGenreWhitelist() {
|
||||||
return _genreWhitelistCache;
|
return _genreWhitelistCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Live Log Viewer ──
|
||||||
|
let _logViewerActive = false;
|
||||||
|
let _logViewerFilter = '';
|
||||||
|
let _logViewerSource = 'app';
|
||||||
|
const _LOG_MAX_LINES = 1000;
|
||||||
|
|
||||||
|
async function _logViewerInit() {
|
||||||
|
if (_logViewerActive) return;
|
||||||
|
_logViewerActive = true;
|
||||||
|
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
|
||||||
|
|
||||||
|
// Fetch initial tail
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ source: _logViewerSource, lines: 200 });
|
||||||
|
if (_logViewerFilter) params.set('level', _logViewerFilter);
|
||||||
|
const resp = await fetch(`/api/logs/tail?${params}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.lines) {
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
_logViewerAppendLines(data.lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load initial logs:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live updates
|
||||||
|
if (typeof socket !== 'undefined' && socket && socket.connected) {
|
||||||
|
socket.emit('logs:subscribe', { source: _logViewerSource });
|
||||||
|
socket.on('logs:live', _logViewerOnLive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerStop() {
|
||||||
|
if (!_logViewerActive) return;
|
||||||
|
_logViewerActive = false;
|
||||||
|
if (typeof socket !== 'undefined' && socket) {
|
||||||
|
socket.off('logs:live', _logViewerOnLive);
|
||||||
|
socket.emit('logs:unsubscribe', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerOnLive(data) {
|
||||||
|
if (!_logViewerActive || !data.lines) return;
|
||||||
|
if (data.source !== _logViewerSource) return;
|
||||||
|
const filtered = _logViewerFilter
|
||||||
|
? data.lines.filter(l => l.includes(` - ${_logViewerFilter} - `))
|
||||||
|
: data.lines;
|
||||||
|
if (filtered.length > 0) _logViewerAppendLines(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerAppendLines(lines) {
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (!container) return;
|
||||||
|
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
|
||||||
|
const terminal = document.getElementById('log-viewer-terminal');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'log-line ' + _logViewerGetClass(line);
|
||||||
|
div.textContent = line;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim old lines
|
||||||
|
while (container.children.length > _LOG_MAX_LINES) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
const countEl = document.getElementById('log-viewer-line-count');
|
||||||
|
if (countEl) countEl.textContent = `${container.children.length} lines`;
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
if (autoScroll && terminal) {
|
||||||
|
terminal.scrollTop = terminal.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerGetClass(line) {
|
||||||
|
if (line.includes(' - DEBUG - ')) return 'log-debug';
|
||||||
|
if (line.includes(' - INFO - ')) return 'log-info';
|
||||||
|
if (line.includes(' - WARNING - ')) return 'log-warning';
|
||||||
|
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'log-error';
|
||||||
|
return 'log-plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _logViewerChangeSource() {
|
||||||
|
_logViewerStop();
|
||||||
|
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
|
||||||
|
await _logViewerInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerFilterLevel(btn) {
|
||||||
|
document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
_logViewerFilter = btn.dataset.level || '';
|
||||||
|
// Reload with filter
|
||||||
|
_logViewerStop();
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (container) container.innerHTML = '';
|
||||||
|
_logViewerInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerCopy() {
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (!container) return;
|
||||||
|
const text = Array.from(container.children).map(el => el.textContent).join('\n');
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success'));
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
showToast('Logs copied', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logViewerClear() {
|
||||||
|
const container = document.getElementById('log-viewer-lines');
|
||||||
|
if (container) container.innerHTML = '';
|
||||||
|
const countEl = document.getElementById('log-viewer-line-count');
|
||||||
|
if (countEl) countEl.textContent = '0 lines';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Database Maintenance ──
|
// ── Database Maintenance ──
|
||||||
async function loadDbMaintenanceInfo() {
|
async function loadDbMaintenanceInfo() {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -17213,6 +17213,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
color: #ef5350;
|
color: #ef5350;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live Log Viewer ── */
|
||||||
|
.log-viewer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-select {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-btn:hover { background: rgba(255, 255, 255, 0.06); color: #fff; }
|
||||||
|
.log-filter-btn.active { background: rgba(255, 255, 255, 0.1); color: #fff; border-color: rgba(255, 255, 255, 0.25); }
|
||||||
|
.log-filter-btn.lvl-debug.active { color: #888; border-color: #666; }
|
||||||
|
.log-filter-btn.lvl-info.active { color: #4fc3f7; border-color: #4fc3f7; }
|
||||||
|
.log-filter-btn.lvl-warning.active { color: #ffd54f; border-color: #ffd54f; }
|
||||||
|
.log-filter-btn.lvl-error.active { color: #ef5350; border-color: #ef5350; }
|
||||||
|
|
||||||
|
.log-viewer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-action-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-action-btn:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
|
||||||
|
|
||||||
|
.log-autoscroll-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-terminal {
|
||||||
|
background: #0a0a14;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-terminal::-webkit-scrollbar { width: 6px; }
|
||||||
|
.log-viewer-terminal::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.log-viewer-terminal::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
|
||||||
|
|
||||||
|
.log-viewer-lines {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
padding: 1px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-debug { color: #666; }
|
||||||
|
.log-info { color: #4fc3f7; }
|
||||||
|
.log-warning { color: #ffd54f; }
|
||||||
|
.log-error { color: #ef5350; font-weight: 500; }
|
||||||
|
.log-plain { color: #aaa; }
|
||||||
|
|
||||||
|
.log-viewer-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-live-dot {
|
||||||
|
color: #4caf50;
|
||||||
|
animation: log-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes log-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
.config-options {
|
.config-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue