From ae74cca13241b649b43e4c5a346088ba270765a8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 10 Jun 2026 15:24:45 +0300 Subject: [PATCH] fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix - export button is now a click-toggle menu (aria-haspopup/expanded, role=menu, caret, Escape + click-outside close) - filter placeholders moved to i18n (actor/entity_type .placeholder keys, en/ru/zh) - _fetchPage clears _loading before render so a zero-result page doesn't spin forever - toolbar/entry use elevated card surface (--lux-bg-1); light-theme device badge contrast; mobile message grid --- .../src/ledgrab/static/css/activity-log.css | 43 +++++++++---- .../static/js/features/activity-log.ts | 64 +++++++++++++++---- server/src/ledgrab/static/locales/en.json | 2 + server/src/ledgrab/static/locales/ru.json | 2 + server/src/ledgrab/static/locales/zh.json | 2 + 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css index 9ed094d..1adb498 100644 --- a/server/src/ledgrab/static/css/activity-log.css +++ b/server/src/ledgrab/static/css/activity-log.css @@ -24,8 +24,11 @@ flex-direction: column; gap: var(--space-sm); padding: var(--space-md) var(--space-md); - background: var(--bg-secondary); - border: var(--lux-hairline) solid var(--border-color); + /* Match the elevated card surface used by entity cards (.dashboard-target), + not the near-black --bg-secondary, so the panel reads as one of the app's + cards rather than a separate flat sheet. */ + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline) solid var(--lux-line, var(--border-color)); border-radius: var(--radius-md); margin-bottom: var(--space-md); } @@ -137,6 +140,16 @@ .al-export-btn .icon { width: 14px; height: 14px; } +/* Caret signals this button opens a menu (rather than firing a direct action), + and rotates to point up while the menu is open. */ +.al-export-caret { + display: inline-flex; + margin-left: 1px; + transition: transform var(--duration-fast) var(--ease-out); +} +.al-export-caret .icon { width: 12px; height: 12px; } +.al-export-wrap.open .al-export-caret { transform: rotate(180deg); } + .al-export-menu { display: none; position: absolute; @@ -151,8 +164,7 @@ overflow: hidden; } -.al-export-wrap:hover .al-export-menu, -.al-export-wrap:focus-within .al-export-menu { display: block; } +.al-export-wrap.open .al-export-menu { display: block; } .al-export-menu button { display: block; @@ -166,7 +178,8 @@ cursor: pointer; } -.al-export-menu button:hover { background: var(--bg-secondary); } +.al-export-menu button:hover, +.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; } /* Filter label */ .al-filter-label { @@ -311,8 +324,9 @@ } .al-entry { - background: var(--card-bg); - border: var(--lux-hairline) solid var(--border-color); + /* Same elevated surface + hairline as entity cards (see .al-toolbar). */ + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline) solid var(--lux-line, var(--border-color)); border-radius: var(--radius-sm); overflow: hidden; transition: border-color var(--duration-fast); @@ -320,10 +334,11 @@ .al-entry:hover { border-color: var(--text-muted); } -/* New-entry flash */ +/* New-entry flash — settles on the card surface (animation-fill-mode: forwards + holds the 100% frame, so it must match the .al-entry background exactly). */ @keyframes al-new-flash { - 0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)); } - 100% { background: var(--card-bg); } + 0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); } + 100% { background: var(--lux-bg-1, var(--card-bg)); } } .al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; } @@ -382,7 +397,9 @@ .al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); } [data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); } -[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); } +/* Darker purple text in light theme — the dark-theme #ab47bc fails AA contrast + on the pale tinted background at this small badge size. */ +[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; } [data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); } [data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); } [data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); } @@ -585,9 +602,11 @@ gap: 4px var(--space-xs); } + /* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message. + message stays in its own 1fr column so it never overlaps the badge. */ .al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; } .al-cat-badge{ grid-column: 3; grid-row: 1; } - .al-msg { grid-column: 2 / span 2; grid-row: 1; } + .al-msg { grid-column: 2; grid-row: 1; } .al-toolbar-advanced .al-field-group { min-width: 100%; } } diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts index 6a8d9ae..3f20482 100644 --- a/server/src/ledgrab/static/js/features/activity-log.ts +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -259,7 +259,7 @@ function _renderFilterToolbar(): string { const catChips = CATEGORIES.map(cat => { const active = _filters.categories.includes(cat); return ``; }).join(''); @@ -267,7 +267,7 @@ function _renderFilterToolbar(): string { const active = _filters.severities.includes(sev); const icon = _severityIcon(sev); return ``; }).join(''); @@ -278,7 +278,7 @@ function _renderFilterToolbar(): string { { key: 'devices', label: t('activity_log.preset.devices') }, ]; const presetBtns = presets.map(p => - `` + `` ).join(''); const hasFilters = _filters.categories.length || _filters.severities.length || @@ -300,12 +300,12 @@ function _renderFilterToolbar(): string { aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}` : ''}
-
- - + data-al-export-toggle aria-haspopup="menu" aria-expanded="false" + title="${escapeHtml(t('activity_log.export'))}" + aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} ${escapeHtml(t('activity_log.export'))} +
@@ -320,7 +320,7 @@ function _renderFilterToolbar(): string { @@ -328,7 +328,7 @@ function _renderFilterToolbar(): string { @@ -395,6 +395,15 @@ function _renderList(): string { let _delegatedClickAttached = false; +/** Collapse the export dropdown if open (idempotent). */ +function _closeExportMenu(): void { + const wrap = document.getElementById('tab-activity_log') + ?.querySelector('.al-export-wrap.open'); + if (!wrap) return; + wrap.classList.remove('open'); + wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false'); +} + function _attachDelegatedClicks(): void { if (_delegatedClickAttached) return; const panel = document.getElementById('tab-activity_log'); @@ -404,6 +413,24 @@ function _attachDelegatedClicks(): void { panel.addEventListener('click', (e: MouseEvent) => { const target = e.target as HTMLElement; + // Export menu: toggle button opens/closes the CSV/JSON dropdown. + const exportToggle = target.closest('[data-al-export-toggle]'); + if (exportToggle) { + const wrap = exportToggle.closest('.al-export-wrap'); + const open = wrap?.classList.toggle('open') ?? false; + exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + return; + } + // Export menu: a menu item's inline onclick triggers the download (it + // runs first, on the deeper element) — we just collapse the menu after. + if (target.closest('.al-export-menu')) { + _closeExportMenu(); + return; + } + // Any other click in the panel dismisses an open export menu, then + // continues to row / entity handling below. + _closeExportMenu(); + // Entity navigation: click on data-entity-type button const entityBtn = target.closest('button.al-entity-link[data-entity-type]'); if (entityBtn) { @@ -424,6 +451,15 @@ function _attachDelegatedClicks(): void { }); panel.addEventListener('keydown', (e: KeyboardEvent) => { + // Escape closes the export menu and restores focus to its trigger. + if (e.key === 'Escape') { + const toggle = panel.querySelector('.al-export-wrap.open [data-al-export-toggle]'); + if (toggle) { + _closeExportMenu(); + toggle.focus(); + } + return; + } if (e.key !== 'Enter' && e.key !== ' ') return; const row = (e.target as HTMLElement).closest('.al-entry-row[data-toggle-id]'); if (row) { @@ -488,6 +524,12 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom _nextBeforeSeq = page.next_before_seq; _hasMore = page.has_more; _total = page.total; + // Reset the loading flag BEFORE rendering: _renderList() shows the + // spinner whenever (_loading && _entries.length === 0), so a zero-result + // page (e.g. an unmatched entity-type filter, or a fresh install) would + // otherwise render the spinner here and spin forever — the finally below + // clears _loading but does not re-render. + _loading = false; _updateListContainer(); } catch (e: unknown) { if (e && typeof e === 'object' && 'isAuth' in e) return; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index c334c2f..0b48d2a 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -30,9 +30,11 @@ "activity_log.export.error": "Export failed.", "activity_log.export.json": "Export JSON", "activity_log.filter.actor": "Actor", + "activity_log.filter.actor.placeholder": "system, api-key-name…", "activity_log.filter.category": "Category", "activity_log.filter.clear": "Clear filters", "activity_log.filter.entity_type": "Entity type", + "activity_log.filter.entity_type.placeholder": "output_target, device…", "activity_log.filter.search": "Search messages…", "activity_log.filter.severity": "Severity", "activity_log.filter.since": "From", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index f26a4e0..6b8223e 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -30,9 +30,11 @@ "activity_log.export.error": "Ошибка экспорта.", "activity_log.export.json": "Экспорт JSON", "activity_log.filter.actor": "Субъект", + "activity_log.filter.actor.placeholder": "system, имя API-ключа…", "activity_log.filter.category": "Категория", "activity_log.filter.clear": "Сбросить фильтры", "activity_log.filter.entity_type": "Тип сущности", + "activity_log.filter.entity_type.placeholder": "output_target, device…", "activity_log.filter.search": "Поиск сообщений…", "activity_log.filter.severity": "Уровень", "activity_log.filter.since": "С", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index df39718..82110d2 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -30,9 +30,11 @@ "activity_log.export.error": "导出失败。", "activity_log.export.json": "导出 JSON", "activity_log.filter.actor": "操作者", + "activity_log.filter.actor.placeholder": "system、API 密钥名…", "activity_log.filter.category": "类别", "activity_log.filter.clear": "清除过滤", "activity_log.filter.entity_type": "实体类型", + "activity_log.filter.entity_type.placeholder": "output_target, device…", "activity_log.filter.search": "搜索消息…", "activity_log.filter.severity": "严重性", "activity_log.filter.since": "从",