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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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 {
|
||||
if (!seconds || seconds <= 0) return '-';
|
||||
const total = Math.floor(seconds);
|
||||
|
||||
@@ -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<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 ────────
|
||||
|
||||
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}"
|
||||
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-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-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>` : ''}
|
||||
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
|
||||
</div>
|
||||
@@ -390,13 +441,6 @@ function _render(): void {
|
||||
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()}
|
||||
@@ -708,8 +752,8 @@ export function renderCompactEntry(entry: ActivityEntry): string {
|
||||
const sevClass = _severityClass(entry.severity);
|
||||
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-time tabular-nums">${escapeHtml(relTime)}</span>
|
||||
<span class="al-compact-msg">${escapeHtml(entry.message)}</span>
|
||||
<span class="al-compact-time tabular-nums" data-reltime="${_escapeAttr(entry.ts)}">${escapeHtml(relTime)}</span>
|
||||
<span class="al-compact-msg">${escapeHtml(localizeMessage(entry))}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -723,6 +767,7 @@ export async function loadActivityLog(): Promise<void> {
|
||||
_render();
|
||||
await _fetchPage(null, false);
|
||||
_startLiveUpdates();
|
||||
ensureRelativeTimeTicker();
|
||||
|
||||
// Re-render on language change (baked-in t() calls)
|
||||
document.addEventListener('languageChanged', _onLanguageChanged);
|
||||
|
||||
@@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
|
||||
'playlists',
|
||||
'sync-clocks',
|
||||
'targets',
|
||||
'recent-activity',
|
||||
] as const;
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<void> {
|
||||
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<v
|
||||
let dynamicHtml = '';
|
||||
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) {
|
||||
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'))} →
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>` : '';
|
||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>${_raSection}`;
|
||||
} else {
|
||||
const enriched = targets.map(target => ({
|
||||
...target,
|
||||
@@ -1060,6 +1086,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Recent Activity section — registered like any other section so it
|
||||
// participates in layout ordering, show/hide, and Customize panel.
|
||||
sectionFragments['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'))} →
|
||||
</button>
|
||||
</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).
|
||||
@@ -1071,23 +1114,6 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// 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'), '')}
|
||||
${_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'))} →
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Войти",
|
||||
|
||||
@@ -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": "登录",
|
||||
|
||||
Reference in New Issue
Block a user