v0.11.4-devices

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 09:58:26 +02:00
parent 500516b0fd
commit f0909a443d
10 changed files with 635 additions and 43 deletions
+140 -1
View File
@@ -35,7 +35,146 @@ function showHistoryTab(tab, { push = true, load = true } = {}) {
if (load) loadHistory();
}
function confirmManualCommandForDisabledZone(id) {
const zone = disabledZoneForDevice(id);
if (!zone) return true;
return confirm(tr('devices.manualDisabledZoneConfirm', { zone: zone.name }));
}
function pingSamples(id) {
if (!app.pingMonitor.samples[id]) app.pingMonitor.samples[id] = [];
return app.pingMonitor.samples[id];
}
function addPingSample(id, value, error = '') {
const samples = pingSamples(id);
samples.push({ at: Date.now(), value: Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : null, error: String(error || '') });
if (samples.length > 36) samples.splice(0, samples.length - 36);
}
function pingSparkline(samples) {
const width = 360, height = 92, padX = 8, padY = 9;
const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value));
if (!valid.length) return `<div class="ping-empty">${esc(tr('devices.pingNoSamples'))}</div>`;
const values = valid.map(item => item.value);
const min = Math.min(...values);
const max = Math.max(...values);
const ceiling = Math.max(max, 10);
const floor = Math.min(min, 0);
const range = Math.max(1, ceiling - floor);
const lastIndex = Math.max(1, samples.length - 1);
const points = valid.map(item => {
const x = padX + (item.index / lastIndex) * (width - padX * 2);
const y = height - padY - ((item.value - floor) / range) * (height - padY * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const guide = [0.25, 0.5, 0.75].map(ratio => `<line x1="${padX}" y1="${(height * ratio).toFixed(1)}" x2="${width - padX}" y2="${(height * ratio).toFixed(1)}"></line>`).join('');
return `<svg class="ping-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${esc(tr('devices.pingLive'))}"><g class="ping-grid-lines">${guide}</g><polyline points="${points}"></polyline></svg>`;
}
function pingStats(samples) {
const values = samples.map(sample => sample.value == null ? NaN : Number(sample.value)).filter(Number.isFinite);
if (!values.length) return { current: null, average: null, min: null, max: null };
const current = [...samples].reverse().find(sample => sample.value != null && Number.isFinite(Number(sample.value)))?.value ?? null;
return {
current,
average: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length),
min: Math.min(...values),
max: Math.max(...values),
};
}
function pingValue(value) {
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} ms` : '—';
}
function renderPingDialog() {
const dialog = $('#pingDialog');
const select = $('#pingDeviceSelect');
const all = $('#pingAllDevices');
const toggle = $('#pingToggleButton');
const grid = $('#pingLiveGrid');
if (!dialog || !select || !all || !toggle || !grid) return;
if (!app.pingMonitor.targetId || !app.devices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = app.devices[0]?.id || '';
select.innerHTML = app.devices.map(device => `<option value="${esc(device.id)}" ${device.id === app.pingMonitor.targetId ? 'selected' : ''}>${esc(device.name)}</option>`).join('');
select.disabled = app.pingMonitor.all;
all.checked = app.pingMonitor.all;
toggle.textContent = tr(app.pingMonitor.running ? 'devices.pingStop' : 'devices.pingStart');
toggle.classList.toggle('primary', app.pingMonitor.running);
toggle.classList.toggle('secondary', !app.pingMonitor.running);
const devices = app.pingMonitor.all ? app.devices : app.devices.filter(device => device.id === app.pingMonitor.targetId);
grid.innerHTML = devices.length ? devices.map(device => {
const samples = pingSamples(device.id);
const stats = pingStats(samples);
const last = samples.length ? samples[samples.length - 1] : null;
const state = last?.error ? `<span class="ping-state error">${esc(tr('devices.pingFailed'))}</span>` : `<span class="ping-state ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span>`;
return `<article class="ping-live-card">
<div class="ping-live-head"><div><strong>${esc(device.name)}</strong><small>${esc(device.ip)}</small></div>${state}</div>
${pingSparkline(samples)}
<div class="ping-stat-grid">
<div><span>${esc(tr('devices.pingCurrent'))}</span><strong>${esc(pingValue(stats.current))}</strong></div>
<div><span>${esc(tr('devices.pingAverage'))}</span><strong>${esc(pingValue(stats.average))}</strong></div>
<div><span>${esc(tr('devices.pingMin'))}</span><strong>${esc(pingValue(stats.min))}</strong></div>
<div><span>${esc(tr('devices.pingMax'))}</span><strong>${esc(pingValue(stats.max))}</strong></div>
</div>
<small class="ping-sample-count">${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''}</small>
</article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
}
function schedulePingCycle(delay = 1800) {
clearTimeout(app.pingMonitor.timer);
app.pingMonitor.timer = null;
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
app.pingMonitor.timer = setTimeout(runPingCycle, delay);
}
async function runPingCycle() {
if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return;
const targets = app.pingMonitor.all ? [...app.devices] : app.devices.filter(device => device.id === app.pingMonitor.targetId);
if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; }
app.pingMonitor.inFlight = true;
await Promise.allSettled(targets.map(async device => {
try {
const updated = await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' });
updateDevice(updated);
addPingSample(device.id, updated.response_time_ms);
} catch (error) {
addPingSample(device.id, null, error.message || String(error));
}
}));
app.pingMonitor.inFlight = false;
renderDevices();
renderPingDialog();
schedulePingCycle();
}
function startPingMonitor() {
if (!app.devices.length) return;
app.pingMonitor.running = true;
renderPingDialog();
clearTimeout(app.pingMonitor.timer);
app.pingMonitor.timer = null;
runPingCycle();
}
function stopPingMonitor() {
app.pingMonitor.running = false;
clearTimeout(app.pingMonitor.timer);
app.pingMonitor.timer = null;
renderPingDialog();
}
function openDevicePing(id) {
app.pingMonitor.targetId = id || app.devices[0]?.id || '';
app.pingMonitor.all = false;
openDialog('pingDialog');
renderPingDialog();
startPingMonitor();
}
async function sendDeviceCommand(id, commandOrFactory) {
if (!confirmManualCommandForDisabledZone(id)) return;
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => { }).then(() => {
const current = app.devices.find(device => device.id === id);
@@ -204,7 +343,7 @@ function showDiscoveryNames(ids) {
function populateDeviceRename(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#renameDeviceForm'); form.reset();
form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0);
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip; form.port.value = String(device.port ?? 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
openDialog('renameDeviceDialog');
}