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:
@@ -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