feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export)

- new top-level Activity tab: filter toolbar (category/severity chips, presets, debounced search, actor/entity/date), keyset load-more, expandable detail
- live prepend via server:activity_logged; authed CSV/JSON blob export
- formatTimestamp/formatRelativeTime in core/ui.ts; history+severity SVG icons; Ctrl+7 shortcut
- i18n activity_log.* across en/ru/zh; getting-started tutorial step; activity-log.css (themed)
- review fixes: newest-first ordering, attribute-context XSS hardening (_escapeAttr + event delegation)
This commit is contained in:
2026-06-09 20:42:44 +03:00
parent 4a0927521a
commit 9a0137fa4c
17 changed files with 1714 additions and 44 deletions
+1
View File
@@ -69,3 +69,4 @@ Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + co
Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean.
Phase 4 landed (2026-06-09): schemas (`api/schemas/activity_log.py`), routes (`api/routes/activity_log.py`: list/export/settings/clear), router registration in `api/__init__.py`, `get_seq_for_id` helper on `ActivityLogRepository`. 49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean. Pagination bug found and fixed (limit+1 probe must drop oldest row when has_more, not tail).
Phase 3 landed (2026-06-09): instrumented all four categories — entity CRUD via `fire_entity_event` choke-point (`dependencies.py`), auth failures + WS session in `auth.py`, device online/offline in `device_health.py`, device discovered/lost in `discovery_watcher.py`, ADB connect/disconnect in `system_settings.py`, capture start/stop (individual + bulk) in `output_targets_control.py`, scene/playlist/automation activate in their respective route/engine files, backup/restore/delete + restart/shutdown/update/calibration/settings in `backup.py`/`update.py`/`calibration.py`; all 11 entity delete handlers pass `entity_name` to `fire_entity_event`; 22 new tests (security: token never in any field, explicitly asserted) — all green. Full suite 2369 passed, 2 skipped, 0 failed. Ruff clean. Complete (category, action) inventory in phase-3-instrumentation.md Handoff section.
Phase 5 landed (2026-06-09): Activity tab frontend — `features/activity-log.ts` (loadActivityLog, filter toolbar with category/severity chips + presets + debounced search + date range + actor/entity-type text fields, keyset-paginated list, expandable detail drawer, live-prepend via `server:activity_logged`, authed CSV/JSON blob export); `core/ui.ts` additions (`formatTimestamp`, `formatRelativeTime`); `core/icon-paths.ts` + `core/icons.ts` additions (scrollText/audit, circleAlert, info, filter, xCircle); `core/tab-registry.ts` registered; `templates/index.html` tab button + panel; `app.ts` + `global.d.ts` wired; `static/css/activity-log.css` (precision-ledger design, category color rail, live-dot pulse, detail drawer, responsive breakpoints); all three locales updated (activity_log.* + time.* relative-time keys + tour.activity_log); `features/tutorials.ts` getting-started tour extended. `tsc --noEmit` clean, `npm run build` passes. Reusable helpers for Phase 6 documented in phase-5-frontend-tab.md Handoff section.
+5 -1
View File
@@ -83,7 +83,7 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
| Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 4: REST API | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 5: Frontend tab | frontend | ⬜ Not Started | ⬜ | ⬜ | |
| Phase 5: Frontend tab | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | |
| Phase 6: Dashboard/Settings | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Outstanding Warnings
@@ -99,6 +99,10 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem).
| 4 | PUT /settings only AuthRequired → anon could disable auditing/prune trail | 🟠 High (security) | resolved — `require_authenticated` on settings PUT |
| 4 | CSV formula-injection missed leading TAB/CR | 🟡 Medium (security) | resolved — added `\t`/`\r` to guard |
| 4 | `total` count full-scans on every list request | 🔵 Low (perf) | accepted — bounded by retention; read-only; optional opt-in deferred |
| 5 | Inverted list ordering broke pagination + live-append | 🔴 Blocker | resolved — pages reversed to newest-first; re-review PASS |
| 5 | Attribute-context XSS (entity_name title + JSON.stringify onclick) | 🟡 Warning (security) | resolved — `_escapeAttr` + data-attr event delegation |
| 5 | Filter toolbar value= attrs not quote-escaped (new code) | 🟡 Warning (security) | resolved — `_escapeAttr` on q/actor/entity_type/since/until |
| 5 | Manual browser smoke test (tab loads, filters, live, export) | 🔵 Note | open — recommend at final review (server restart needed) |
## Final Review
+85 -39
View File
@@ -1,6 +1,6 @@
# Phase 5: Frontend — Activity tab + smart filtering + live updates
**Status:** ⬜ Not Started
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend · uses the `frontend-design` skill
@@ -12,14 +12,14 @@ This is a viewer (Dashboard-style), NOT a CRUD card section.
## Tasks
- [ ] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware)
- [x] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware)
and `formatRelativeTime(isoOrMs)` ("2m ago"), with `tabular-nums` styling guidance for the
list. Reuse existing `time.*` i18n key conventions.
- [ ] `features/activity-log.ts`:
- [x] `features/activity-log.ts`:
- `export async function loadActivityLog()` — fetch first page from
`GET /activity-log` (via `fetchWithAuth`), render the toolbar + list into the panel.
- **Smart filter toolbar:** category (multi, IconSelect/chips), severity (chips), actor
(EntitySelect/text), entity type, date range, free-text search (debounced). Quick presets:
- **Smart filter toolbar:** category (multi, chips), severity (chips), actor
(text input), entity type, date range, free-text search (debounced). Quick presets:
Today / Errors / Auth / Devices. Filters drive server-side query params (no client-side
filtering of a partial page). Re-query on change; reset cursor.
- **List:** one row per entry — severity icon, category badge, relative time (title=absolute),
@@ -32,60 +32,106 @@ This is a viewer (Dashboard-style), NOT a CRUD card section.
- **Export button:** triggers `GET /activity-log/export?format=…` with current filters via an
authed blob download (use `fetchWithAuth` → blob URL, per frontend.md auth rules).
- Empty / loading / error states; re-render on `languageChanged`.
- [ ] Tab wiring:
- [x] Tab wiring:
- `core/tab-registry.ts`: add `activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }`.
- `templates/index.html`: sidebar tab button (`data-tab`, `switchTab('activity_log')`,
history/clock SVG icon, `data-i18n`) + `<div class="tab-panel" id="tab-activity_log">`.
- `app.ts`: import + `Object.assign(window, { loadActivityLog, … })`; `global.d.ts` decls.
- [ ] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons
- [x] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons
(info/warning/error) reuse existing constants where possible.
- [ ] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels,
- [x] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels,
category/severity names, column labels, presets, empty/error, export, "N entries").
- [ ] Tutorials: add an Activity-tab step to the getting-started tour in
- [x] Tutorials: add an Activity-tab step to the getting-started tour in
`features/tutorials.ts` + `tour.*` keys in all 3 locales.
## Files to Modify/Create
- `server/src/ledgrab/static/js/core/ui.ts` — modify: timestamp/relative-time formatters
- `server/src/ledgrab/static/js/features/activity-log.ts`new: the viewer
- `server/src/ledgrab/static/js/core/tab-registry.ts` — modify: register tab
- `server/src/ledgrab/templates/index.html` — modify: tab button + panel
- `server/src/ledgrab/static/js/app.ts` — modify: import + window globals
- `server/src/ledgrab/static/js/global.d.ts` — modify: window decls
- `server/src/ledgrab/static/js/core/icon-paths.ts` / `core/icons.ts` — modify: icons
- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modify: i18n keys
- `server/src/ledgrab/static/js/features/tutorials.ts` — modify: tour step
- `server/src/ledgrab/static/css/*` — modify/new: list + toolbar styling (follow base.css vars)
- `server/src/ledgrab/static/js/core/ui.ts` — modified: timestamp/relative-time formatters
- `server/src/ledgrab/static/js/features/activity-log.ts`NEW: the viewer
- `server/src/ledgrab/static/js/core/tab-registry.ts` — modified: register tab
- `server/src/ledgrab/templates/index.html` — modified: tab button + panel
- `server/src/ledgrab/static/js/app.ts` — modified: import + window globals
- `server/src/ledgrab/static/js/global.d.ts` — modified: window decls
- `server/src/ledgrab/static/js/core/icon-paths.ts` — modified: icons (scrollText, circleAlert, info, filter, xCircle)
- `server/src/ledgrab/static/js/core/icons.ts` — modified: ICON_ACTIVITY_LOG, ICON_SEVERITY_*, ICON_FILTER, ICON_X_CIRCLE
- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modified: activity_log.* + time.* + tour.activity_log
- `server/src/ledgrab/static/js/features/tutorials.ts` — modified: gettingStartedSteps
- `server/src/ledgrab/static/css/activity-log.css` — NEW: list + toolbar styling
- `server/src/ledgrab/static/css/all.css` — modified: import activity-log.css
## Acceptance Criteria
- New **Activity** tab loads, lists entries, and paginates via keyset "load more".
- Filters hit server-side query params; quick presets work; free-text is debounced.
- New events append live via `server:activity_logged` and respect active filters.
- Export downloads CSV/JSON with auth, honoring current filters.
- Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change.
- No plain `<select>` (use IconSelect/EntitySelect/chips); SVG icons only (no emoji).
- `npx tsc --noEmit` clean; `npm run build` succeeds.
- [x] New **Activity** tab loads, lists entries, and paginates via keyset "load more".
- [x] Filters hit server-side query params; quick presets work; free-text is debounced.
- [x] New events append live via `server:activity_logged` and respect active filters.
- [x] Export downloads CSV/JSON with auth, honoring current filters.
- [x] Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change.
- [x] No plain `<select>` (use chips); SVG icons only (no emoji).
- [x] `npx tsc --noEmit` clean; `npm run build` succeeds.
## Notes
- **Use the `frontend-design` skill** for the viewer layout, filter toolbar, and detail design
— aim for a polished, distinctive result consistent with the app's design language and CSS
variables in `static/css/base.css` (`--primary-color`, `--card-bg`, etc.).
- Models to mirror: `features/dashboard.ts` (non-card live viewer + load pattern),
`core/events-ws.ts` (`server:<type>` dispatch), `core/entity-palette.ts` (EntitySelect),
`core/icon-select.ts` (IconSelect). Auth/blob download rules: `contexts/frontend.md`.
- Frontend-only phase → run `tsc --noEmit` + `npm run build`; do NOT run pytest/ruff.
- **Used the `frontend-design` skill** for the viewer layout, filter toolbar, and detail design.
- Models mirrored: `features/dashboard.ts` (live viewer pattern), `core/events-ws.ts` (WS dispatch),
`core/api.ts` (fetchWithAuth), `core/navigation.ts` (navigateToCard).
- Frontend-only phase — ran `tsc --noEmit` + `npm run build`; did NOT run pytest/ruff.
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows frontend conventions (i18n ×3, icons, selectors, auth fetch, CSS vars)
- [ ] No unintended side effects
- [ ] Build passes (`tsc --noEmit` + `npm run build`)
- [x] All tasks completed
- [x] Code follows frontend conventions (i18n ×3, icons, auth fetch, CSS vars)
- [x] No unintended side effects
- [x] Build passes (`tsc --noEmit` + `npm run build`)
- [ ] Manual smoke: tab loads, filters query server, live append, export
## Handoff to Next Phase
<!-- Filled in by the implementer: the activity-log render/append functions Phase 6's
Dashboard widget can reuse, the i18n namespace, and the settings endpoint shape used. -->
### Reusable helpers for Phase 6 (Dashboard widget + Settings panel)
**From `features/activity-log.ts`:**
| Export | Purpose | How Phase 6 uses it |
|--------|---------|---------------------|
| `loadActivityLog()` | Loads the full tab (already registered as the tab loader) | Phase 6 doesn't call this, but it shares state |
| `activityLogToggleDetail(id)` | Expand/collapse an entry row | Dashboard widget can reuse for the Recent Activity list |
| `activityLogExport(format)` | Authed blob download with current filters | Could surface a direct link in the Settings panel |
| `_renderEntryRow(entry, isNew)` | Renders a single entry as an HTML string | Dashboard widget should import this to render its 5-10 most-recent entries |
| `_fetchPage(beforeSeq, append)` | Keyset-paged fetch from `/activity-log` | Dashboard widget calls with `limit=5&categories=…` for its mini-list |
**Note:** `_renderEntryRow` and `_fetchPage` are module-private (underscore prefix). Phase 6 should
either (a) re-export them with public names, or (b) duplicate the minimal render logic for the
compact widget format.
**Recommended approach for Phase 6:** Export two new public helpers:
```typescript
// Add to activity-log.ts
export async function fetchRecentEntries(limit = 5): Promise<ActivityEntry[]>
export function renderCompactEntry(entry: ActivityEntry): string
```
### i18n namespace
All keys are under `activity_log.*`. Time-relative formatters use `time.*` keys.
### CSS classes and tokens introduced
| Class | Purpose |
|-------|---------|
| `.al-panel` | Tab root wrapper |
| `.al-toolbar` | Filter toolbar container |
| `.al-chip` / `.al-chip.active` | Category/severity toggle chips |
| `.al-preset-btn` | Quick-preset buttons |
| `.al-entry` / `.al-entry-row` | Log entry row |
| `.al-detail` / `.al-detail-grid` | Expandable entry detail |
| `.al-cat-*` | Per-category badge colors (auth/device/entity/capture/system) |
| `.al-sev-info/warning/error` | Severity icon color classes |
| `.al-live-dot` | Pulsing green live-update dot |
| `.al-meta-pre` | Scrollable metadata JSON block |
| `.tabular-nums` | `font-variant-numeric: tabular-nums` utility |
### Settings endpoint shape used
Phase 6 Settings panel will call:
- `GET /activity-log/settings``{ enabled: bool, max_days: int, max_entries: int }`
- `PUT /activity-log/settings` → same shape (bounds: enabled=bool, max_days=03650, max_entries=010_000_000)
@@ -0,0 +1,626 @@
/* ─────────────────────────────────────────────────────────────────────────
Activity Log — audit viewer tab
Design language: precision-instrument / ledger. Monospaced timestamps,
color-coded severity rail, thin category pills. Clean "terminal" feel
without being cold — the primary green accent anchors the live-update dot.
───────────────────────────────────────────────────────────────────────── */
/* ── Panel wrapper ───────────────────────────────────────────────────────── */
.al-panel {
display: flex;
flex-direction: column;
gap: 0;
max-width: 1400px;
margin: 0 auto;
padding: var(--space-lg) var(--space-lg) var(--space-xl);
min-height: 0;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.al-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding-bottom: var(--space-md);
border-bottom: var(--lux-hairline) solid var(--border-color);
margin-bottom: var(--space-lg);
}
.al-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.al-header-icon .icon {
width: 22px;
height: 22px;
color: var(--primary-color);
flex-shrink: 0;
}
.al-header-title h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
}
.al-header-subtitle {
font-size: 0.8125rem;
color: var(--text-secondary);
margin: 0;
width: 100%;
}
/* ── Filter toolbar ──────────────────────────────────────────────────────── */
.al-toolbar {
display: flex;
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);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
}
.al-toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.al-toolbar-search {
gap: var(--space-sm);
}
/* Search input */
.al-search-wrap {
position: relative;
flex: 1;
min-width: 200px;
max-width: 420px;
}
.al-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-muted);
display: flex;
align-items: center;
}
.al-search-icon .icon {
width: 15px;
height: 15px;
}
.al-search-input {
width: 100%;
padding: 6px 10px 6px 34px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.875rem;
font-family: var(--font-body);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
outline: none;
}
.al-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.al-search-input::placeholder { color: var(--text-muted); }
/* Quick presets */
.al-presets {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-preset-btn {
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 500;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-preset-btn:hover {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Clear button */
.al-clear-btn {
padding: 4px 6px;
margin-left: auto;
color: var(--text-secondary);
}
.al-clear-btn:hover { color: var(--danger-color); border-color: var(--danger-color); }
/* Export button + dropdown */
.al-export-wrap {
position: relative;
margin-left: auto;
}
.al-export-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
font-size: 0.8125rem;
}
.al-export-btn .icon { width: 14px; height: 14px; }
.al-export-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--shadow-color);
z-index: 100;
min-width: 140px;
overflow: hidden;
}
.al-export-wrap:hover .al-export-menu,
.al-export-wrap:focus-within .al-export-menu { display: block; }
.al-export-menu button {
display: block;
width: 100%;
padding: 8px 14px;
text-align: left;
background: none;
border: none;
color: var(--text-color);
font-size: 0.8125rem;
cursor: pointer;
}
.al-export-menu button:hover { background: var(--bg-secondary); }
/* Filter label */
.al-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
flex-shrink: 0;
}
.al-filter-label-sep { margin-left: var(--space-sm); }
/* Category / severity chips */
.al-chip-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 0.75rem;
font-weight: 500;
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
background: var(--card-bg);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-chip .icon { width: 12px; height: 12px; }
.al-chip:hover {
background: var(--bg-secondary);
border-color: var(--text-secondary);
color: var(--text-color);
}
.al-chip.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Severity chip colors when active */
.al-sev-chip-error.active { background: var(--danger-color); border-color: var(--danger-color); }
.al-sev-chip-warning.active { background: var(--warning-color); border-color: var(--warning-color); }
.al-sev-chip-info.active { background: var(--info-color); border-color: var(--info-color); }
/* Advanced field row */
.al-toolbar-advanced {
gap: var(--space-sm);
padding-top: var(--space-xs);
border-top: var(--lux-hairline) solid var(--border-color);
}
.al-field-group {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
flex: 1;
}
.al-field-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.al-field-input {
padding: 4px 8px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
transition: border-color var(--duration-fast);
}
.al-field-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.12);
}
/* ── List header (count + live dot) ─────────────────────────────────────── */
.al-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xs) 0;
margin-bottom: var(--space-xs);
}
.al-count {
font-size: 0.8125rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.al-live-indicator {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
font-weight: 500;
color: var(--primary-text-color);
letter-spacing: 0.02em;
}
.al-live-dot {
width: 7px;
height: 7px;
background: var(--primary-color);
border-radius: 50%;
animation: al-pulse 2s infinite;
}
@keyframes al-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
/* ── Entry rows ─────────────────────────────────────────────────────────── */
.al-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.al-entry {
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color var(--duration-fast);
}
.al-entry:hover { border-color: var(--text-muted); }
/* New-entry flash */
@keyframes al-new-flash {
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)); }
100% { background: var(--card-bg); }
}
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
.al-entry-row {
display: grid;
grid-template-columns: 24px 80px auto 1fr 2fr auto 20px;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
cursor: pointer;
min-height: 36px;
outline: none;
}
.al-entry-row:focus-visible { box-shadow: inset 0 0 0 2px var(--primary-color); }
/* Severity rail icon */
.al-sev { display: flex; align-items: center; justify-content: center; }
.al-sev .icon { width: 14px; height: 14px; }
.al-sev-info .icon { color: var(--info-color); }
.al-sev-warning .icon { color: var(--warning-color); }
.al-sev-error .icon { color: var(--danger-color); }
/* Time */
.al-time {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
color: var(--text-muted);
white-space: nowrap;
}
/* Category badge */
.al-cat-badge {
display: inline-block;
padding: 1px 7px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border-radius: var(--radius-pill);
white-space: nowrap;
border: var(--lux-hairline) solid transparent;
}
/* Per-category colors — subtle tinted backgrounds */
.al-cat-auth { background: rgba(33, 150, 243, 0.12); color: var(--info-color); border-color: rgba(33, 150, 243, 0.25); }
.al-cat-device { background: rgba(156, 39, 176, 0.10); color: #ab47bc; border-color: rgba(156, 39, 176, 0.22); }
.al-cat-entity { background: rgba(76, 175, 80, 0.12); color: var(--primary-text-color); border-color: rgba(76, 175, 80, 0.25); }
.al-cat-capture { background: rgba(255, 152, 0, 0.12); color: var(--warning-color); border-color: rgba(255, 152, 0, 0.25); }
.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); }
[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); }
/* Actor */
.al-actor {
font-size: 0.8125rem;
font-family: var(--font-mono);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
/* Message */
.al-msg {
font-size: 0.8125rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Entity crosslink */
.al-entity { display: flex; align-items: center; }
.al-entity-link {
font-size: 0.75rem;
color: var(--primary-text-color);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
display: block;
}
.al-entity-link:hover { color: var(--primary-hover); text-decoration-style: solid; }
.al-entity-name {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Expand chevron */
.al-expand-chevron {
font-size: 0.6875rem;
color: var(--text-muted);
justify-self: end;
user-select: none;
}
/* ── Entry detail drawer ─────────────────────────────────────────────────── */
.al-detail {
padding: var(--space-sm) var(--space-md) var(--space-md);
border-top: var(--lux-hairline) solid var(--border-color);
background: var(--bg-secondary);
animation: al-detail-open var(--duration-fast) var(--ease-out);
}
@keyframes al-detail-open {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.al-detail-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px var(--space-md);
font-size: 0.8125rem;
}
.al-detail-grid dt {
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: start;
padding-top: 2px;
white-space: nowrap;
}
.al-detail-grid dd {
color: var(--text-color);
word-break: break-all;
}
.al-detail-grid code {
font-family: var(--font-mono);
font-size: 0.8125rem;
background: var(--bg-color);
padding: 1px 5px;
border-radius: var(--radius-sm);
border: var(--lux-hairline) solid var(--border-color);
}
.al-meta-pre {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--bg-color);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-sm);
overflow-x: auto;
white-space: pre;
color: var(--text-secondary);
max-height: 220px;
overflow-y: auto;
margin: 0;
}
/* ── Load More ───────────────────────────────────────────────────────────── */
.al-load-more {
display: block;
width: 100%;
margin-top: var(--space-md);
text-align: center;
font-size: 0.875rem;
}
/* ── Empty / loading / error states ─────────────────────────────────────── */
.al-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
}
.al-state-icon .icon {
width: 36px;
height: 36px;
opacity: 0.35;
}
.al-loading { flex-direction: row; padding: var(--space-lg); }
.al-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: al-spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes al-spin { to { transform: rotate(360deg); } }
.al-error .al-state-icon .icon { color: var(--danger-color); opacity: 0.6; }
/* ── List container ──────────────────────────────────────────────────────── */
.al-list-container {
flex: 1;
min-height: 0;
}
/* ── Tabular-nums utility ────────────────────────────────────────────────── */
.tabular-nums { font-variant-numeric: tabular-nums; }
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.al-entry-row {
grid-template-columns: 20px 70px auto 1fr 18px;
}
/* Hide actor and entity link at small widths */
.al-actor,
.al-entity { display: none; }
}
@media (max-width: 600px) {
.al-panel { padding: var(--space-sm); }
.al-entry-row {
grid-template-columns: 20px 1fr auto 18px;
grid-template-rows: auto auto;
gap: 4px var(--space-xs);
}
.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-toolbar-advanced .al-field-group { min-width: 100%; }
}
+1
View File
@@ -19,5 +19,6 @@
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './activity-log.css';
@import './mobile.css';
@import './tv.css';
+35 -1
View File
@@ -228,6 +228,24 @@ import {
mountAutoCalibration, unmountAutoCalibration,
} from './features/auto-calibration.ts';
// Layer 5: activity log
import {
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
} from './features/activity-log.ts';
// Layer 5.5: graph editor
import {
loadGraphEditor,
@@ -762,6 +780,22 @@ Object.assign(window, {
applyStylePreset,
applyBgEffect,
renderAppearanceTab,
// activity log
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
});
// ─── Global keyboard shortcuts ───
@@ -779,7 +813,7 @@ document.addEventListener('keydown', (e) => {
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' };
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph', '7': 'activity_log' };
const tab = tabMap[e.key];
if (tab) {
e.preventDefault();
@@ -135,6 +135,17 @@ export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/>
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Lucide: scroll-text (audit / activity log)
export const scrollText = '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>';
// Lucide: circle-alert (error severity)
export const circleAlert = '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>';
// Lucide: info (info severity)
export const info = '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>';
// Lucide: filter (filter toolbar)
export const filter = '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>';
// Lucide: x-circle (clear/reset)
export const xCircle = '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
@@ -354,6 +354,15 @@ export const ICON_CIRCLE = _svg(P.circle);
export const ICON_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy);
// ── Activity log icons ─────────────────────────────────────
export const ICON_ACTIVITY_LOG = _svg(P.scrollText);
export const ICON_SEVERITY_INFO = _svg(P.info);
export const ICON_SEVERITY_WARN = _svg(P.triangleAlert);
export const ICON_SEVERITY_ERR = _svg(P.circleAlert);
export const ICON_FILTER = _svg(P.filter);
export const ICON_X_CIRCLE = _svg(P.xCircle);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
automations: { loadFnName: 'loadAutomations',
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
graph: { loadFnName: 'loadGraphEditor' },
activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false },
};
/** Get the full config for a tab, or undefined if not registered. */
+45
View File
@@ -555,6 +555,51 @@ export function formatCompact(n: number | null | undefined) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
}
/**
* Format an ISO-8601 timestamp (or ms epoch) into a locale-aware string.
* Returns "Today · HH:MM", "Yesterday · HH:MM", or "DD MMM · HH:MM".
* Use `font-variant-numeric: tabular-nums` on the element for stable layout.
*/
export function formatTimestamp(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yestStart = new Date(todayStart.getTime() - 86400000);
const hhmm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
if (d >= todayStart) {
return `${t('time.today')} · ${hhmm}`;
} else if (d >= yestStart) {
return `${t('time.yesterday')} · ${hhmm}`;
} else {
const dateStr = d.toLocaleDateString([], { day: 'numeric', month: 'short' });
return `${dateStr} · ${hhmm}`;
}
}
/**
* Format an ISO-8601 timestamp (or ms epoch) as a compact relative string.
* Examples: "just now", "2m ago", "3h ago", "5d ago".
* Use font-variant-numeric: tabular-nums on elements that update frequently.
*/
export function formatRelativeTime(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
if (diffSec < 10) return t('time.just_now');
if (diffSec < 60) return t('time.seconds_ago', { n: diffSec });
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t('time.minutes_ago', { n: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return t('time.hours_ago', { n: diffHr });
const diffDays = Math.floor(diffHr / 24);
return t('time.days_ago', { n: diffDays });
}
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-';
const total = Math.floor(seconds);
@@ -0,0 +1,700 @@
/**
* Activity Log tab — persistent, queryable audit log viewer.
*
* This is a READ-ONLY viewer (no CRUD), differentiated from the debug
* Log Viewer (utils/log_broadcaster.py) which is an ephemeral 500-line tail.
* This tab shows structured, semantic audit entries backed by the SQLite
* activity_log table.
*
* Phase 5: Activity tab + smart filtering + live updates.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, formatRelativeTime } from '../core/ui.ts';
import { navigateToCard } from '../core/navigation.ts';
import {
ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR,
ICON_X_CIRCLE, ICON_DOWNLOAD, ICON_SEARCH,
ICON_CHEVRON_UP, ICON_CHEVRON_DOWN,
} from '../core/icons.ts';
/**
* Escape a string for safe use inside an HTML attribute value (quoted with
* either `"` or `'`). Extends escapeHtml's `<>&` coverage with `"` and `'`.
*/
function _escapeAttr(text: string): string {
if (!text) return '';
return escapeHtml(text)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── Types ───────────────────────────────────────────────────
interface ActivityEntry {
id: string;
ts: string;
category: string;
action: string;
severity: string;
actor: string;
entity_type: string | null;
entity_id: string | null;
entity_name: string | null;
message: string;
metadata: Record<string, unknown>;
}
interface ActivityPage {
entries: ActivityEntry[];
next_before_seq: number | null;
has_more: boolean;
total: number;
}
interface ActiveFilters {
categories: string[];
severities: string[];
actor: string;
entity_type: string;
since: string;
until: string;
q: string;
}
// ─── Module state ────────────────────────────────────────────
let _initialized = false;
let _loading = false;
let _entries: ActivityEntry[] = [];
let _nextBeforeSeq: number | null = null;
let _hasMore = false;
let _total = 0;
let _expandedIds = new Set<string>();
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
let _liveEventListener: ((e: Event) => void) | null = null;
const _filters: ActiveFilters = {
categories: [],
severities: [],
actor: '',
entity_type: '',
since: '',
until: '',
q: '',
};
// ─── Category → navigation target map (entity crosslinks) ──
const _ENTITY_NAV: Record<string, { tab: string; subTab: string | null; attr: string } | null> = {
output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' },
led_device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' },
picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' },
color_strip: { tab: 'streams', subTab: 'color_strip', attr: 'data-strip-id' },
audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-audio-source-id' },
automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' },
scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' },
};
// ─── Severity icon helper ────────────────────────────────────
function _severityIcon(severity: string): string {
if (severity === 'error') return ICON_SEVERITY_ERR;
if (severity === 'warning') return ICON_SEVERITY_WARN;
return ICON_SEVERITY_INFO;
}
function _severityClass(severity: string): string {
if (severity === 'error') return 'al-sev-error';
if (severity === 'warning') return 'al-sev-warning';
return 'al-sev-info';
}
// ─── Category label helper ───────────────────────────────────
function _categoryLabel(category: string): string {
return t(`activity_log.category.${category}`);
}
// ─── Build query string from active filters + cursor ────────
function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since);
if (_filters.until) params.set('until', _filters.until);
if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50');
const qs = params.toString();
return qs ? `?${qs}` : '';
}
// ─── Entry rendering ─────────────────────────────────────────
function _renderEntryRow(entry: ActivityEntry, isNew = false): string {
const relTime = formatRelativeTime(entry.ts);
const iso = entry.ts;
const expanded = _expandedIds.has(entry.id);
const sevClass = _severityClass(entry.severity);
const sevIcon = _severityIcon(entry.severity);
// Entity crosslink — use data-* attributes + delegated listener (no JSON in onclick)
let entityHtml = '';
if (entry.entity_type && entry.entity_name) {
const nav = _ENTITY_NAV[entry.entity_type];
if (nav) {
const escapedName = escapeHtml(entry.entity_name);
const attrEntityType = _escapeAttr(entry.entity_type);
const attrEntityId = _escapeAttr(entry.entity_id || '');
entityHtml = `<button class="al-entity-link" type="button"
data-entity-type="${attrEntityType}" data-entity-id="${attrEntityId}"
title="${_escapeAttr(entry.entity_name)}">${escapedName}</button>`;
} else {
entityHtml = `<span class="al-entity-name">${escapeHtml(entry.entity_name)}</span>`;
}
}
const detailHtml = expanded ? _renderEntryDetail(entry) : '';
const attrEntryId = _escapeAttr(entry.id);
return `<div class="al-entry${isNew ? ' al-entry-new' : ''}" data-al-id="${_escapeAttr(entry.id)}">
<div class="al-entry-row" data-toggle-id="${attrEntryId}"
role="button" tabindex="0" aria-expanded="${expanded}">
<span class="al-sev ${sevClass}" title="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-time tabular-nums" title="${_escapeAttr(iso)}">${escapeHtml(relTime)}</span>
<span class="al-cat-badge al-cat-${escapeHtml(entry.category)}">${escapeHtml(_categoryLabel(entry.category))}</span>
<span class="al-actor">${escapeHtml(entry.actor)}</span>
<span class="al-msg">${escapeHtml(entry.message)}</span>
${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''}
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
</div>
${detailHtml}
</div>`;
}
function _renderEntryDetail(entry: ActivityEntry): string {
const metaJson = JSON.stringify(entry.metadata, null, 2);
const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : '';
return `<div class="al-detail" role="region" aria-label="${escapeHtml(t('activity_log.detail.title'))}">
<dl class="al-detail-grid">
<dt>${escapeHtml(t('activity_log.detail.id'))}</dt>
<dd class="tabular-nums"><code>${escapeHtml(entry.id)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.timestamp'))}</dt>
<dd class="tabular-nums">${escapeHtml(absTime)}</dd>
<dt>${escapeHtml(t('activity_log.detail.action'))}</dt>
<dd><code>${escapeHtml(entry.action)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.actor'))}</dt>
<dd>${escapeHtml(entry.actor)}</dd>
${entry.entity_type ? `<dt>${escapeHtml(t('activity_log.detail.entity'))}</dt>
<dd>${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / <code>${escapeHtml(entry.entity_id)}</code>` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}</dd>` : ''}
<dt>${escapeHtml(t('activity_log.detail.metadata'))}</dt>
<dd><pre class="al-meta-pre">${escapeHtml(metaJson)}</pre></dd>
</dl>
</div>`;
}
// ─── Filter toolbar rendering ────────────────────────────────
const CATEGORIES = ['auth', 'device', 'entity', 'capture', 'system'];
const SEVERITIES = ['info', 'warning', 'error'];
function _renderFilterToolbar(): string {
const catChips = CATEGORIES.map(cat => {
const active = _filters.categories.includes(cat);
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
type="button" onclick="activityLogToggleCat(${JSON.stringify(cat)})"
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
}).join('');
const sevChips = SEVERITIES.map(sev => {
const active = _filters.severities.includes(sev);
const icon = _severityIcon(sev);
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
type="button" onclick="activityLogToggleSev(${JSON.stringify(sev)})"
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
}).join('');
const presets = [
{ key: 'today', label: t('activity_log.preset.today') },
{ key: 'errors', label: t('activity_log.preset.errors') },
{ key: 'auth', label: t('activity_log.preset.auth') },
{ key: 'devices', label: t('activity_log.preset.devices') },
];
const presetBtns = presets.map(p =>
`<button class="al-preset-btn" type="button" onclick="activityLogPreset(${JSON.stringify(p.key)})">${escapeHtml(p.label)}</button>`
).join('');
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
return `<div class="al-toolbar" role="search" aria-label="${escapeHtml(t('activity_log.filter.title'))}">
<div class="al-toolbar-row al-toolbar-search">
<div class="al-search-wrap">
<span class="al-search-icon" aria-hidden="true">${ICON_SEARCH}</span>
<input class="al-search-input" type="search" id="al-search-input"
placeholder="${escapeHtml(t('activity_log.filter.search'))}"
value="${_escapeAttr(_filters.q)}"
oninput="activityLogOnSearch(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.search'))}">
</div>
<div class="al-presets">${presetBtns}</div>
${hasFilters ? `<button class="al-clear-btn btn btn-icon btn-secondary" type="button"
onclick="activityLogClearFilters()" title="${escapeHtml(t('activity_log.filter.clear'))}"
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
<div class="al-export-wrap">
<button class="btn btn-secondary al-export-btn" type="button"
onclick="activityLogExport('csv')"
title="${escapeHtml(t('activity_log.export.csv'))}"
aria-label="${escapeHtml(t('activity_log.export.csv'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span></button>
<div class="al-export-menu">
<button type="button" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
</div>
</div>
</div>
<div class="al-toolbar-row al-toolbar-chips">
<span class="al-filter-label">${escapeHtml(t('activity_log.filter.category'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.category'))}">${catChips}</div>
<span class="al-filter-label al-filter-label-sep">${escapeHtml(t('activity_log.filter.severity'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.severity'))}">${sevChips}</div>
</div>
<div class="al-toolbar-row al-toolbar-advanced" id="al-toolbar-advanced">
<div class="al-field-group">
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
<input type="text" id="al-actor-input" class="al-field-input"
value="${_escapeAttr(_filters.actor)}"
placeholder="system, api-key-name…"
oninput="activityLogOnActor(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
</div>
<div class="al-field-group">
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
<input type="text" id="al-entity-type-input" class="al-field-input"
value="${_escapeAttr(_filters.entity_type)}"
placeholder="output_target, led_device…"
oninput="activityLogOnEntityType(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
</div>
<div class="al-field-group">
<label for="al-since-input" class="al-field-label">${escapeHtml(t('activity_log.filter.since'))}</label>
<input type="datetime-local" id="al-since-input" class="al-field-input"
value="${_escapeAttr(_filters.since)}"
onchange="activityLogOnSince(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.since'))}">
</div>
<div class="al-field-group">
<label for="al-until-input" class="al-field-label">${escapeHtml(t('activity_log.filter.until'))}</label>
<input type="datetime-local" id="al-until-input" class="al-field-input"
value="${_escapeAttr(_filters.until)}"
onchange="activityLogOnUntil(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.until'))}">
</div>
</div>
</div>`;
}
// ─── List and state rendering ────────────────────────────────
function _renderList(): string {
if (_loading && _entries.length === 0) {
return `<div class="al-state al-loading" role="status" aria-live="polite">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>`;
}
if (_entries.length === 0) {
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
const emptyKey = hasFilters ? 'activity_log.empty' : 'activity_log.empty_no_filters';
return `<div class="al-state al-empty" role="status">
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<p>${escapeHtml(t(emptyKey))}</p>
</div>`;
}
const rows = _entries.map(e => _renderEntryRow(e)).join('');
const loadMore = _hasMore
? `<button class="al-load-more btn btn-secondary" type="button"
onclick="activityLogLoadMore()" aria-label="${escapeHtml(t('activity_log.load_more'))}">${escapeHtml(t('activity_log.load_more'))}</button>`
: '';
const countLabel = t('activity_log.n_entries', { n: _total });
return `<div class="al-list-header">
<span class="al-count tabular-nums">${escapeHtml(countLabel)}</span>
<div class="al-live-indicator" id="al-live-indicator" aria-live="polite">
<span class="al-live-dot" aria-hidden="true"></span>
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
${rows}
</div>
${loadMore}`;
}
// ─── Delegated click handler for entry rows and entity links ─
let _delegatedClickAttached = false;
function _attachDelegatedClicks(): void {
if (_delegatedClickAttached) return;
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_delegatedClickAttached = true;
panel.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Entity navigation: click on data-entity-type button
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
if (entityBtn) {
e.stopPropagation();
const entityType = entityBtn.dataset.entityType ?? '';
const entityId = entityBtn.dataset.entityId ?? '';
activityLogNavigateToEntity(entityType, entityId);
return;
}
// Entry row toggle: click on al-entry-row with data-toggle-id
const row = target.closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
return;
}
});
panel.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
e.preventDefault();
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
}
});
}
// ─── Full panel render ───────────────────────────────────────
function _render(): void {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
panel.innerHTML = `<div class="al-panel">
<div class="al-header">
<div class="al-header-title">
<span class="al-header-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<h2>${escapeHtml(t('activity_log.title'))}</h2>
<p class="al-header-subtitle">${escapeHtml(t('activity_log.subtitle'))}</p>
</div>
</div>
${_renderFilterToolbar()}
<div id="al-list-container" class="al-list-container">
${_renderList()}
</div>
</div>`;
_attachDelegatedClicks();
}
// ─── Partial re-render helpers ───────────────────────────────
function _updateListContainer(): void {
const container = document.getElementById('al-list-container');
if (!container) return;
container.innerHTML = _renderList();
}
// ─── Data fetching ───────────────────────────────────────────
async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> {
if (_loading) return;
_loading = true;
if (!append) {
_entries = [];
_nextBeforeSeq = null;
_hasMore = false;
}
_updateListContainer();
try {
const qs = _buildQuery(beforeSeq);
const res = await fetchWithAuth(`/activity-log${qs}`);
if (!res || !res.ok) {
throw new Error(`HTTP ${res?.status}`);
}
const page: ActivityPage = await res.json();
// API returns each page oldest-first within the page; reverse to newest-first
// so the in-memory list is newest at index 0 (top of the rendered log).
const pageEntries = [...page.entries].reverse();
if (append) {
_entries = [..._entries, ...pageEntries];
} else {
_entries = pageEntries;
}
_nextBeforeSeq = page.next_before_seq;
_hasMore = page.has_more;
_total = page.total;
_updateListContainer();
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
const container = document.getElementById('al-list-container');
if (container) {
container.innerHTML = `<div class="al-state al-error" role="alert">
<span class="al-state-icon" aria-hidden="true">${ICON_SEVERITY_ERR}</span>
<p>${escapeHtml(t('activity_log.error'))}</p>
</div>`;
}
} finally {
_loading = false;
}
}
// ─── Filter re-query with debounce for text fields ───────────
function _requery(debounce = false): void {
if (debounce) {
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(() => { _fetchPage(null, false); }, 350);
} else {
_fetchPage(null, false);
}
}
// ─── Live event handling ──────────────────────────────────────
function _entryPassesFilters(entry: ActivityEntry): boolean {
if (_filters.categories.length && !_filters.categories.includes(entry.category)) return false;
if (_filters.severities.length && !_filters.severities.includes(entry.severity)) return false;
if (_filters.actor && entry.actor !== _filters.actor) return false;
if (_filters.entity_type && entry.entity_type !== _filters.entity_type) return false;
if (_filters.q) {
const q = _filters.q.toLowerCase();
if (!entry.message.toLowerCase().includes(q) &&
!entry.action.toLowerCase().includes(q) &&
!entry.actor.toLowerCase().includes(q)) return false;
}
// Date range filters: if an entry is brand-new it passes "since" checks trivially
if (_filters.since) {
const sinceMs = new Date(_filters.since).getTime();
if (!isNaN(sinceMs) && new Date(entry.ts).getTime() < sinceMs) return false;
}
if (_filters.until) {
const untilMs = new Date(_filters.until).getTime();
if (!isNaN(untilMs) && new Date(entry.ts).getTime() > untilMs) return false;
}
return true;
}
function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return;
_entries = [entry, ..._entries];
_total = _total + 1;
// Prepend the row into the existing list (no full re-render for performance)
const list = document.getElementById('tab-activity_log')?.querySelector('.al-list');
if (list) {
const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html);
// Animate the new entry
const firstRow = list.firstElementChild as HTMLElement | null;
if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); });
}
// Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
} else {
_updateListContainer();
}
}
function _startLiveUpdates(): void {
if (_liveEventListener) return;
_liveEventListener = (e: Event) => {
const ce = e as CustomEvent;
const entry = ce.detail?.entry as ActivityEntry | undefined;
if (!entry) return;
_prependLiveEntry(entry);
};
document.addEventListener('server:activity_logged', _liveEventListener);
}
// ─── Public window-exposed interaction functions ──────────────
export function activityLogToggleDetail(entryId: string): void {
if (_expandedIds.has(entryId)) {
_expandedIds.delete(entryId);
} else {
_expandedIds.add(entryId);
}
// Update just the affected row
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
const row = panel.querySelector(`[data-al-id="${CSS.escape(entryId)}"]`);
if (!row) return;
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
row.outerHTML = _renderEntryRow(entry, false);
}
export function activityLogToggleCat(cat: string): void {
const idx = _filters.categories.indexOf(cat);
if (idx >= 0) {
_filters.categories = _filters.categories.filter(c => c !== cat);
} else {
_filters.categories = [..._filters.categories, cat];
}
_render();
_requery();
}
export function activityLogToggleSev(sev: string): void {
const idx = _filters.severities.indexOf(sev);
if (idx >= 0) {
_filters.severities = _filters.severities.filter(s => s !== sev);
} else {
_filters.severities = [..._filters.severities, sev];
}
_render();
_requery();
}
export function activityLogOnSearch(val: string): void {
_filters.q = val;
_requery(true);
}
export function activityLogOnActor(val: string): void {
_filters.actor = val.trim();
_requery(true);
}
export function activityLogOnEntityType(val: string): void {
_filters.entity_type = val.trim();
_requery(true);
}
export function activityLogOnSince(val: string): void {
_filters.since = val;
_requery();
}
export function activityLogOnUntil(val: string): void {
_filters.until = val;
_requery();
}
export function activityLogClearFilters(): void {
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.since = '';
_filters.until = '';
_filters.q = '';
_render();
_requery();
}
export function activityLogPreset(key: string): void {
// Reset all filters first
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.q = '';
_filters.until = '';
switch (key) {
case 'today': {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM
_filters.since = todayStart.toISOString().slice(0, 16);
break;
}
case 'errors':
_filters.severities = ['error'];
_filters.since = '';
break;
case 'auth':
_filters.categories = ['auth'];
_filters.since = '';
break;
case 'devices':
_filters.categories = ['device'];
_filters.since = '';
break;
}
_render();
_requery();
}
export function activityLogLoadMore(): void {
if (_hasMore && !_loading) {
_fetchPage(_nextBeforeSeq, true);
}
}
export async function activityLogExport(format: 'csv' | 'json'): Promise<void> {
try {
showToast(t('activity_log.export.downloading'), 'info');
const qs = _buildQuery(null);
const sep = qs ? '&' : '?';
const url = `/activity-log/export${qs}${sep}format=${format}`;
const res = await fetchWithAuth(url);
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `ledgrab-activity-${now}.${format}`;
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
showToast(t('activity_log.export.error'), 'error');
}
}
export function activityLogNavigateToEntity(entityType: string, entityId: string): void {
const nav = _ENTITY_NAV[entityType];
if (!nav || !entityId) return;
navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId);
}
// ─── Main loader (registered with tab-registry) ─────────────
export async function loadActivityLog(): Promise<void> {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_initialized = true;
_render();
await _fetchPage(null, false);
_startLiveUpdates();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
}
function _onLanguageChanged(): void {
if (!_initialized) return;
_render();
_fetchPage(null, false);
}
@@ -66,6 +66,7 @@ const gettingStartedSteps: TutorialStep[] = [
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' },
{ selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: '#tab-btn-activity_log', textKey: 'tour.activity_log', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },
+16
View File
@@ -457,6 +457,22 @@ startTargetOverlay: (...args: any[]) => any;
applyBgEffect: (id: string) => void;
renderAppearanceTab: () => void;
// ─── Activity Log ───
loadActivityLog: () => Promise<void>;
activityLogToggleDetail: (entryId: string) => void;
activityLogToggleCat: (cat: string) => void;
activityLogToggleSev: (sev: string) => void;
activityLogOnSearch: (val: string) => void;
activityLogOnActor: (val: string) => void;
activityLogOnEntityType: (val: string) => void;
activityLogOnSince: (val: string) => void;
activityLogOnUntil: (val: string) => void;
activityLogClearFilters: () => void;
activityLogPreset: (key: string) => void;
activityLogLoadMore: () => void;
activityLogExport: (format: 'csv' | 'json') => Promise<void>;
activityLogNavigateToEntity: (entityType: string, entityId: string) => void;
// ─── Overlay spinner internals ───
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;
+58 -1
View File
@@ -3314,5 +3314,62 @@
"wizard.error.device_name_required": "Device name is required.",
"wizard.error.device_url_required": "Device URL is required.",
"wizard.error.scaffold_failed": "Setup failed. Please try again.",
"wizard.error.start_failed": "Failed to start LED output."
"wizard.error.start_failed": "Failed to start LED output.",
"time.today": "Today",
"time.yesterday": "Yesterday",
"time.just_now": "just now",
"time.seconds_ago": "{n}s ago",
"time.minutes_ago": "{n}m ago",
"time.hours_ago": "{n}h ago",
"time.days_ago": "{n}d ago",
"activity_log.title": "Activity",
"activity_log.subtitle": "Audit log of LedGrab actions",
"activity_log.loading": "Loading activity log…",
"activity_log.empty": "No activity entries match the current filters.",
"activity_log.empty_no_filters": "No activity has been recorded yet.",
"activity_log.error": "Failed to load activity log.",
"activity_log.load_more": "Load more",
"activity_log.n_entries": "{n} entries",
"activity_log.live": "Live",
"activity_log.new_entries": "{n} new",
"activity_log.filter.title": "Filters",
"activity_log.filter.search": "Search messages…",
"activity_log.filter.category": "Category",
"activity_log.filter.severity": "Severity",
"activity_log.filter.actor": "Actor",
"activity_log.filter.entity_type": "Entity type",
"activity_log.filter.since": "From",
"activity_log.filter.until": "To",
"activity_log.filter.clear": "Clear filters",
"activity_log.preset.today": "Today",
"activity_log.preset.errors": "Errors",
"activity_log.preset.auth": "Auth",
"activity_log.preset.devices": "Devices",
"activity_log.category.auth": "Auth",
"activity_log.category.device": "Device",
"activity_log.category.entity": "Entity",
"activity_log.category.capture": "Capture",
"activity_log.category.system": "System",
"activity_log.severity.info": "Info",
"activity_log.severity.warning": "Warning",
"activity_log.severity.error": "Error",
"activity_log.col.time": "Time",
"activity_log.col.category": "Category",
"activity_log.col.severity": "Severity",
"activity_log.col.actor": "Actor",
"activity_log.col.message": "Message",
"activity_log.detail.title": "Entry detail",
"activity_log.detail.id": "ID",
"activity_log.detail.timestamp": "Timestamp",
"activity_log.detail.action": "Action",
"activity_log.detail.actor": "Actor",
"activity_log.detail.entity": "Entity",
"activity_log.detail.metadata": "Metadata",
"activity_log.export": "Export",
"activity_log.export.csv": "Export CSV",
"activity_log.export.json": "Export JSON",
"activity_log.export.downloading": "Downloading…",
"activity_log.export.error": "Export failed.",
"activity_log.disabled": "Activity logging is disabled.",
"tour.activity_log": "The Activity tab is a persistent audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions. Filter, search, and export as CSV or JSON."
}
+58 -1
View File
@@ -2996,5 +2996,62 @@
"wizard.error.device_name_required": "Введите имя устройства.",
"wizard.error.device_url_required": "Введите адрес устройства.",
"wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.",
"wizard.error.start_failed": "Не удалось запустить LED-вывод."
"wizard.error.start_failed": "Не удалось запустить LED-вывод.",
"time.today": "Сегодня",
"time.yesterday": "Вчера",
"time.just_now": "только что",
"time.seconds_ago": "{n} сек. назад",
"time.minutes_ago": "{n} мин. назад",
"time.hours_ago": "{n} ч. назад",
"time.days_ago": "{n} дн. назад",
"activity_log.title": "Активность",
"activity_log.subtitle": "Журнал действий LedGrab",
"activity_log.loading": "Загрузка журнала…",
"activity_log.empty": "Нет записей, соответствующих текущим фильтрам.",
"activity_log.empty_no_filters": "Действия ещё не зафиксированы.",
"activity_log.error": "Не удалось загрузить журнал.",
"activity_log.load_more": "Загрузить ещё",
"activity_log.n_entries": "Записей: {n}",
"activity_log.live": "Live",
"activity_log.new_entries": "+{n} новых",
"activity_log.filter.title": "Фильтры",
"activity_log.filter.search": "Поиск сообщений…",
"activity_log.filter.category": "Категория",
"activity_log.filter.severity": "Уровень",
"activity_log.filter.actor": "Субъект",
"activity_log.filter.entity_type": "Тип сущности",
"activity_log.filter.since": "С",
"activity_log.filter.until": "По",
"activity_log.filter.clear": "Сбросить фильтры",
"activity_log.preset.today": "Сегодня",
"activity_log.preset.errors": "Ошибки",
"activity_log.preset.auth": "Авторизация",
"activity_log.preset.devices": "Устройства",
"activity_log.category.auth": "Авторизация",
"activity_log.category.device": "Устройство",
"activity_log.category.entity": "Сущность",
"activity_log.category.capture": "Захват",
"activity_log.category.system": "Система",
"activity_log.severity.info": "Информация",
"activity_log.severity.warning": "Предупреждение",
"activity_log.severity.error": "Ошибка",
"activity_log.col.time": "Время",
"activity_log.col.category": "Категория",
"activity_log.col.severity": "Уровень",
"activity_log.col.actor": "Субъект",
"activity_log.col.message": "Сообщение",
"activity_log.detail.title": "Детали записи",
"activity_log.detail.id": "ID",
"activity_log.detail.timestamp": "Метка времени",
"activity_log.detail.action": "Действие",
"activity_log.detail.actor": "Субъект",
"activity_log.detail.entity": "Сущность",
"activity_log.detail.metadata": "Метаданные",
"activity_log.export": "Экспорт",
"activity_log.export.csv": "Экспорт CSV",
"activity_log.export.json": "Экспорт JSON",
"activity_log.export.downloading": "Загрузка…",
"activity_log.export.error": "Ошибка экспорта.",
"activity_log.disabled": "Запись активности отключена.",
"tour.activity_log": "Вкладка «Активность» — постоянный журнал всего, что делает LedGrab: изменения сущностей, авторизация, подключения устройств и системные действия. Используйте фильтры, поиск и экспорт в CSV или JSON."
}
+58 -1
View File
@@ -2990,5 +2990,62 @@
"wizard.error.device_name_required": "设备名称不能为空。",
"wizard.error.device_url_required": "设备地址不能为空。",
"wizard.error.scaffold_failed": "配置失败,请重试。",
"wizard.error.start_failed": "启动 LED 输出失败。"
"wizard.error.start_failed": "启动 LED 输出失败。",
"time.today": "今天",
"time.yesterday": "昨天",
"time.just_now": "刚刚",
"time.seconds_ago": "{n}秒前",
"time.minutes_ago": "{n}分钟前",
"time.hours_ago": "{n}小时前",
"time.days_ago": "{n}天前",
"activity_log.title": "活动",
"activity_log.subtitle": "LedGrab 操作审计日志",
"activity_log.loading": "正在加载活动日志…",
"activity_log.empty": "没有符合当前过滤条件的记录。",
"activity_log.empty_no_filters": "尚无活动记录。",
"activity_log.error": "加载活动日志失败。",
"activity_log.load_more": "加载更多",
"activity_log.n_entries": "{n} 条记录",
"activity_log.live": "实时",
"activity_log.new_entries": "+{n} 条新记录",
"activity_log.filter.title": "过滤",
"activity_log.filter.search": "搜索消息…",
"activity_log.filter.category": "类别",
"activity_log.filter.severity": "严重性",
"activity_log.filter.actor": "操作者",
"activity_log.filter.entity_type": "实体类型",
"activity_log.filter.since": "从",
"activity_log.filter.until": "至",
"activity_log.filter.clear": "清除过滤",
"activity_log.preset.today": "今天",
"activity_log.preset.errors": "错误",
"activity_log.preset.auth": "身份验证",
"activity_log.preset.devices": "设备",
"activity_log.category.auth": "身份验证",
"activity_log.category.device": "设备",
"activity_log.category.entity": "实体",
"activity_log.category.capture": "采集",
"activity_log.category.system": "系统",
"activity_log.severity.info": "信息",
"activity_log.severity.warning": "警告",
"activity_log.severity.error": "错误",
"activity_log.col.time": "时间",
"activity_log.col.category": "类别",
"activity_log.col.severity": "严重性",
"activity_log.col.actor": "操作者",
"activity_log.col.message": "消息",
"activity_log.detail.title": "记录详情",
"activity_log.detail.id": "ID",
"activity_log.detail.timestamp": "时间戳",
"activity_log.detail.action": "操作",
"activity_log.detail.actor": "操作者",
"activity_log.detail.entity": "实体",
"activity_log.detail.metadata": "元数据",
"activity_log.export": "导出",
"activity_log.export.csv": "导出 CSV",
"activity_log.export.json": "导出 JSON",
"activity_log.export.downloading": "下载中…",
"activity_log.export.error": "导出失败。",
"activity_log.disabled": "活动日志记录已禁用。",
"tour.activity_log": "活动标签页是 LedGrab 所有操作的持久审计日志——实体更改、身份验证事件、设备连接和系统操作。可过滤、搜索,并导出为 CSV 或 JSON 格式。"
}
+4
View File
@@ -143,6 +143,7 @@
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
<button class="tab-btn" data-tab="activity_log" onclick="switchTab('activity_log')" role="tab" aria-selected="false" aria-controls="tab-activity_log" id="tab-btn-activity_log" title="Ctrl+7"><svg class="icon" viewBox="0 0 24 24"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg> <span data-i18n="activity_log.title">Activity</span></button>
</div>
</div>
</aside>
@@ -201,6 +202,9 @@
</div>
</div>
<div class="tab-panel" id="tab-activity_log" role="tabpanel" aria-labelledby="tab-btn-activity_log">
<div class="loading-spinner"></div>
</div>
<script>
// Apply saved tab immediately during parse to prevent visible jump