Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
119 lines
3.6 KiB
JavaScript
119 lines
3.6 KiB
JavaScript
// Stats tab: device status, lifetime counters, battery history chart
|
|
// (chart logic in battery_chart.js). window.FRAME_API set by template.
|
|
|
|
let lastDevice = null;
|
|
|
|
function renderDeviceStatus(device) {
|
|
const el = document.getElementById('device-status');
|
|
el.innerHTML = '';
|
|
if (!device || !device.last_seen) {
|
|
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
|
return;
|
|
}
|
|
const now = Date.now() / 1000;
|
|
const rows = [];
|
|
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
|
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
|
if (device.firmware_version) {
|
|
let fw = `v${device.firmware_version}`;
|
|
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
|
fw += ` (v${device.firmware_available} waiting)`;
|
|
}
|
|
rows.push(['Firmware', fw, false]);
|
|
}
|
|
if (device.battery) {
|
|
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
|
}
|
|
if (device.on_battery_since) {
|
|
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
|
}
|
|
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
|
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
|
}
|
|
for (const [label, value, alert] of rows) {
|
|
const p = document.createElement('p');
|
|
p.className = 'sub';
|
|
if (alert) {
|
|
p.style.color = 'var(--danger-text)';
|
|
p.style.fontWeight = '600';
|
|
}
|
|
p.textContent = `${label}: ${value}`;
|
|
el.appendChild(p);
|
|
}
|
|
}
|
|
|
|
async function loadDevice() {
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/queue`);
|
|
if (!resp.ok) {
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
lastDevice = data.device;
|
|
renderDeviceStatus(data.device);
|
|
} catch (e) { /* retried on the next poll */ }
|
|
}
|
|
|
|
function renderStats(stats) {
|
|
const el = document.getElementById('stats-box');
|
|
el.innerHTML = '';
|
|
const now = Date.now() / 1000;
|
|
const rows = [
|
|
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
|
|
['Wake cycles', stats.device_wakes],
|
|
['Photos displayed', stats.photos_displayed],
|
|
['Photos removed from rotation', stats.photos_removed],
|
|
['Battery reports received', stats.battery_reports],
|
|
['Battery recharge cycles', stats.recharge_cycles],
|
|
['OTA updates applied', stats.ota_updates_applied],
|
|
['Settings saved', stats.config_saves],
|
|
];
|
|
for (const [label, value] of rows) {
|
|
const p = document.createElement('p');
|
|
p.className = 'sub';
|
|
p.textContent = `${label}: ${value}`;
|
|
el.appendChild(p);
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
const el = document.getElementById('stats-box');
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/stats`);
|
|
if (!resp.ok) {
|
|
el.innerHTML = '<p class="sub">Could not load.</p>';
|
|
return;
|
|
}
|
|
renderStats(await resp.json());
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="sub">Could not load.</p>';
|
|
}
|
|
}
|
|
|
|
loadDevice();
|
|
loadStats();
|
|
loadBatteryLog();
|
|
|
|
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
|
// new theme's colors as soon as the toggle is used -- canvas pixels
|
|
// don't repaint themselves the way CSS does.
|
|
document.addEventListener('themechange', () => {
|
|
if (lastBatteryLog) {
|
|
drawBatteryChart(lastBatteryLog);
|
|
}
|
|
if (lastDevice) {
|
|
renderDeviceStatus(lastDevice);
|
|
}
|
|
});
|
|
|
|
// Fast tick: re-renders "Last seen"/"On battery for" from already-
|
|
// fetched data every second so they count up smoothly without hitting
|
|
// the server that often.
|
|
setInterval(() => {
|
|
if (lastDevice) {
|
|
renderDeviceStatus(lastDevice);
|
|
}
|
|
}, 1000);
|
|
|
|
setInterval(loadDevice, 10000);
|