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}` : ''}
-
@@ -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": "从",