Add adaptive FPS and honest device reachability during streaming
DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed by sustained traffic, sends appear successful while the device is actually unreachable. This adds: - HTTP liveness probe (GET /json/info, 2s timeout) every 10s during streaming, exposed as device_streaming_reachable in target state - Adaptive FPS (opt-in): exponential backoff when device is unreachable, gradual recovery when it stabilizes — finds sustainable send rate - Honest health checks: removed the lie that forced device_online=true during streaming; now runs actual health checks regardless - Target editor toggle, FPS display shows effective rate when throttled, health dot reflects streaming reachability, red highlight when unreachable - Auto-backup scheduling support in settings modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -549,6 +549,10 @@ ul.section-tip li {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.fps-unreachable {
|
||||
color: #ff5252;
|
||||
}
|
||||
|
||||
/* Timing breakdown bar */
|
||||
.timing-breakdown {
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -137,6 +137,7 @@ import { navigateToCard } from './core/navigation.js';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
} from './features/settings.js';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
@@ -388,11 +389,15 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
|
||||
// settings (backup / restore)
|
||||
// settings (backup / restore / auto-backup)
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
downloadBackup,
|
||||
handleRestoreFileSelected,
|
||||
saveAutoBackupSettings,
|
||||
restoreSavedBackup,
|
||||
downloadSavedBackup,
|
||||
deleteSavedBackup,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
|
||||
@@ -207,20 +207,32 @@ function _updateRunningMetrics(enrichedRunning) {
|
||||
// Update text values (use cached refs, fallback to querySelector)
|
||||
const cached = _metricsElements.get(target.id);
|
||||
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
|
||||
if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`
|
||||
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
||||
if (fpsEl) {
|
||||
const effFps = state.fps_effective;
|
||||
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
|
||||
? `${fpsCurrent}<span class="dashboard-fps-target">/${effFps}↓${fpsTarget}</span>`
|
||||
: `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
|
||||
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
||||
fpsEl.innerHTML = `<span class="${unreachableClass}">${fpsTargetLabel}</span>`
|
||||
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
||||
}
|
||||
|
||||
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
|
||||
if (errorsEl) errorsEl.textContent = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`;
|
||||
|
||||
// Update health dot
|
||||
// Update health dot — prefer streaming reachability when processing
|
||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||
if (isLed) {
|
||||
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
|
||||
if (row) {
|
||||
const dot = row.querySelector('.health-dot');
|
||||
if (dot && state.device_last_checked != null) {
|
||||
dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
|
||||
if (dot) {
|
||||
const streamReachable = state.device_streaming_reachable;
|
||||
if (state.processing && streamReachable != null) {
|
||||
dot.className = `health-dot ${streamReachable ? 'health-online' : 'health-offline'}`;
|
||||
} else if (state.device_last_checked != null) {
|
||||
dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const settingsModal = new Modal('settings-modal');
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
settingsModal.open();
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
@@ -135,3 +137,164 @@ function pollHealth() {
|
||||
// Wait a moment before first check to let the server shut down
|
||||
setTimeout(check, 2000);
|
||||
}
|
||||
|
||||
// ─── Auto-Backup settings ─────────────────────────────────
|
||||
|
||||
export async function loadAutoBackupSettings() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/auto-backup/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('auto-backup-enabled').checked = data.enabled;
|
||||
document.getElementById('auto-backup-interval').value = String(data.interval_hours);
|
||||
document.getElementById('auto-backup-max').value = data.max_backups;
|
||||
|
||||
const statusEl = document.getElementById('auto-backup-status');
|
||||
if (data.last_backup_time) {
|
||||
const d = new Date(data.last_backup_time);
|
||||
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + d.toLocaleString();
|
||||
} else {
|
||||
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load auto-backup settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAutoBackupSettings() {
|
||||
const enabled = document.getElementById('auto-backup-enabled').checked;
|
||||
const interval_hours = parseFloat(document.getElementById('auto-backup-interval').value);
|
||||
const max_backups = parseInt(document.getElementById('auto-backup-max').value, 10);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/auto-backup/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, interval_hours, max_backups }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('settings.auto_backup.saved'), 'success');
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
} catch (err) {
|
||||
console.error('Failed to save auto-backup settings:', err);
|
||||
showToast(t('settings.auto_backup.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Saved backup list ────────────────────────────────────
|
||||
|
||||
export async function loadBackupList() {
|
||||
const container = document.getElementById('saved-backups-list');
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/backups');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.count === 0) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${t('settings.saved_backups.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.backups.map(b => {
|
||||
const sizeKB = (b.size_bytes / 1024).toFixed(1);
|
||||
const date = new Date(b.created_at).toLocaleString();
|
||||
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
|
||||
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
|
||||
<span>${date}</span>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
|
||||
</div>
|
||||
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">↺</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">⬇</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load backup list:', err);
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSavedBackup(filename) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (err) {
|
||||
console.error('Backup download failed:', err);
|
||||
showToast(t('settings.backup.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSavedBackup(filename) {
|
||||
const confirmed = await showConfirm(t('settings.restore.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// Download the backup file from the server
|
||||
const dlResp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { timeout: 30000 });
|
||||
if (!dlResp.ok) {
|
||||
const err = await dlResp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${dlResp.status}`);
|
||||
}
|
||||
const blob = await dlResp.blob();
|
||||
|
||||
// POST it to the restore endpoint
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
const resp = await fetch(`${API_BASE}/system/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
showToast(data.message || t('settings.restore.success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Restore from saved backup failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSavedBackup(filename) {
|
||||
const confirmed = await showConfirm(t('settings.saved_backups.delete_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
loadBackupList();
|
||||
} catch (err) {
|
||||
console.error('Backup delete failed:', err);
|
||||
showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ class TargetEditorModal extends Modal {
|
||||
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -272,6 +273,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-brightness-threshold').value = thresh;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = thresh;
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false;
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
@@ -290,6 +293,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh;
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false;
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||
} else {
|
||||
@@ -305,6 +310,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-brightness-threshold').value = 0;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = false;
|
||||
|
||||
_populateCssDropdown('');
|
||||
_populateBrightnessVsDropdown('');
|
||||
}
|
||||
@@ -364,6 +371,8 @@ export async function saveTargetEditor() {
|
||||
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
||||
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
|
||||
|
||||
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
@@ -372,6 +381,7 @@ export async function saveTargetEditor() {
|
||||
min_brightness_threshold: minBrightnessThreshold,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
adaptive_fps: adaptiveFps,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -765,8 +775,29 @@ function _patchTargetMetrics(target) {
|
||||
const metrics = target.metrics || {};
|
||||
|
||||
const fps = card.querySelector('[data-tm="fps"]');
|
||||
if (fps) fps.innerHTML = `${state.fps_current ?? 0}<span class="target-fps-target">/${state.fps_target || 0}</span>`
|
||||
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
||||
if (fps) {
|
||||
const effFps = state.fps_effective;
|
||||
const tgtFps = state.fps_target || 0;
|
||||
const fpsLabel = (effFps != null && effFps < tgtFps)
|
||||
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}↓${tgtFps}</span>`
|
||||
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
|
||||
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
||||
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
|
||||
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
||||
}
|
||||
|
||||
// Update health dot to reflect streaming reachability when processing
|
||||
const healthDot = card.querySelector('.health-dot');
|
||||
if (healthDot && state.processing) {
|
||||
const reachable = state.device_streaming_reachable;
|
||||
if (reachable === false) {
|
||||
healthDot.className = 'health-dot health-offline';
|
||||
healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
|
||||
} else if (reachable === true) {
|
||||
healthDot.className = 'health-dot health-online';
|
||||
healthDot.title = t('device.health.online');
|
||||
}
|
||||
}
|
||||
|
||||
const timing = card.querySelector('[data-tm="timing"]');
|
||||
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"device.metrics.device_fps": "Device refresh rate",
|
||||
"device.health.online": "Online",
|
||||
"device.health.offline": "Offline",
|
||||
"device.health.streaming_unreachable": "Unreachable during streaming",
|
||||
"device.health.checking": "Checking...",
|
||||
"device.tutorial.start": "Start tutorial",
|
||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||
@@ -912,6 +913,8 @@
|
||||
"targets.brightness_vs.none": "None (device brightness)",
|
||||
"targets.min_brightness_threshold": "Min Brightness Threshold:",
|
||||
"targets.min_brightness_threshold.hint": "Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)",
|
||||
"targets.adaptive_fps": "Adaptive FPS:",
|
||||
"targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.",
|
||||
|
||||
"search.open": "Search (Ctrl+K)",
|
||||
"search.placeholder": "Search entities... (Ctrl+K)",
|
||||
@@ -943,5 +946,25 @@
|
||||
"settings.restore.error": "Restore failed",
|
||||
"settings.restore.restarting": "Server is restarting...",
|
||||
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
|
||||
"settings.button.close": "Close"
|
||||
"settings.button.close": "Close",
|
||||
|
||||
"settings.auto_backup.label": "Auto-Backup",
|
||||
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
|
||||
"settings.auto_backup.enable": "Enable auto-backup",
|
||||
"settings.auto_backup.interval_label": "Interval",
|
||||
"settings.auto_backup.max_label": "Max backups",
|
||||
"settings.auto_backup.save": "Save Settings",
|
||||
"settings.auto_backup.saved": "Auto-backup settings saved",
|
||||
"settings.auto_backup.save_error": "Failed to save auto-backup settings",
|
||||
"settings.auto_backup.last_backup": "Last backup",
|
||||
"settings.auto_backup.never": "Never",
|
||||
|
||||
"settings.saved_backups.label": "Saved Backups",
|
||||
"settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.",
|
||||
"settings.saved_backups.empty": "No saved backups",
|
||||
"settings.saved_backups.restore": "Restore",
|
||||
"settings.saved_backups.download": "Download",
|
||||
"settings.saved_backups.delete": "Delete",
|
||||
"settings.saved_backups.delete_confirm": "Delete this backup file?",
|
||||
"settings.saved_backups.delete_error": "Failed to delete backup"
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"device.metrics.device_fps": "Частота обновления устройства",
|
||||
"device.health.online": "Онлайн",
|
||||
"device.health.offline": "Недоступен",
|
||||
"device.health.streaming_unreachable": "Недоступен во время стриминга",
|
||||
"device.health.checking": "Проверка...",
|
||||
"device.tutorial.start": "Начать обучение",
|
||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||
@@ -912,6 +913,8 @@
|
||||
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
||||
"targets.min_brightness_threshold": "Мин. порог яркости:",
|
||||
"targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)",
|
||||
"targets.adaptive_fps": "Адаптивный FPS:",
|
||||
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
|
||||
|
||||
"search.open": "Поиск (Ctrl+K)",
|
||||
"search.placeholder": "Поиск... (Ctrl+K)",
|
||||
@@ -943,5 +946,25 @@
|
||||
"settings.restore.error": "Ошибка восстановления",
|
||||
"settings.restore.restarting": "Сервер перезапускается...",
|
||||
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
|
||||
"settings.button.close": "Закрыть"
|
||||
"settings.button.close": "Закрыть",
|
||||
|
||||
"settings.auto_backup.label": "Авто-бэкап",
|
||||
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
|
||||
"settings.auto_backup.enable": "Включить авто-бэкап",
|
||||
"settings.auto_backup.interval_label": "Интервал",
|
||||
"settings.auto_backup.max_label": "Макс. копий",
|
||||
"settings.auto_backup.save": "Сохранить настройки",
|
||||
"settings.auto_backup.saved": "Настройки авто-бэкапа сохранены",
|
||||
"settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа",
|
||||
"settings.auto_backup.last_backup": "Последний бэкап",
|
||||
"settings.auto_backup.never": "Никогда",
|
||||
|
||||
"settings.saved_backups.label": "Сохранённые копии",
|
||||
"settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.",
|
||||
"settings.saved_backups.empty": "Нет сохранённых копий",
|
||||
"settings.saved_backups.restore": "Восстановить",
|
||||
"settings.saved_backups.download": "Скачать",
|
||||
"settings.saved_backups.delete": "Удалить",
|
||||
"settings.saved_backups.delete_confirm": "Удалить эту резервную копию?",
|
||||
"settings.saved_backups.delete_error": "Не удалось удалить копию"
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"device.metrics.device_fps": "设备刷新率",
|
||||
"device.health.online": "在线",
|
||||
"device.health.offline": "离线",
|
||||
"device.health.streaming_unreachable": "流传输期间不可达",
|
||||
"device.health.checking": "检测中...",
|
||||
"device.tutorial.start": "开始教程",
|
||||
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
||||
@@ -912,6 +913,8 @@
|
||||
"targets.brightness_vs.none": "无(设备亮度)",
|
||||
"targets.min_brightness_threshold": "最低亮度阈值:",
|
||||
"targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)",
|
||||
"targets.adaptive_fps": "自适应FPS:",
|
||||
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
|
||||
|
||||
"search.open": "搜索 (Ctrl+K)",
|
||||
"search.placeholder": "搜索实体... (Ctrl+K)",
|
||||
@@ -943,5 +946,25 @@
|
||||
"settings.restore.error": "恢复失败",
|
||||
"settings.restore.restarting": "服务器正在重启...",
|
||||
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
|
||||
"settings.button.close": "关闭"
|
||||
"settings.button.close": "关闭",
|
||||
|
||||
"settings.auto_backup.label": "自动备份",
|
||||
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
|
||||
"settings.auto_backup.enable": "启用自动备份",
|
||||
"settings.auto_backup.interval_label": "间隔",
|
||||
"settings.auto_backup.max_label": "最大备份数",
|
||||
"settings.auto_backup.save": "保存设置",
|
||||
"settings.auto_backup.saved": "自动备份设置已保存",
|
||||
"settings.auto_backup.save_error": "保存自动备份设置失败",
|
||||
"settings.auto_backup.last_backup": "上次备份",
|
||||
"settings.auto_backup.never": "从未",
|
||||
|
||||
"settings.saved_backups.label": "已保存的备份",
|
||||
"settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。",
|
||||
"settings.saved_backups.empty": "没有已保存的备份",
|
||||
"settings.saved_backups.restore": "恢复",
|
||||
"settings.saved_backups.download": "下载",
|
||||
"settings.saved_backups.delete": "删除",
|
||||
"settings.saved_backups.delete_confirm": "删除此备份文件?",
|
||||
"settings.saved_backups.delete_error": "删除备份失败"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user