diff --git a/plans/activity-log/PLAN.md b/plans/activity-log/PLAN.md index 841ee87..e32bc99 100644 --- a/plans/activity-log/PLAN.md +++ b/plans/activity-log/PLAN.md @@ -108,6 +108,11 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem). | 6 | Widget outside dashboard layout-toggle/ordering system | 🔵 Note | accepted — deliberate (always-visible), collapse still works | | Final | Entity-crosslink map keys mismatched backend entity_type (device/color_strip_source/audio_source) | 🟡 Warning | resolved — `_ENTITY_NAV` keys corrected + scene_playlist added | | Final | Owner-authored names interpolated raw at some record sites | 🔵 Note (defense-in-depth) | resolved — `sanitize_display` applied uniformly | +| Manual test | Entry descriptions rendered server English (not localized) | 🟡 Warning (acceptance criterion) | resolved — client-side `localizeMessage` from structured fields + `activity_log.msg.*`/`entity_type.*` templates ×3 | +| Manual test | Redundant "Activity" header banner at top of tab | 🔵 Note | resolved — header block removed | +| Manual test | Recent Activity widget missing from Customize Dashboard | 🟡 Warning | resolved — registered as a first-class dashboard section (show/hide/reorder; pre-existing layouts preserved) | +| Manual test | Activity widget live event rebuilt the whole dashboard | 🟡 Warning (perf) | resolved — surgical list update; single listener with teardown | +| Manual test | Relative-time labels static (never tick) | 🟡 Warning | resolved — shared `ensureRelativeTimeTicker` (single 30s interval, visibility-aware) | ## Final Review diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css index 6046e5b..7ed7dc6 100644 --- a/server/src/ledgrab/static/css/activity-log.css +++ b/server/src/ledgrab/static/css/activity-log.css @@ -17,46 +17,6 @@ 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 { diff --git a/server/src/ledgrab/static/js/core/ui.ts b/server/src/ledgrab/static/js/core/ui.ts index a1c3a50..77fed90 100644 --- a/server/src/ledgrab/static/js/core/ui.ts +++ b/server/src/ledgrab/static/js/core/ui.ts @@ -600,6 +600,45 @@ export function formatRelativeTime(isoOrMs: string | number): string { return t('time.days_ago', { n: diffDays }); } +// ── Shared relative-time ticker ────────────────────────────────────────────── +// A single process-wide interval that keeps every `[data-reltime]` element +// up to date. Call `ensureRelativeTimeTicker()` from any feature that renders +// such elements — repeated calls are idempotent (one interval, ever). + +let _relTimeIntervalId: ReturnType | null = null; +let _relTimeVisibilityBound = false; + +/** Refresh every `[data-reltime]` element's text content to the current + * relative-time label produced by `formatRelativeTime`. */ +function _tickRelativeTimes(): void { + if (document.hidden) return; + document.querySelectorAll('[data-reltime]').forEach(el => { + const iso = el.getAttribute('data-reltime'); + if (iso) el.textContent = formatRelativeTime(iso); + }); +} + +/** + * Start the shared relative-time ticker (idempotent — safe to call many times). + * Ticks every 30 s, skips work when the tab is hidden, and fires one + * immediate refresh when the tab becomes visible again. + * Also fires one immediate refresh on each `languageChanged` event so + * freshly-translated labels appear without waiting for the next tick. + */ +export function ensureRelativeTimeTicker(): void { + // One-time visibility + language listeners + if (!_relTimeVisibilityBound) { + _relTimeVisibilityBound = true; + document.addEventListener('visibilitychange', () => { + if (!document.hidden) _tickRelativeTimes(); + }); + document.addEventListener('languageChanged', () => _tickRelativeTimes()); + } + // Idempotent: only start the interval once + if (_relTimeIntervalId !== null) return; + _relTimeIntervalId = setInterval(_tickRelativeTimes, 30_000); +} + export function formatUptime(seconds: number | null | undefined): string { if (!seconds || seconds <= 0) return '-'; const total = Math.floor(seconds); diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts index 6f2bb76..6a8d9ae 100644 --- a/server/src/ledgrab/static/js/features/activity-log.ts +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -11,7 +11,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; -import { showToast, formatRelativeTime } from '../core/ui.ts'; +import { showToast, formatRelativeTime, ensureRelativeTimeTicker } from '../core/ui.ts'; import { navigateToCard } from '../core/navigation.ts'; import { ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR, @@ -118,6 +118,57 @@ function _categoryLabel(category: string): string { return t(`activity_log.category.${category}`); } +// ─── Localized entity-type label ──────────────────────────── + +function _entityTypeLabel(entityType: string): string { + const key = `activity_log.entity_type.${entityType}`; + const translated = t(key); + // If t() returned the key unchanged there is no translation — humanize it + if (translated === key) { + return entityType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + return translated; +} + +// ─── Client-side message localization ─────────────────────── +// +// Maps entry.action → an i18n template key and extracts placeholders +// from the structured fields so the displayed description is rendered +// in the user's locale rather than the server-generated English string. +// +// Fallback: if the template key is missing (t() returns the key +// unchanged) we return entry.message (the original server string) so +// the UI always shows something sensible. + +export function localizeMessage(entry: ActivityEntry): string { + const meta = entry.metadata || {}; + + // Build a params bag from structured fields + metadata. + // Keys match the {placeholder} names used in locale templates. + const params: Record = { + name: entry.entity_name ?? '', + actor: entry.actor ?? '', + type: entry.entity_type ? _entityTypeLabel(entry.entity_type) : '', + key: String(meta.setting_key ?? ''), + address: String(meta.address ?? meta.url ?? ''), + reason: String(meta.reason ?? ''), + client: String(meta.client ?? ''), + device_type: String(meta.device_type ?? ''), + filename: String(meta.filename ?? ''), + }; + + // The backend always emits dotted actions (e.g. "entity.created", + // "auth.ws_connected"), so the template key is a direct 1:1 mapping. + const templateKey = `activity_log.msg.${entry.action}`; + + const localized = t(templateKey, params); + // t() returns the key unchanged when there is no matching translation. + if (localized === templateKey) { + return entry.message; + } + return localized; +} + // ─── Build query string from active filters + cursor ──────── function _buildQuery(beforeSeq: number | null = null): string { @@ -167,10 +218,10 @@ function _renderEntryRow(entry: ActivityEntry, isNew = false): string {
${sevIcon} - ${escapeHtml(relTime)} + ${escapeHtml(relTime)} ${escapeHtml(_categoryLabel(entry.category))} ${escapeHtml(entry.actor)} - ${escapeHtml(entry.message)} + ${escapeHtml(localizeMessage(entry))} ${entityHtml ? `${entityHtml}` : ''}
@@ -390,13 +441,6 @@ function _render(): void { if (!panel) return; panel.innerHTML = `
-
-
- -

${escapeHtml(t('activity_log.title'))}

-

${escapeHtml(t('activity_log.subtitle'))}

-
-
${_renderFilterToolbar()}
${_renderList()} @@ -708,8 +752,8 @@ export function renderCompactEntry(entry: ActivityEntry): string { const sevClass = _severityClass(entry.severity); return `
${sevIcon} - ${escapeHtml(relTime)} - ${escapeHtml(entry.message)} + ${escapeHtml(relTime)} + ${escapeHtml(localizeMessage(entry))}
`; } @@ -723,6 +767,7 @@ export async function loadActivityLog(): Promise { _render(); await _fetchPage(null, false); _startLiveUpdates(); + ensureRelativeTimeTicker(); // Re-render on language change (baked-in t() calls) document.addEventListener('languageChanged', _onLanguageChanged); diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts index 316fe68..3b87be5 100644 --- a/server/src/ledgrab/static/js/features/dashboard-customize.ts +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [ 'playlists', 'sync-clocks', 'targets', + 'recent-activity', ] as const; const SECTION_LABEL_KEYS: Record = { @@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record = { playlists: 'dashboard.section.playlists', 'sync-clocks': 'dashboard.section.sync_clocks', targets: 'dashboard.section.targets', + 'recent-activity': 'dashboard.section.recent_activity', }; const PERF_CELL_LABEL_KEYS: Record = { diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index a139b9a..135912d 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -25,6 +25,7 @@ export type SectionKey = | 'playlists' | 'sync-clocks' | 'targets' + | 'recent-activity' // Reserved registry keys for v1.1+ (so saved layouts forward-compat). | 'audio-meters' | 'alerts' @@ -155,6 +156,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = { _defaultSection('playlists'), _defaultSection('sync-clocks'), _defaultSection('targets'), + _defaultSection('recent-activity'), ], perfCells: [ _defaultPerfCell('patches'), diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 638460d..4820051 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -5,7 +5,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; -import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; +import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, ensureRelativeTimeTicker } from '../core/ui.ts'; import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; @@ -588,6 +588,9 @@ async function _loadRecentActivityWidget(): Promise { const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT); _renderRecentActivityList(list, entries); + // Start relative-time ticker (idempotent — shared with the Activity tab) + ensureRelativeTimeTicker(); + // Start live listener (idempotent) _startRecentActivityLive(); } @@ -605,20 +608,29 @@ function _renderRecentActivityList(list: HTMLElement, entries: ActivityEntry[]): list.innerHTML = entries.map(e => renderCompactEntry(e)).join(''); } +function _stopRecentActivityLive(): void { + if (_recentActivityLiveListener) { + document.removeEventListener('server:activity_logged', _recentActivityLiveListener); + _recentActivityLiveListener = null; + } +} + function _startRecentActivityLive(): void { - if (_recentActivityLiveListener) return; + // Always tear down first so we never stack listeners across loadDashboard calls. + _stopRecentActivityLive(); _recentActivityLiveListener = (e: Event) => { const ce = e as CustomEvent; const entry = ce.detail?.entry; if (!entry) return; const list = document.getElementById('dashboard-recent-activity-list'); + // No-op when the widget isn't mounted (section hidden or not yet rendered). if (!list) return; if (!list.classList.contains('dal-list')) { // Transition from empty-state to list on the first live event _renderRecentActivityList(list, [entry]); return; } - // Prepend the new row and cap at N + // Surgically prepend the new row and cap at N — no loadDashboard(). list.insertAdjacentHTML('afterbegin', renderCompactEntry(entry)); const rows = list.querySelectorAll('.al-compact-row'); if (rows.length > RECENT_ACTIVITY_LIMIT) { @@ -820,7 +832,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${t('dashboard.no_targets')}
`; + const _raSection = isSectionVisible('recent-activity') ? `
+ ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} + ${_sectionContent('recent-activity', `
+
+
+ ${escapeHtml(t('activity_log.loading'))} +
+
+ `)} +
` : ''; + dynamicHtml = `
${t('dashboard.no_targets')}
${_raSection}`; } else { const enriched = targets.map(target => ({ ...target, @@ -1060,6 +1086,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise`; } + // Recent Activity section — registered like any other section so it + // participates in layout ordering, show/hide, and Customize panel. + sectionFragments['recent-activity'] = `
+ ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} + ${_sectionContent('recent-activity', `
+
+
+ ${escapeHtml(t('activity_log.loading'))} +
+
+ `)} +
`; + // Now assemble in layout-driven order, skipping invisible // sections and the perf section (which is always rendered // separately at the top for chart-persistence reasons). @@ -1071,23 +1114,6 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise - ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} - ${_sectionContent('recent-activity', `
-
-
- ${escapeHtml(t('activity_log.loading'))} -
-
- `)} -
`; - // First load: build everything in one innerHTML to avoid flicker. // Poll-interval control was moved to the transport bar (it's global, // not dashboard-specific) — toolbar now keeps the tutorial help diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 7554915..c334c2f 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -52,6 +52,60 @@ "activity_log.severity.warning": "Warning", "activity_log.subtitle": "Audit log of LedGrab actions", "activity_log.title": "Activity", + "activity_log.msg.auth.rejected": "Authentication failed: {reason} (client: {client})", + "activity_log.msg.auth.ws_connected": "WebSocket session established by '{actor}'", + "activity_log.msg.activity_log.cleared": "Activity log cleared by '{actor}'", + "activity_log.msg.calibration.started": "Calibration started", + "activity_log.msg.calibration.stopped": "Calibration stopped", + "activity_log.msg.calibration.cancelled": "Calibration cancelled", + "activity_log.msg.playlist.started": "Playlist '{name}' started", + "activity_log.msg.playlist.stopped": "Playlist '{name}' stopped", + "activity_log.msg.scene.activated": "Scene '{name}' activated", + "activity_log.msg.device.adb_connected": "ADB connected to {address}", + "activity_log.msg.device.adb_disconnected": "ADB disconnected from {address}", + "activity_log.msg.device.discovered": "Device discovered at {address}", + "activity_log.msg.device.lost": "Device lost at {address}", + "activity_log.msg.device.online": "Device '{name}' came online", + "activity_log.msg.device.offline": "Device '{name}' went offline", + "activity_log.msg.update.applied": "Update applied", + "activity_log.msg.update.dismissed": "Update dismissed", + "activity_log.msg.settings.changed": "Setting '{key}' changed", + "activity_log.msg.audit_log.disabled": "Activity logging disabled", + "activity_log.msg.automation.activated": "Automation '{name}' activated", + "activity_log.msg.automation.deactivated": "Automation '{name}' deactivated", + "activity_log.msg.server.shutting_down": "Server shutting down", + "activity_log.msg.server.restarting": "Server restart requested", + "activity_log.msg.server.shutdown_requested": "Server shutdown requested", + "activity_log.msg.backup.created": "Backup created", + "activity_log.msg.backup.restored": "Backup restored", + "activity_log.msg.backup.deleted": "Backup deleted", + "activity_log.msg.capture.started": "Capture started for '{name}'", + "activity_log.msg.capture.stopped": "Capture stopped for '{name}'", + "activity_log.msg.entity.created": "{type} '{name}' created", + "activity_log.msg.entity.updated": "{type} '{name}' updated", + "activity_log.msg.entity.deleted": "{type} '{name}' deleted", + "activity_log.entity_type.output_target": "Output Target", + "activity_log.entity_type.device": "Device", + "activity_log.entity_type.picture_source": "Picture Source", + "activity_log.entity_type.color_strip_source": "Color Strip Source", + "activity_log.entity_type.audio_source": "Audio Source", + "activity_log.entity_type.automation": "Automation", + "activity_log.entity_type.scene_preset": "Scene Preset", + "activity_log.entity_type.scene_playlist": "Scene Playlist", + "activity_log.entity_type.sync_clock": "Sync Clock", + "activity_log.entity_type.template": "Template", + "activity_log.entity_type.gradient": "Gradient", + "activity_log.entity_type.cspt": "Processing Template", + "activity_log.entity_type.audio_template": "Audio Template", + "activity_log.entity_type.audio_processing_template": "Audio Processing Template", + "activity_log.entity_type.audio_processing_profile": "Audio Processing Profile", + "activity_log.entity_type.http_endpoint": "HTTP Endpoint", + "activity_log.entity_type.mqtt_source": "MQTT Source", + "activity_log.entity_type.home_assistant_source": "Home Assistant Source", + "activity_log.entity_type.weather_source": "Weather Source", + "activity_log.entity_type.game_integration": "Game Integration", + "activity_log.entity_type.value_source": "Value Source", + "activity_log.entity_type.asset": "Asset", "api.error.network": "Network error — check your connection", "api.error.timeout": "Request timed out — please try again", "api_key.login": "Login", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 7cada2f..f26a4e0 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -52,6 +52,60 @@ "activity_log.severity.warning": "Предупреждение", "activity_log.subtitle": "Журнал действий LedGrab", "activity_log.title": "Активность", + "activity_log.msg.auth.rejected": "Ошибка аутентификации: {reason} (клиент: {client})", + "activity_log.msg.auth.ws_connected": "WebSocket-сессия установлена: '{actor}'", + "activity_log.msg.activity_log.cleared": "Журнал активности очищен пользователем '{actor}'", + "activity_log.msg.calibration.started": "Калибровка запущена", + "activity_log.msg.calibration.stopped": "Калибровка остановлена", + "activity_log.msg.calibration.cancelled": "Калибровка отменена", + "activity_log.msg.playlist.started": "Плейлист '{name}' запущен", + "activity_log.msg.playlist.stopped": "Плейлист '{name}' остановлен", + "activity_log.msg.scene.activated": "Сцена '{name}' активирована", + "activity_log.msg.device.adb_connected": "ADB подключён к {address}", + "activity_log.msg.device.adb_disconnected": "ADB отключён от {address}", + "activity_log.msg.device.discovered": "Устройство обнаружено по адресу {address}", + "activity_log.msg.device.lost": "Устройство потеряно по адресу {address}", + "activity_log.msg.device.online": "Устройство '{name}' в сети", + "activity_log.msg.device.offline": "Устройство '{name}' недоступно", + "activity_log.msg.update.applied": "Обновление применено", + "activity_log.msg.update.dismissed": "Обновление отклонено", + "activity_log.msg.settings.changed": "Параметр '{key}' изменён", + "activity_log.msg.audit_log.disabled": "Запись активности отключена", + "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", + "activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована", + "activity_log.msg.server.shutting_down": "Сервер выключается", + "activity_log.msg.server.restarting": "Запрошен перезапуск сервера", + "activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера", + "activity_log.msg.backup.created": "Резервная копия создана", + "activity_log.msg.backup.restored": "Резервная копия восстановлена", + "activity_log.msg.backup.deleted": "Резервная копия удалена", + "activity_log.msg.capture.started": "Захват запущен для '{name}'", + "activity_log.msg.capture.stopped": "Захват остановлен для '{name}'", + "activity_log.msg.entity.created": "{type} '{name}' создан", + "activity_log.msg.entity.updated": "{type} '{name}' обновлён", + "activity_log.msg.entity.deleted": "{type} '{name}' удалён", + "activity_log.entity_type.output_target": "Цель вывода", + "activity_log.entity_type.device": "Устройство", + "activity_log.entity_type.picture_source": "Источник изображения", + "activity_log.entity_type.color_strip_source": "Источник цветовой полосы", + "activity_log.entity_type.audio_source": "Аудиоисточник", + "activity_log.entity_type.automation": "Автоматизация", + "activity_log.entity_type.scene_preset": "Пресет сцены", + "activity_log.entity_type.scene_playlist": "Плейлист сцен", + "activity_log.entity_type.sync_clock": "Синх-часы", + "activity_log.entity_type.template": "Шаблон", + "activity_log.entity_type.gradient": "Градиент", + "activity_log.entity_type.cspt": "Шаблон обработки", + "activity_log.entity_type.audio_template": "Аудиошаблон", + "activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки", + "activity_log.entity_type.audio_processing_profile": "Профиль аудиообработки", + "activity_log.entity_type.http_endpoint": "HTTP-эндпоинт", + "activity_log.entity_type.mqtt_source": "MQTT-источник", + "activity_log.entity_type.home_assistant_source": "Источник Home Assistant", + "activity_log.entity_type.weather_source": "Источник погоды", + "activity_log.entity_type.game_integration": "Игровая интеграция", + "activity_log.entity_type.value_source": "Источник значений", + "activity_log.entity_type.asset": "Ресурс", "api.error.network": "Ошибка сети — проверьте подключение", "api.error.timeout": "Превышено время ожидания — попробуйте снова", "api_key.login": "Войти", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index ac9ca7e..df39718 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -52,6 +52,60 @@ "activity_log.severity.warning": "警告", "activity_log.subtitle": "LedGrab 操作审计日志", "activity_log.title": "活动", + "activity_log.msg.auth.rejected": "身份验证失败:{reason}(客户端:{client})", + "activity_log.msg.auth.ws_connected": "WebSocket 会话已建立:'{actor}'", + "activity_log.msg.activity_log.cleared": "活动日志已由 '{actor}' 清除", + "activity_log.msg.calibration.started": "校准已启动", + "activity_log.msg.calibration.stopped": "校准已停止", + "activity_log.msg.calibration.cancelled": "校准已取消", + "activity_log.msg.playlist.started": "播放列表 '{name}' 已启动", + "activity_log.msg.playlist.stopped": "播放列表 '{name}' 已停止", + "activity_log.msg.scene.activated": "场景 '{name}' 已激活", + "activity_log.msg.device.adb_connected": "ADB 已连接到 {address}", + "activity_log.msg.device.adb_disconnected": "ADB 已断开与 {address} 的连接", + "activity_log.msg.device.discovered": "在 {address} 发现设备", + "activity_log.msg.device.lost": "在 {address} 的设备已丢失", + "activity_log.msg.device.online": "设备 '{name}' 已上线", + "activity_log.msg.device.offline": "设备 '{name}' 已离线", + "activity_log.msg.update.applied": "更新已应用", + "activity_log.msg.update.dismissed": "更新已忽略", + "activity_log.msg.settings.changed": "设置 '{key}' 已更改", + "activity_log.msg.audit_log.disabled": "活动记录已禁用", + "activity_log.msg.automation.activated": "自动化 '{name}' 已激活", + "activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用", + "activity_log.msg.server.shutting_down": "服务器正在关闭", + "activity_log.msg.server.restarting": "已请求服务器重启", + "activity_log.msg.server.shutdown_requested": "已请求服务器关闭", + "activity_log.msg.backup.created": "备份已创建", + "activity_log.msg.backup.restored": "备份已还原", + "activity_log.msg.backup.deleted": "备份已删除", + "activity_log.msg.capture.started": "'{name}' 的采集已启动", + "activity_log.msg.capture.stopped": "'{name}' 的采集已停止", + "activity_log.msg.entity.created": "{type} '{name}' 已创建", + "activity_log.msg.entity.updated": "{type} '{name}' 已更新", + "activity_log.msg.entity.deleted": "{type} '{name}' 已删除", + "activity_log.entity_type.output_target": "输出目标", + "activity_log.entity_type.device": "设备", + "activity_log.entity_type.picture_source": "图像源", + "activity_log.entity_type.color_strip_source": "色带源", + "activity_log.entity_type.audio_source": "音频源", + "activity_log.entity_type.automation": "自动化", + "activity_log.entity_type.scene_preset": "场景预设", + "activity_log.entity_type.scene_playlist": "场景播放列表", + "activity_log.entity_type.sync_clock": "同步时钟", + "activity_log.entity_type.template": "模板", + "activity_log.entity_type.gradient": "渐变", + "activity_log.entity_type.cspt": "处理模板", + "activity_log.entity_type.audio_template": "音频模板", + "activity_log.entity_type.audio_processing_template": "音频处理模板", + "activity_log.entity_type.audio_processing_profile": "音频处理配置", + "activity_log.entity_type.http_endpoint": "HTTP 端点", + "activity_log.entity_type.mqtt_source": "MQTT 源", + "activity_log.entity_type.home_assistant_source": "Home Assistant 源", + "activity_log.entity_type.weather_source": "天气源", + "activity_log.entity_type.game_integration": "游戏集成", + "activity_log.entity_type.value_source": "数值源", + "activity_log.entity_type.asset": "资源", "api.error.network": "网络错误 — 请检查连接", "api.error.timeout": "请求超时 — 请重试", "api_key.login": "登录",