fix(logs): clipboard fallback + severity filter for Logs panel (#2081)

- replace navigator.clipboard.writeText with _copyText (has textarea fallback)
- add severity filter dropdown (All / Errors / Warnings+)
- add _severityForLine and _filteredLogsLines helpers
- add logsSeverityFilter HTML element + CSS class hooks
- add 5 new i18n keys across all 8 locales
- update test_logs_ui_static.py to match new implementation

Closes #2081
This commit is contained in:
bergeouss
2026-05-11 15:40:49 +00:00
parent b766b7f759
commit 85547612fe
4 changed files with 90 additions and 9 deletions
+40
View File
@@ -645,6 +645,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet',
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.',
logs_copied: 'Logs copied',
logs_severity: 'Severity',
logs_severity_all: 'All',
logs_severity_errors: 'Errors',
logs_severity_warnings: 'Warnings+',
logs_filter_active: 'shown (filter active)',
// Insights
insights_title: 'Usage Analytics',
@@ -1735,6 +1740,11 @@ const LOCALES = {
logs_no_mtime: '未書き込み',
logs_truncated_hint: '大きなログファイルの末尾を表示しています。メモリ使用量を抑えるため、古いデータは省略されました。',
logs_copied: 'ログをコピーしました',
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
// Insights
insights_title: '使用状況分析',
@@ -2639,6 +2649,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
session_time_unknown: 'Неизвестно',
@@ -3663,6 +3678,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...',
session_time_unknown: 'Desconocido',
@@ -4670,6 +4690,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
scheduled_jobs: 'Geplante Aufgaben',
@@ -5710,6 +5735,11 @@ const LOCALES = {
logs_no_mtime: '尚未写入',
logs_truncated_hint: '此处显示的是日志文件的末尾内容。为节省内存,已省略较早的数据。',
logs_copied: '日志已复制',
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
@@ -7902,6 +7932,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: 'Nova conversa',
filter_conversations: 'Filtrar conversas...',
session_time_unknown: 'Desconhecido',
@@ -8890,6 +8925,11 @@ const LOCALES = {
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
logs_severity: 'Severity', // TODO: translate
logs_severity_all: 'All', // TODO: translate
logs_severity_errors: 'Errors', // TODO: translate
logs_severity_warnings: 'Warnings+', // TODO: translate
logs_filter_active: 'shown (filter active)', // TODO: translate
new_conversation: '새 대화',
filter_conversations: '대화 필터…',
session_time_unknown: 'Unknown',
+6
View File
@@ -257,6 +257,12 @@
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<label class="logs-control-label" for="logsSeverityFilter" data-i18n="logs_severity">Severity</label>
<select id="logsSeverityFilter" onchange="_applyLogsSeverityFilter()">
<option value="all" data-i18n="logs_severity_all">All</option>
<option value="errors" data-i18n="logs_severity_errors">Errors</option>
<option value="warnings" data-i18n="logs_severity_warnings">Warnings+</option>
</select>
<label class="logs-check-row"><input id="logsAutoRefresh" type="checkbox" checked onchange="_syncLogsAutoRefresh()"><span data-i18n="logs_auto_refresh">Auto-refresh (5s)</span></label>
<label class="logs-check-row"><input id="logsWrap" type="checkbox" onchange="_syncLogsWrap()"><span data-i18n="logs_wrap">Wrap lines</span></label>
<button type="button" class="logs-copy" id="logsCopyAll" onclick="copyLogsAll()" data-i18n="logs_copy_all">Copy all</button>
+41 -8
View File
@@ -32,6 +32,7 @@ let _profilePreFormDetail = null;
let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes
let _logsAutoRefreshTimer = null;
let _lastLogsLines = [];
let _logsSeverityFilter = 'all';
// Map of panel names → i18n keys for the app titlebar label.
const APP_TITLEBAR_KEYS = {
@@ -2663,6 +2664,32 @@ function _selectedLogsTail() {
return [100,200,500,1000].includes(value) ? value : 200;
}
function _severityForLine(line) {
const text = String(line || '').toUpperCase();
if (/\b(ERROR|CRITICAL|TRACEBACK)\b/.test(text)) return 'error';
if (/\b(WARNING|WARN)\b/.test(text)) return 'warning';
if (/\b(DEBUG)\b/.test(text)) return 'debug';
if (/\b(INFO)\b/.test(text)) return 'info';
return 'other';
}
function _filteredLogsLines() {
if (_logsSeverityFilter === 'all') return _lastLogsLines;
return _lastLogsLines.filter(line => {
const sev = _severityForLine(line);
if (_logsSeverityFilter === 'errors') return sev === 'error';
if (_logsSeverityFilter === 'warnings') return sev === 'warning' || sev === 'error';
return true;
});
}
function _applyLogsSeverityFilter() {
const el = $('logsSeverityFilter');
_logsSeverityFilter = (el && el.value) || 'all';
// Re-render from cached lines without re-fetching
_renderLogs({ lines: _lastLogsLines, hint: '', truncated: false, _fromFilter: true });
}
function _logLineSeverityClass(line) {
const text = String(line || '').toUpperCase();
if (/\b(WARNING|WARN)\b/.test(text)) return 'log-line-warning';
@@ -2710,14 +2737,19 @@ function _renderLogs(data) {
const box = $('logsOutput');
const status = $('logsStatus');
if (!box) return;
const lines = Array.isArray(data && data.lines) ? data.lines : [];
_lastLogsLines = lines.slice();
const rawLines = Array.isArray(data && data.lines) ? data.lines : [];
// Only update cache when loading fresh data (not when re-rendering from filter)
if (data && !data._fromFilter) _lastLogsLines = rawLines.slice();
const displayLines = _filteredLogsLines();
const hint = data && data.hint ? `<div class="logs-hint">${esc(data.hint)}</div>` : '';
const truncated = data && data.truncated ? `<div class="logs-hint warn">${esc(t('logs_truncated_hint'))}</div>` : '';
if (!lines.length) {
box.innerHTML = `${hint}${truncated}<div class="logs-empty">${esc(t('logs_empty'))}</div>`;
const filterNote = _logsSeverityFilter !== 'all'
? `<div class="logs-hint">${esc(displayLines.length + ' / ' + _lastLogsLines.length + ' ' + t('logs_filter_active'))}</div>`
: '';
if (!displayLines.length) {
box.innerHTML = `${hint}${truncated}${filterNote}<div class="logs-empty">${esc(t('logs_empty'))}</div>`;
} else {
box.innerHTML = `${hint}${truncated}` + lines.map(line => {
box.innerHTML = `${hint}${truncated}${filterNote}` + displayLines.map(line => {
const cls = _logLineSeverityClass(line);
return `<div class="log-line ${cls}">${esc(line)}</div>`;
}).join('');
@@ -2726,7 +2758,7 @@ function _renderLogs(data) {
if (status) {
const bytes = data && Number(data.total_bytes || 0);
const when = data && data.mtime ? new Date(data.mtime * 1000).toLocaleString() : t('logs_no_mtime');
status.textContent = `${lines.length} / ${data.tail || _selectedLogsTail()} lines · ${bytes.toLocaleString()} bytes · ${when}`;
status.textContent = `${rawLines.length} / ${data.tail || _selectedLogsTail()} lines · ${bytes.toLocaleString()} bytes · ${when}`;
}
}
@@ -2754,9 +2786,10 @@ function _syncLogsAutoRefresh() {
}
async function copyLogsAll() {
const text = _lastLogsLines.join('\n');
const lines = _filteredLogsLines();
const text = lines.join('\n');
try {
await navigator.clipboard.writeText(text);
await _copyText(text);
showToast(t('logs_copied'));
} catch(e) {
showToast(t('copy_failed'), 'error');
+3 -1
View File
@@ -93,7 +93,9 @@ def test_logs_panel_fetches_allowlisted_api_and_exposes_controls():
assert "logsWrap" in INDEX
assert "logsCopyAll" in INDEX
assert "logsAutoRefresh" in INDEX
assert "navigator.clipboard.writeText" in PANELS
assert "logsSeverityFilter" in INDEX
copy_fn = _function_body(PANELS, "copyLogsAll")
assert "_copyText" in copy_fn
assert "logs-copy" in INDEX