mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user