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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=0–3650, max_entries=0–10_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%; }
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── 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
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 格式。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user