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}")
|
||||
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
|
||||
# ===========================
|
||||
|
|
@ -54390,6 +54459,69 @@ def _emit_tool_progress_loop():
|
|||
except Exception as 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')
|
||||
def handle_sync_subscribe(data):
|
||||
for pid in data.get('playlist_ids', []):
|
||||
|
|
@ -54680,7 +54812,9 @@ def start_runtime_services():
|
|||
socketio.start_background_task(_hydrabase_reconnect_loop)
|
||||
# API Rate Monitor — 1s push for speedometer gauges
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -3843,6 +3843,7 @@
|
|||
<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="advanced" onclick="switchSettingsTab('advanced')">Advanced</button>
|
||||
<button class="stg-tab" data-tab="logs" onclick="switchSettingsTab('logs')">Logs</button>
|
||||
</div>
|
||||
<!-- Settings Panels -->
|
||||
<div class="settings-columns">
|
||||
|
|
@ -5484,6 +5485,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end Library Preferences body -->
|
||||
|
||||
<!-- Security Settings -->
|
||||
<div class="settings-group" data-stg="advanced">
|
||||
<h3>🔒 Security</h3>
|
||||
|
|
@ -5750,8 +5753,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end Library Preferences body -->
|
||||
|
||||
<!-- Logging Settings -->
|
||||
<div class="settings-group" data-stg="advanced">
|
||||
<h3>Logging</h3>
|
||||
|
|
@ -5847,6 +5848,44 @@
|
|||
</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 -->
|
||||
<div class="settings-actions">
|
||||
<button class="save-button" id="save-settings">Save Settings</button>
|
||||
|
|
|
|||
|
|
@ -3602,6 +3602,11 @@ const WHATS_NEW = {
|
|||
'2.32': [
|
||||
// --- 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: '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' },
|
||||
|
|
|
|||
|
|
@ -5755,6 +5755,12 @@ function switchSettingsTab(tab) {
|
|||
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
|
||||
try { loadDbMaintenanceInfo(); } catch (e) { }
|
||||
}
|
||||
// Initialize live log viewer when switching to Logs tab
|
||||
if (tab === 'logs') {
|
||||
_logViewerInit();
|
||||
} else {
|
||||
_logViewerStop();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStgService(el) {
|
||||
|
|
@ -6974,6 +6980,139 @@ function _collectGenreWhitelist() {
|
|||
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 ──
|
||||
async function loadDbMaintenanceInfo() {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -17213,6 +17213,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in a new issue