fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time

- localize entry descriptions client-side via localizeMessage (activity_log.msg.* + entity_type.* templates x3 locales); server message kept as fallback/export/search
- remove redundant Activity header banner from tab
- Recent Activity widget is now a first-class dashboard section (Customize Dashboard show/hide/reorder; pre-existing layouts preserved)
- live activity event updates the widget surgically (no full dashboard rebuild); single listener with teardown
- relative-time labels tick via shared ensureRelativeTimeTicker (single 30s interval, visibility-aware)
This commit is contained in:
2026-06-10 12:03:18 +03:00
parent 3dd1ac3f0d
commit ff1ff06cb5
10 changed files with 314 additions and 73 deletions
+5
View File
@@ -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 | | 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 | 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 | | 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 ## Final Review
@@ -17,46 +17,6 @@
min-height: 0; 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 ──────────────────────────────────────────────────────── */ /* ── Filter toolbar ──────────────────────────────────────────────────────── */
.al-toolbar { .al-toolbar {
+39
View File
@@ -600,6 +600,45 @@ export function formatRelativeTime(isoOrMs: string | number): string {
return t('time.days_ago', { n: diffDays }); 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<typeof setInterval> | 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<HTMLElement>('[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 { export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-'; if (!seconds || seconds <= 0) return '-';
const total = Math.floor(seconds); const total = Math.floor(seconds);
@@ -11,7 +11,7 @@
import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.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 { navigateToCard } from '../core/navigation.ts';
import { import {
ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR, 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}`); 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<string, string> = {
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 ──────── // ─── Build query string from active filters + cursor ────────
function _buildQuery(beforeSeq: number | null = null): string { function _buildQuery(beforeSeq: number | null = null): string {
@@ -167,10 +218,10 @@ function _renderEntryRow(entry: ActivityEntry, isNew = false): string {
<div class="al-entry-row" data-toggle-id="${attrEntryId}" <div class="al-entry-row" data-toggle-id="${attrEntryId}"
role="button" tabindex="0" aria-expanded="${expanded}"> 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-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-time tabular-nums" title="${_escapeAttr(iso)}" data-reltime="${_escapeAttr(iso)}">${escapeHtml(relTime)}</span>
<span class="al-cat-badge al-cat-${escapeHtml(entry.category)}">${escapeHtml(_categoryLabel(entry.category))}</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-actor">${escapeHtml(entry.actor)}</span>
<span class="al-msg">${escapeHtml(entry.message)}</span> <span class="al-msg">${escapeHtml(localizeMessage(entry))}</span>
${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''} ${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''}
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span> <span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
</div> </div>
@@ -390,13 +441,6 @@ function _render(): void {
if (!panel) return; if (!panel) return;
panel.innerHTML = `<div class="al-panel"> 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()} ${_renderFilterToolbar()}
<div id="al-list-container" class="al-list-container"> <div id="al-list-container" class="al-list-container">
${_renderList()} ${_renderList()}
@@ -708,8 +752,8 @@ export function renderCompactEntry(entry: ActivityEntry): string {
const sevClass = _severityClass(entry.severity); const sevClass = _severityClass(entry.severity);
return `<div class="al-compact-row al-sev ${sevClass}" title="${_escapeAttr(entry.ts)}"> return `<div class="al-compact-row al-sev ${sevClass}" title="${_escapeAttr(entry.ts)}">
<span class="al-compact-icon" aria-label="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span> <span class="al-compact-icon" aria-label="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-compact-time tabular-nums">${escapeHtml(relTime)}</span> <span class="al-compact-time tabular-nums" data-reltime="${_escapeAttr(entry.ts)}">${escapeHtml(relTime)}</span>
<span class="al-compact-msg">${escapeHtml(entry.message)}</span> <span class="al-compact-msg">${escapeHtml(localizeMessage(entry))}</span>
</div>`; </div>`;
} }
@@ -723,6 +767,7 @@ export async function loadActivityLog(): Promise<void> {
_render(); _render();
await _fetchPage(null, false); await _fetchPage(null, false);
_startLiveUpdates(); _startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls) // Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged); document.addEventListener('languageChanged', _onLanguageChanged);
@@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
'playlists', 'playlists',
'sync-clocks', 'sync-clocks',
'targets', 'targets',
'recent-activity',
] as const; ] as const;
const SECTION_LABEL_KEYS: Record<string, string> = { const SECTION_LABEL_KEYS: Record<string, string> = {
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
playlists: 'dashboard.section.playlists', playlists: 'dashboard.section.playlists',
'sync-clocks': 'dashboard.section.sync_clocks', 'sync-clocks': 'dashboard.section.sync_clocks',
targets: 'dashboard.section.targets', targets: 'dashboard.section.targets',
'recent-activity': 'dashboard.section.recent_activity',
}; };
const PERF_CELL_LABEL_KEYS: Record<string, string> = { const PERF_CELL_LABEL_KEYS: Record<string, string> = {
@@ -25,6 +25,7 @@ export type SectionKey =
| 'playlists' | 'playlists'
| 'sync-clocks' | 'sync-clocks'
| 'targets' | 'targets'
| 'recent-activity'
// Reserved registry keys for v1.1+ (so saved layouts forward-compat). // Reserved registry keys for v1.1+ (so saved layouts forward-compat).
| 'audio-meters' | 'audio-meters'
| 'alerts' | 'alerts'
@@ -155,6 +156,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultSection('playlists'), _defaultSection('playlists'),
_defaultSection('sync-clocks'), _defaultSection('sync-clocks'),
_defaultSection('targets'), _defaultSection('targets'),
_defaultSection('recent-activity'),
], ],
perfCells: [ perfCells: [
_defaultPerfCell('patches'), _defaultPerfCell('patches'),
@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts'; 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 { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.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 { 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 { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts'; import { isActiveTab } from '../core/tab-registry.ts';
@@ -588,6 +588,9 @@ async function _loadRecentActivityWidget(): Promise<void> {
const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT); const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT);
_renderRecentActivityList(list, entries); _renderRecentActivityList(list, entries);
// Start relative-time ticker (idempotent — shared with the Activity tab)
ensureRelativeTimeTicker();
// Start live listener (idempotent) // Start live listener (idempotent)
_startRecentActivityLive(); _startRecentActivityLive();
} }
@@ -605,20 +608,29 @@ function _renderRecentActivityList(list: HTMLElement, entries: ActivityEntry[]):
list.innerHTML = entries.map(e => renderCompactEntry(e)).join(''); list.innerHTML = entries.map(e => renderCompactEntry(e)).join('');
} }
function _stopRecentActivityLive(): void {
if (_recentActivityLiveListener) {
document.removeEventListener('server:activity_logged', _recentActivityLiveListener);
_recentActivityLiveListener = null;
}
}
function _startRecentActivityLive(): void { function _startRecentActivityLive(): void {
if (_recentActivityLiveListener) return; // Always tear down first so we never stack listeners across loadDashboard calls.
_stopRecentActivityLive();
_recentActivityLiveListener = (e: Event) => { _recentActivityLiveListener = (e: Event) => {
const ce = e as CustomEvent; const ce = e as CustomEvent;
const entry = ce.detail?.entry; const entry = ce.detail?.entry;
if (!entry) return; if (!entry) return;
const list = document.getElementById('dashboard-recent-activity-list'); 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) return;
if (!list.classList.contains('dal-list')) { if (!list.classList.contains('dal-list')) {
// Transition from empty-state to list on the first live event // Transition from empty-state to list on the first live event
_renderRecentActivityList(list, [entry]); _renderRecentActivityList(list, [entry]);
return; 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)); list.insertAdjacentHTML('afterbegin', renderCompactEntry(entry));
const rows = list.querySelectorAll('.al-compact-row'); const rows = list.querySelectorAll('.al-compact-row');
if (rows.length > RECENT_ACTIVITY_LIMIT) { if (rows.length > RECENT_ACTIVITY_LIMIT) {
@@ -820,7 +832,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds: any[] = []; let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) { if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; const _raSection = isSectionVisible('recent-activity') ? `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>
</div>
<div class="dal-footer">
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
${escapeHtml(t('dashboard.recent_activity.view_all'))} &rarr;
</button>
</div>`)}
</div>` : '';
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>${_raSection}`;
} else { } else {
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
...target, ...target,
@@ -1060,20 +1086,9 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`; </div>`;
} }
// Now assemble in layout-driven order, skipping invisible // Recent Activity section — registered like any other section so it
// sections and the perf section (which is always rendered // participates in layout ordering, show/hide, and Customize panel.
// separately at the top for chart-persistence reasons). sectionFragments['recent-activity'] = `<div class="dashboard-section" data-section="recent-activity">
for (const section of getOrderedSections()) {
if (section.key === 'perf') continue;
if (!section.visible) continue;
const html = sectionFragments[section.key];
if (html) dynamicHtml += html;
}
}
// Recent Activity widget — always appended at the end of dynamic sections.
// Uses a placeholder that _loadRecentActivityWidget() fills in async after render.
dynamicHtml += `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}"> ${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading"> <div class="al-state al-loading">
@@ -1088,6 +1103,17 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`)} </div>`)}
</div>`; </div>`;
// 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).
for (const section of getOrderedSections()) {
if (section.key === 'perf') continue;
if (!section.visible) continue;
const html = sectionFragments[section.key];
if (html) dynamicHtml += html;
}
}
// First load: build everything in one innerHTML to avoid flicker. // First load: build everything in one innerHTML to avoid flicker.
// Poll-interval control was moved to the transport bar (it's global, // Poll-interval control was moved to the transport bar (it's global,
// not dashboard-specific) — toolbar now keeps the tutorial help // not dashboard-specific) — toolbar now keeps the tutorial help
+54
View File
@@ -52,6 +52,60 @@
"activity_log.severity.warning": "Warning", "activity_log.severity.warning": "Warning",
"activity_log.subtitle": "Audit log of LedGrab actions", "activity_log.subtitle": "Audit log of LedGrab actions",
"activity_log.title": "Activity", "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.network": "Network error — check your connection",
"api.error.timeout": "Request timed out — please try again", "api.error.timeout": "Request timed out — please try again",
"api_key.login": "Login", "api_key.login": "Login",
+54
View File
@@ -52,6 +52,60 @@
"activity_log.severity.warning": "Предупреждение", "activity_log.severity.warning": "Предупреждение",
"activity_log.subtitle": "Журнал действий LedGrab", "activity_log.subtitle": "Журнал действий LedGrab",
"activity_log.title": "Активность", "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.network": "Ошибка сети — проверьте подключение",
"api.error.timeout": "Превышено время ожидания — попробуйте снова", "api.error.timeout": "Превышено время ожидания — попробуйте снова",
"api_key.login": "Войти", "api_key.login": "Войти",
+54
View File
@@ -52,6 +52,60 @@
"activity_log.severity.warning": "警告", "activity_log.severity.warning": "警告",
"activity_log.subtitle": "LedGrab 操作审计日志", "activity_log.subtitle": "LedGrab 操作审计日志",
"activity_log.title": "活动", "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.network": "网络错误 — 请检查连接",
"api.error.timeout": "请求超时 — 请重试", "api.error.timeout": "请求超时 — 请重试",
"api_key.login": "登录", "api_key.login": "登录",