Make the device status card always visible, not just on Stats
Build and push server image / build-and-push (push) Successful in 39s

Moved out of the Stats tab's side column into a new horizontal bar
shared by every frame page (Photos/Configuration/Stats), sitting
between the page title and the tabs so it's on screen regardless of
which tab is active.

_device_status_bar.html is a new partial included via a device_status
block in app_base.html; device_status_bar.js is the fetch/render/poll
logic extracted from frame_stats.js and adapted to a wrapping row of
label/value pairs instead of a stacked list. frame_stats.html's Device
card and now-single-card .side-col are gone -- Battery history and
Lifetime stats just stack directly.
This commit is contained in:
2026-07-22 10:46:05 -04:00
parent 996e06e2bc
commit 60fcfca4a0
8 changed files with 128 additions and 92 deletions
+77
View File
@@ -0,0 +1,77 @@
// Device status bar: always-visible strip (below the page title, above
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
// battery, so it's not tucked away on just the Stats tab. Shared by
// every frame page; each sets window.FRAME_API before this loads.
let lastDeviceStatus = null;
function renderDeviceStatusBar(device) {
const el = document.getElementById('device-status');
if (!el) return;
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 stat = document.createElement('span');
stat.className = 'device-stat' + (alert ? ' alert' : '');
const labelPart = document.createTextNode(label + ': ');
const valuePart = document.createElement('strong');
valuePart.textContent = value;
stat.appendChild(labelPart);
stat.appendChild(valuePart);
el.appendChild(stat);
}
}
async function loadDeviceStatusBar() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
return;
}
const data = await resp.json();
lastDeviceStatus = data.device;
renderDeviceStatusBar(data.device);
} catch (e) { /* retried on the next poll */ }
}
loadDeviceStatusBar();
document.addEventListener('themechange', () => {
if (lastDeviceStatus) {
renderDeviceStatusBar(lastDeviceStatus);
}
});
// 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 (lastDeviceStatus) {
renderDeviceStatusBar(lastDeviceStatus);
}
}, 1000);
setInterval(loadDeviceStatusBar, 10000);
+6 -73
View File
@@ -1,58 +1,6 @@
// Stats tab: device status, lifetime counters, battery history chart // Stats tab: lifetime counters + battery history chart (chart logic in
// (chart logic in battery_chart.js). window.FRAME_API set by template. // battery_chart.js). Device status now lives in the always-visible bar
// (device_status_bar.js), not here. 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) { function renderStats(stats) {
const el = document.getElementById('stats-box'); const el = document.getElementById('stats-box');
@@ -90,29 +38,14 @@ async function loadStats() {
} }
} }
loadDevice();
loadStats(); loadStats();
loadBatteryLog(); loadBatteryLog();
// Redraw the canvas chart (and the "Last seen" alert color) with the // Redraw the canvas chart with the new theme's colors as soon as the
// new theme's colors as soon as the toggle is used -- canvas pixels // toggle is used -- canvas pixels don't repaint themselves the way CSS
// don't repaint themselves the way CSS does. // does.
document.addEventListener('themechange', () => { document.addEventListener('themechange', () => {
if (lastBatteryLog) { if (lastBatteryLog) {
drawBatteryChart(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);
+27
View File
@@ -484,6 +484,33 @@ code {
} }
.control-banner button { margin: 0; } .control-banner button { margin: 0; }
/* Always-visible device summary, sitting between the page title and the
tabs (see _device_status_bar.html) -- a compact horizontal row rather
than a full .card, since it has to fit above the tabs on every frame
page without pushing content down. */
.device-status-bar {
padding: 10px 16px;
margin-bottom: 18px;
}
.device-status-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 26px;
row-gap: 6px;
}
.device-status-row .sub { margin: 0; }
.device-stat {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.device-stat strong { color: var(--text); font-weight: 600; }
.device-stat.alert, .device-stat.alert strong { color: var(--danger-text); }
@media (max-width: 860px) {
.device-status-row { column-gap: 16px; }
}
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */ /* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; } .mobile-bar { display: none; }
.sidebar-backdrop { display: none; } .sidebar-backdrop { display: none; }
@@ -0,0 +1,3 @@
<section class="card device-status-bar" id="device-status-bar">
<div id="device-status" class="device-status-row"><p class="sub">Loading...</p></div>
</section>
+1
View File
@@ -72,6 +72,7 @@
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button> <button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div> </div>
</div> </div>
{% block device_status %}{% endblock %}
{% block tabs %}{% endblock %} {% block tabs %}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+2
View File
@@ -3,6 +3,7 @@
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} {% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %} {% block content %}
@@ -203,5 +204,6 @@
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }}; window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }}; window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
</script> </script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_config.js"></script> <script src="/static/frame_config.js"></script>
{% endblock %} {% endblock %}
+2
View File
@@ -3,6 +3,7 @@
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} {% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %} {% block content %}
@@ -55,6 +56,7 @@
{% block scripts %} {% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script> <script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/queue.js"></script> <script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script> <script src="/static/frame_photos.js"></script>
{% endblock %} {% endblock %}
+10 -19
View File
@@ -3,29 +3,19 @@
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} {% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %} {% block content %}
<div class="layout"> <section class="card">
<div class="main-col"> <h2 class="card-title">Battery history</h2>
<section class="card"> <div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
<h2 class="card-title">Battery history</h2> </section>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card"> <section class="card">
<h2 class="card-title">Lifetime stats</h2> <h2 class="card-title">Lifetime stats</h2>
<div id="stats-box"><p class="sub">Loading...</p></div> <div id="stats-box"><p class="sub">Loading...</p></div>
</section> </section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<div id="result"></div> <div id="result"></div>
{% endblock %} {% endblock %}
@@ -33,5 +23,6 @@
{% block scripts %} {% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script> <script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script> <script src="/static/battery_chart.js"></script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_stats.js"></script> <script src="/static/frame_stats.js"></script>
{% endblock %} {% endblock %}