Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02934b1d10 | ||
|
|
f24c3b9c8e | ||
|
|
38944a1287 | ||
|
|
5b4fdbe330 | ||
|
|
dcbc71e683 | ||
|
|
f4d2a23e8a | ||
|
|
55b53d5bb2 | ||
|
|
60fcfca4a0 |
@@ -591,7 +591,12 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out
|
||||
|
||||
uint32_t count = 0;
|
||||
json_extract_uint(body, "count", &count);
|
||||
if ((int)count > max_labels) {
|
||||
/* Unsigned compare: casting count to int first let a server-supplied
|
||||
* value >= 2^31 (still a perfectly ordinary decimal in the JSON) go
|
||||
* negative, skipping this clamp entirely and driving the loop below
|
||||
* with the full attacker/server-controlled count -- out[found] is a
|
||||
* fixed MANAGE_FACE_LABELS_MAX-element caller stack array. */
|
||||
if (count > (uint32_t)max_labels) {
|
||||
count = (uint32_t)max_labels;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.2
|
||||
1.2.3
|
||||
|
||||
@@ -15,7 +15,7 @@ import io
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .image_pipeline import _placement_transform, logical_render_size, logical_to_native
|
||||
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native
|
||||
|
||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||
@@ -55,6 +55,8 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
||||
|
||||
labels = []
|
||||
for face in named[:MAX_LABELED_FACES]:
|
||||
if not _has_bounding_box(face):
|
||||
continue
|
||||
face_w = face.get("imageWidth") or fitted.width
|
||||
face_h = face.get("imageHeight") or fitted.height
|
||||
img_scale_x = fitted.width / face_w
|
||||
|
||||
@@ -118,6 +118,16 @@ def _plain_center_crop_box(
|
||||
return left, top, crop_w, crop_h
|
||||
|
||||
|
||||
def _has_bounding_box(face: dict) -> bool:
|
||||
"""Immich has occasionally been observed to return a face entry with
|
||||
a still-pending or otherwise incomplete bounding box (a null field)
|
||||
-- treat it as undetected rather than crash on arithmetic with None."""
|
||||
return all(
|
||||
face.get(k) is not None
|
||||
for k in ("boundingBoxX1", "boundingBoxX2", "boundingBoxY1", "boundingBoxY2")
|
||||
)
|
||||
|
||||
|
||||
def _face_aware_crop_box(
|
||||
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
|
||||
) -> tuple[int, int, int, int]:
|
||||
@@ -138,6 +148,8 @@ def _face_aware_crop_box(
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
for face in faces:
|
||||
if not _has_bounding_box(face):
|
||||
continue
|
||||
face_w = face.get("imageWidth") or img_width
|
||||
face_h = face.get("imageHeight") or img_height
|
||||
scale_x = img_width / face_w
|
||||
|
||||
+17
-11
@@ -94,17 +94,23 @@ def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||
current = row[0] if row else 0
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
if row is None:
|
||||
conn.execute(
|
||||
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
|
||||
)
|
||||
row = (version,)
|
||||
else:
|
||||
if row is None:
|
||||
# Brand new database: _migration_1's create_all() already
|
||||
# produces today's full schema straight from models.py.
|
||||
# Every migration after it is an incremental ALTER/UPDATE
|
||||
# meant to bring an *existing* install forward -- replaying
|
||||
# those here would just collide with columns create_all
|
||||
# already added (e.g. "duplicate column name"). Jump
|
||||
# straight to the latest version instead.
|
||||
_migration_1(conn)
|
||||
latest = MIGRATIONS[-1][0]
|
||||
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
|
||||
else:
|
||||
current = row[0]
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -59,6 +60,17 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
def _valid_repo_url(url: str) -> bool:
|
||||
"""The frame will periodically fetch from this URL on its own (see
|
||||
gitea_releases.py) and, with auto-update on, install whatever it
|
||||
finds -- unlike a one-off manual firmware upload, that's a standing
|
||||
trust relationship, so it's worth rejecting obviously-wrong input at
|
||||
save time rather than only failing later at fetch time. http(s) only
|
||||
-- no file://, no other schemes."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -129,7 +141,10 @@ def api_config_save(
|
||||
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
if firmware_update_repo_url is not None:
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
stripped = firmware_update_repo_url.strip()
|
||||
if stripped and not _valid_repo_url(stripped):
|
||||
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||
cfg.firmware_update_repo_url = stripped
|
||||
if firmware_auto_update is not None:
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
if battery_alert_threshold_pct is not None:
|
||||
@@ -207,7 +222,6 @@ def api_queue(
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"battery_as_of": cfg.battery_as_of,
|
||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
||||
"battery_estimate_s": battery_estimate_s(cfg),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"controller": (
|
||||
@@ -240,7 +254,6 @@ def api_queue(
|
||||
if snapshot["battery_percent"] >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": snapshot["on_battery_since"],
|
||||
"battery_estimate_s": snapshot["battery_estimate_s"],
|
||||
},
|
||||
}
|
||||
@@ -323,7 +336,14 @@ def api_queue_remove(
|
||||
|
||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
"""Scoped to what this frame is actually showing/queuing -- a user
|
||||
merely linked to view this frame shouldn't be able to pull thumbnails
|
||||
for arbitrary asset ids in the owner's Immich library, only the
|
||||
frame's own curated album. Same rule device.frame_share and
|
||||
manage.manage_thumbnail already enforce."""
|
||||
require_configured(frame)
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
raise HTTPException(404, "Not on this frame")
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
@@ -437,15 +457,22 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
||||
return version
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/firmware/check")
|
||||
@router.post("/api/frames/{frame_id}/firmware/check")
|
||||
def api_firmware_check(
|
||||
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Throttled check of the configured Gitea repo's latest release
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
just reports it so the UI can offer the "Update frame" button.
|
||||
force=true (the "Check now" button) bypasses the throttle."""
|
||||
force=true (the "Check now" button) bypasses the throttle.
|
||||
|
||||
require_frame_control (not view), and POST (not GET): this can
|
||||
silently stage new firmware as a side effect (the auto-apply path
|
||||
below) exactly like /firmware/apply-latest, so it needs the same
|
||||
guard that route has -- a linked viewer without control shouldn't be
|
||||
able to trigger that, and as a GET it would've been exempt from the
|
||||
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
|
||||
if not frame.firmware_update_repo_url:
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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]);
|
||||
// Shown as soon as there's any battery reading at all, even before
|
||||
// battery_estimate_s can compute a rate (needs 2h+ span and a 2%+
|
||||
// drop within the current discharge cycle -- see common.py) -- so
|
||||
// it's clear the number is coming, not that the feature is broken.
|
||||
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
||||
rows.push([
|
||||
'Est. battery life left',
|
||||
hasEstimate ? `~${formatDuration(device.battery_estimate_s)}` : 'Not enough data yet',
|
||||
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" from already-fetched data every
|
||||
// second so it counts up smoothly without hitting the server that often.
|
||||
setInterval(() => {
|
||||
if (lastDeviceStatus) {
|
||||
renderDeviceStatusBar(lastDeviceStatus);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setInterval(loadDeviceStatusBar, 10000);
|
||||
@@ -259,7 +259,7 @@ async function loadFirmwareCheck(force) {
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
if (force) {
|
||||
showStatus(false, await apiError(resp));
|
||||
|
||||
@@ -1,58 +1,6 @@
|
||||
// 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 */ }
|
||||
}
|
||||
// Stats tab: lifetime counters + battery history chart (chart logic in
|
||||
// battery_chart.js). Device status now lives in the always-visible bar
|
||||
// (device_status_bar.js), not here. window.FRAME_API set by template.
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
@@ -90,29 +38,14 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// Redraw the canvas chart 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);
|
||||
|
||||
@@ -484,6 +484,33 @@ code {
|
||||
}
|
||||
.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-bar { 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>
|
||||
@@ -72,6 +72,7 @@
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% block device_status %}{% endblock %}
|
||||
{% block tabs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% 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 content %}
|
||||
@@ -105,6 +106,9 @@
|
||||
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<p class="sub" style="margin-top: 4px;">While on, this frame installs
|
||||
whatever the repo above publishes next, with nobody reviewing it
|
||||
first -- only point it at a repo you trust.</p>
|
||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||
@@ -203,5 +207,6 @@
|
||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% 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 content %}
|
||||
@@ -55,6 +56,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
<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/frame_photos.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,29 +3,19 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% 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 content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</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>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
@@ -33,5 +23,6 @@
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/battery_chart.js"></script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_stats.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user