diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 41a6d43..0a43637 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -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. +## 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 Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups. diff --git a/dashboard_studio/Dockerfile b/dashboard_studio/Dockerfile index 9cb062d..1c4281a 100644 --- a/dashboard_studio/Dockerfile +++ b/dashboard_studio/Dockerfile @@ -6,6 +6,7 @@ RUN apk add --no-cache python3 py3-aiohttp WORKDIR /app COPY server/ /app/server/ COPY web/ /app/web/ +COPY config.yaml /app/config.yaml COPY run.sh /run.sh RUN chmod a+x /run.sh diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 8ea05ab..0b59878 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.6.0" +version: "1.6.1" slug: dashboard_studio 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 diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py index a4ee1b5..d6c36fd 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -12,6 +12,7 @@ import asyncio import json import logging import os +import re from pathlib import Path import aiohttp @@ -32,7 +33,20 @@ HA_WS_URL = "ws://supervisor/core/websocket" HA_API_URL = "http://supervisor/core/api" 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" STORE_FILE = DATA_DIR / "dashboards.json" PORT = int(os.environ.get("PORT", "8099")) @@ -86,7 +100,15 @@ def save_store(data): 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): @@ -191,6 +213,7 @@ async def on_cleanup(app): def make_app(): app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024) app.router.add_get("/", index) + app.router.add_get("/api/version", version) app.router.add_get("/api/dashboards", get_dashboards) app.router.add_put("/api/dashboards", put_dashboards) app.router.add_get("/api/camera/{entity_id}", camera) @@ -206,5 +229,5 @@ def make_app(): if __name__ == "__main__": if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN: 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) diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index c2fa62c..2dc2431 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -4,6 +4,9 @@ const Widgets = window.Widgets; const Icons = window.Icons; 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. */ const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " ")))); const $ = (s, el = document) => el.querySelector(s); @@ -245,13 +248,15 @@ let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false }; // ------------------------------------------------------------ persistence - let saveTimer = null; + let saveTimer = null, savePending = false; function scheduleSave() { + savePending = true; $("#save-status").textContent = tr("Saving…"); clearTimeout(saveTimer); saveTimer = setTimeout(save, 700); } async function save() { + savePending = false; try { 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); @@ -677,6 +682,11 @@
${tr("Language")}
${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")}
+
${tr("About")}
+
+
Dashboard Studio
${tr("Version {0}", esc(APP_VERSION))}
+ ${Icons.svg("globe")}${tr("Source code and changes")} +

${tr("Select a widget to edit it.")}

`; } @@ -1369,7 +1379,27 @@ const c = $("#conn"); c.classList.toggle("ok", ok); 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; /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 7fa9c70..32b9d2f 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -242,6 +242,9 @@ "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}°", "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", }; diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html index f6e02e0..e1984dc 100644 --- a/dashboard_studio/web/index.html +++ b/dashboard_studio/web/index.html @@ -5,6 +5,7 @@ + Dashboard Studio diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 8701c62..1614802 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/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; } .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-item { display: flex; gap: 4px; align-items: center; } .tab-item .input { min-width: 0; }