Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:12:30 +01:00
parent f9822a9c49
commit 7abf7379d8
11 changed files with 1728 additions and 182 deletions
+80
View File
@@ -13,6 +13,7 @@ import json
import logging
import os
import re
import time
from pathlib import Path
import aiohttp
@@ -99,6 +100,50 @@ def save_store(data):
tmp.replace(STORE_FILE)
# Version history: each dashboard keeps its last saves in history/<id>.json, newest last.
# Saves within a few minutes of each other count as one version, so typing doesn't fill the list.
HISTORY_DIR = DATA_DIR / "history"
HISTORY_MAX = 30
HISTORY_MERGE_S = 5 * 60
SAFE_ID = re.compile(r"^[\w-]{1,64}$")
def history_file(dash_id):
return HISTORY_DIR / f"{dash_id}.json"
def load_history(dash_id):
try:
return json.loads(history_file(dash_id).read_text())
except (OSError, json.JSONDecodeError):
return []
def record_history(old, new):
"""Add every dashboard that changed in this save to its history."""
before = {d.get("id"): d for d in old.get("dashboards", []) if isinstance(d, dict)}
now = time.time()
for d in new.get("dashboards", []):
dash_id = d.get("id") if isinstance(d, dict) else None
if not isinstance(dash_id, str) or not SAFE_ID.match(dash_id) or before.get(dash_id) == d:
continue
entries = load_history(dash_id)
if not entries and dash_id in before: # the first time: keep what was there before this save
try:
saved_at = min(STORE_FILE.stat().st_mtime, now - HISTORY_MERGE_S - 1)
except OSError:
saved_at = now - HISTORY_MERGE_S - 1
entries.append({"at": saved_at, "data": before[dash_id]})
if entries and now - entries[-1]["at"] < HISTORY_MERGE_S:
entries[-1] = {"at": now, "data": d}
else:
entries.append({"at": now, "data": d})
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
tmp = history_file(dash_id).with_suffix(".tmp")
tmp.write_text(json.dumps(entries[-HISTORY_MAX:]))
tmp.replace(history_file(dash_id))
async def index(request):
# 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()
@@ -164,10 +209,43 @@ async def put_dashboards(request):
return web.json_response({"error": "invalid json"}, status=400)
if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list):
return web.json_response({"error": "expected {dashboards: [...]}"}, status=400)
try:
record_history(load_store(), data)
except OSError:
LOG.exception("Could not write the version history")
save_store(data)
return web.json_response({"ok": True})
def widget_count(d):
return sum(len(t.get("widgets", [])) for t in d.get("tabs", []) if isinstance(t, dict))
async def get_history(request):
dash_id = request.match_info["dash_id"]
if not SAFE_ID.match(dash_id):
return web.Response(status=400)
entries = load_history(dash_id)
return web.json_response([
{"at": e["at"], "name": e["data"].get("name", ""), "tabs": len(e["data"].get("tabs", [])), "widgets": widget_count(e["data"])}
for e in reversed(entries)
], headers={"Cache-Control": "no-store"})
async def get_history_version(request):
dash_id = request.match_info["dash_id"]
if not SAFE_ID.match(dash_id):
return web.Response(status=400)
try:
at = float(request.match_info["at"])
except ValueError:
return web.Response(status=400)
for e in load_history(dash_id):
if abs(e["at"] - at) < 0.001:
return web.json_response(e["data"], headers={"Cache-Control": "no-store"})
return web.Response(status=404)
async def camera(request):
entity_id = request.match_info["entity_id"]
if not entity_id.startswith("camera.") or "/" in entity_id:
@@ -261,6 +339,8 @@ def make_app():
app.router.add_get("/api/users", users)
app.router.add_get("/api/dashboards", get_dashboards)
app.router.add_put("/api/dashboards", put_dashboards)
app.router.add_get("/api/history/{dash_id}", get_history)
app.router.add_get("/api/history/{dash_id}/{at}", get_history_version)
app.router.add_get("/api/camera/{entity_id}", camera)
app.router.add_get("/ws", websocket)
app["immich"] = Immich(OPTIONS["immich_url"], OPTIONS["immich_api_key"], mock=MOCK)