Dashboard Studio 1.6.1: show version, load fresh files after updates
- Version shown under About in the dashboard settings, with a link to the repository; /api/version reports it - index.html is never cached and stamps the version on every asset URL, so browsers can't keep running old files after an update - Open screens check the version after reconnecting and every 5 minutes and reload into the new version (editing only shows a message); pending saves are flushed first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,10 @@ Dashboard Studio is available in English and Dutch (Nederlands). By default it f
|
|||||||
|
|
||||||
Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
|
Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
|
||||||
|
|
||||||
|
## Version and updates
|
||||||
|
|
||||||
|
The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups.
|
Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RUN apk add --no-cache python3 py3-aiohttp
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/ /app/server/
|
COPY server/ /app/server/
|
||||||
COPY web/ /app/web/
|
COPY web/ /app/web/
|
||||||
|
COPY config.yaml /app/config.yaml
|
||||||
COPY run.sh /run.sh
|
COPY run.sh /run.sh
|
||||||
RUN chmod a+x /run.sh
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Dashboard Studio
|
name: Dashboard Studio
|
||||||
version: "1.6.0"
|
version: "1.6.1"
|
||||||
slug: dashboard_studio
|
slug: dashboard_studio
|
||||||
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
||||||
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -32,7 +33,20 @@ HA_WS_URL = "ws://supervisor/core/websocket"
|
|||||||
HA_API_URL = "http://supervisor/core/api"
|
HA_API_URL = "http://supervisor/core/api"
|
||||||
INGRESS_PROXY_IP = "172.30.32.2"
|
INGRESS_PROXY_IP = "172.30.32.2"
|
||||||
|
|
||||||
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
WEB_DIR = APP_DIR / "web"
|
||||||
|
|
||||||
|
|
||||||
|
def read_version():
|
||||||
|
"""The app version from config.yaml (copied into the image next to server/ and web/)."""
|
||||||
|
try:
|
||||||
|
m = re.search(r'^version:\s*"?([^"\n]+)"?', (APP_DIR / "config.yaml").read_text(), re.M)
|
||||||
|
return m.group(1).strip() if m else "dev"
|
||||||
|
except OSError:
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = read_version()
|
||||||
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data"
|
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data"
|
||||||
STORE_FILE = DATA_DIR / "dashboards.json"
|
STORE_FILE = DATA_DIR / "dashboards.json"
|
||||||
PORT = int(os.environ.get("PORT", "8099"))
|
PORT = int(os.environ.get("PORT", "8099"))
|
||||||
@@ -86,7 +100,15 @@ def save_store(data):
|
|||||||
|
|
||||||
|
|
||||||
async def index(request):
|
async def index(request):
|
||||||
return web.FileResponse(WEB_DIR / "index.html")
|
# Stamp the version on every asset URL, so an update can never be served from a stale cache.
|
||||||
|
html = (WEB_DIR / "index.html").read_text()
|
||||||
|
html = re.sub(r'(static/[\w.-]+\.(?:js|css|png))"', rf'\1?v={VERSION}"', html)
|
||||||
|
html = html.replace("__VERSION__", VERSION)
|
||||||
|
return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
async def version(request):
|
||||||
|
return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
async def get_dashboards(request):
|
async def get_dashboards(request):
|
||||||
@@ -191,6 +213,7 @@ async def on_cleanup(app):
|
|||||||
def make_app():
|
def make_app():
|
||||||
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
|
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
|
||||||
app.router.add_get("/", index)
|
app.router.add_get("/", index)
|
||||||
|
app.router.add_get("/api/version", version)
|
||||||
app.router.add_get("/api/dashboards", get_dashboards)
|
app.router.add_get("/api/dashboards", get_dashboards)
|
||||||
app.router.add_put("/api/dashboards", put_dashboards)
|
app.router.add_put("/api/dashboards", put_dashboards)
|
||||||
app.router.add_get("/api/camera/{entity_id}", camera)
|
app.router.add_get("/api/camera/{entity_id}", camera)
|
||||||
@@ -206,5 +229,5 @@ def make_app():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
|
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
|
||||||
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
|
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
|
||||||
LOG.info("Dashboard Studio listening on port %s (data in %s)", PORT, DATA_DIR)
|
LOG.info("Dashboard Studio %s listening on port %s (data in %s)", VERSION, PORT, DATA_DIR)
|
||||||
web.run_app(make_app(), port=PORT, access_log=None)
|
web.run_app(make_app(), port=PORT, access_log=None)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
const Widgets = window.Widgets;
|
const Widgets = window.Widgets;
|
||||||
const Icons = window.Icons;
|
const Icons = window.Icons;
|
||||||
const tr = window.I18n.t, LOCALE = window.I18n.locale;
|
const tr = window.I18n.t, LOCALE = window.I18n.locale;
|
||||||
|
// Filled in by the server; "dev" when index.html is opened some other way.
|
||||||
|
const APP_VERSION = (document.querySelector('meta[name="ds-version"]')?.content || "dev").replace("__VERSION__", "dev");
|
||||||
|
const REPO_URL = "https://git.melvanveen.nl/veenm/ha-dashboard-studio";
|
||||||
/** Option labels: a direct translation when there is one, else the capitalised value. */
|
/** Option labels: a direct translation when there is one, else the capitalised value. */
|
||||||
const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " "))));
|
const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " "))));
|
||||||
const $ = (s, el = document) => el.querySelector(s);
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
@@ -245,13 +248,15 @@
|
|||||||
let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false };
|
let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false };
|
||||||
|
|
||||||
// ------------------------------------------------------------ persistence
|
// ------------------------------------------------------------ persistence
|
||||||
let saveTimer = null;
|
let saveTimer = null, savePending = false;
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
|
savePending = true;
|
||||||
$("#save-status").textContent = tr("Saving…");
|
$("#save-status").textContent = tr("Saving…");
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(save, 700);
|
saveTimer = setTimeout(save, 700);
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
|
savePending = false;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) });
|
const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) });
|
||||||
if (!r.ok) throw new Error(r.status);
|
if (!r.ok) throw new Error(r.status);
|
||||||
@@ -677,6 +682,11 @@
|
|||||||
<div class="section"><div class="section-title">${tr("Language")}</div>
|
<div class="section"><div class="section-title">${tr("Language")}</div>
|
||||||
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")}
|
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section"><div class="section-title">${tr("About")}</div>
|
||||||
|
<div class="about"><img src="static/favicon.png?v=${esc(APP_VERSION)}" alt="" width="40" height="40">
|
||||||
|
<div><div style="font-weight:600">Dashboard Studio</div><div class="muted small">${tr("Version {0}", esc(APP_VERSION))}</div></div></div>
|
||||||
|
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="width:100%;margin-top:10px;text-decoration:none">${Icons.svg("globe")}${tr("Source code and changes")}</a>
|
||||||
|
</div>
|
||||||
<p class="hint">${tr("Select a widget to edit it.")}</p>`;
|
<p class="hint">${tr("Select a widget to edit it.")}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1369,7 +1379,27 @@
|
|||||||
const c = $("#conn");
|
const c = $("#conn");
|
||||||
c.classList.toggle("ok", ok);
|
c.classList.toggle("ok", ok);
|
||||||
c.textContent = ok ? tr("Connected") : tr("Offline");
|
c.textContent = ok ? tr("Connected") : tr("Offline");
|
||||||
|
// An update restarts the app, which drops the connection: check right after reconnecting.
|
||||||
|
if (ok) checkVersion();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** If the app was updated while this page was open, load the new version. */
|
||||||
|
let updateNoticeShown = false;
|
||||||
|
async function checkVersion() {
|
||||||
|
if (APP_VERSION === "dev") return;
|
||||||
|
let latest;
|
||||||
|
try { latest = (await api("api/version")).version; } catch { return; }
|
||||||
|
if (!latest || latest === APP_VERSION) return;
|
||||||
|
if (editMode) { // never yank the editor away; just say so once
|
||||||
|
if (!updateNoticeShown) toast(tr("Dashboard Studio was updated to {0}. Reload the page to use it.", latest));
|
||||||
|
updateNoticeShown = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast(tr("Dashboard Studio was updated to {0}. Reloading…", latest));
|
||||||
|
if (savePending) { clearTimeout(saveTimer); await save(); } // keep edits still waiting to be saved
|
||||||
|
setTimeout(() => location.reload(), 2500);
|
||||||
|
}
|
||||||
|
setInterval(checkVersion, 5 * 60 * 1000);
|
||||||
ha.onChange = onStateChange;
|
ha.onChange = onStateChange;
|
||||||
|
|
||||||
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
|
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
|
||||||
|
|||||||
@@ -242,6 +242,9 @@
|
|||||||
"Weather (optional)": "Weer (optioneel)", "Show today’s high and low": "Toon de hoogste en laagste temperatuur van vandaag",
|
"Weather (optional)": "Weer (optioneel)", "Show today’s high and low": "Toon de hoogste en laagste temperatuur van vandaag",
|
||||||
"High {0}° · Low {1}°": "Max {0}° · min {1}°", "High {0}°": "Max {0}°",
|
"High {0}° · Low {1}°": "Max {0}° · min {1}°", "High {0}°": "Max {0}°",
|
||||||
"Menu style": "Menustijl", "icons": "Alleen pictogrammen", "labels": "Met namen",
|
"Menu style": "Menustijl", "icons": "Alleen pictogrammen", "labels": "Met namen",
|
||||||
|
"About": "Over", "Version {0}": "Versie {0}", "Source code and changes": "Broncode en wijzigingen",
|
||||||
|
"Dashboard Studio was updated to {0}. Reload the page to use it.": "Dashboard Studio is bijgewerkt naar {0}. Herlaad de pagina om de nieuwe versie te gebruiken.",
|
||||||
|
"Dashboard Studio was updated to {0}. Reloading…": "Dashboard Studio is bijgewerkt naar {0}. Opnieuw laden…",
|
||||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="ds-version" content="__VERSION__">
|
||||||
<title>Dashboard Studio</title>
|
<title>Dashboard Studio</title>
|
||||||
<link rel="icon" type="image/png" href="static/favicon.png">
|
<link rel="icon" type="image/png" href="static/favicon.png">
|
||||||
<link rel="stylesheet" href="static/style.css">
|
<link rel="stylesheet" href="static/style.css">
|
||||||
|
|||||||
@@ -439,6 +439,8 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
|||||||
}
|
}
|
||||||
.surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
.surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||||
.i.flip { transform: rotate(180deg); }
|
.i.flip { transform: rotate(180deg); }
|
||||||
|
.about { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.about img { border-radius: 10px; flex: none; }
|
||||||
.tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
|
.tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
|
||||||
.tab-item { display: flex; gap: 4px; align-items: center; }
|
.tab-item { display: flex; gap: 4px; align-items: center; }
|
||||||
.tab-item .input { min-width: 0; }
|
.tab-item .input { min-width: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user