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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user