Replace notice pages with widgets: pinned text, Sonarr/Radarr calendars, Plex recently-added

WidgetStore keeps the JSON file (same atomic-write/lock pattern as store.py) and
migrates any old pages.json in place. WidgetData fetches each live widget's own
base_url server-side, normalises Sonarr/Radarr/Plex payloads into one row shape,
caches per <id>@<updated_at> behind UWUDASH_WIDGET_CACHE_TTL, and relays artwork
through /api/widgets/{id}/image so the browser never needs the internal host.

Home now renders one merged calendar timeline, one card per recent widget, then
notices; the Settings table carries a Test button that bypasses the cache.
This commit is contained in:
Archibold committed 2026-11-07 17:25:54 +11:00
1 parent 298ef9dbf5
commit 24dd017905
12 files changed
+1050 -302

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
# Two stages: the wheel is built from the src layout, then installed into a venv
# in a clean image, so the runtime layer carries no build tooling and no second
# copy of the source. data/ is a volume: the services store, users.json and
# pages.json live there, so edits survive a restart and the host's internal URLs
# widgets.json live there, so edits survive a restart and the host's internal URLs
# never get baked into a layer.
ARG PYTHON_VERSION=3.11
+57 -29
View File
@@ -47,7 +47,7 @@ clean stage installs it into `/app/.venv`, and the entrypoint is the
(`UWUDASH_HOST` / `UWUDASH_PORT` are baked in as `ENV` defaults - override both
the `--env` and the `-p` mapping if you change the port).
`/app/data` is a `VOLUME` holding `services.json`, `pages.json` (and `users.json`
`/app/data` is a `VOLUME` holding `services.json`, `widgets.json` (and `users.json`
once auth is on): a named volume keeps your service list across restarts, a bind mount
(`-v ./data:/app/data`) seeds the container from the host file instead. `data/`
is in `.dockerignore`, so internal URLs and per-service `headers` tokens never
@@ -78,7 +78,7 @@ container stays `healthy` with auth on.
| `--port` | `8000` | bind port |
| `--data-dir` | `data` | directory holding `services.json` |
| `--services-file` | `<data-dir>/services.json` | explicit store path |
| `--pages-file` | `<data-dir>/pages.json` | explicit notice store path |
| `--widgets-file` | `<data-dir>/widgets.json` | explicit widget store path |
| `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars |
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
| `--reload` | off | uvicorn reload on source changes |
@@ -91,7 +91,8 @@ container stays `healthy` with auth on.
| `UWUDASH_PORT` | `8000` | bind port |
| `UWUDASH_DATA_DIR` | `data` | data directory |
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
| `UWUDASH_PAGES_FILE` | `data/pages.json` | notice store path |
| `UWUDASH_WIDGETS_FILE` | `data/widgets.json` | widget store path |
| `UWUDASH_WIDGET_CACHE_TTL` | `300` | seconds a live widget's upstream payload is reused (0 = fetch every render) |
| `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) |
| `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses |
| `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) |
@@ -205,20 +206,34 @@ Services live in `data/services.json`:
Edit them from the **Settings** page in the UI, or via the API.
## Notice pages
## Widgets
Notices are native dashboard content - no URL, no proxy. They live in
`data/pages.json` (same atomic-write format as `services.json`):
Widgets are native dashboard content - no iframe, no URL bar. They live in
`data/widgets.json` (same atomic-write/lock format as `services.json`) and come
in three kinds:
| kind | what it shows | what it needs |
| --- | --- | --- |
| `notice` | pinned text, nothing fetched | `body` (≤ 5000 chars, line breaks kept) |
| `calendar` | a release window from a Sonarr-family API | `base_url`, `api_prefix` (default `/api/v3`), `days` (1-90), `date_type` (`auto`/`physical`/`digital`/`cinemas`/`release`) |
| `recent` | what just landed (`<base_url>/library/recentlyAdded`) | `base_url`, `limit` (1-50) |
```json
{
"version": 1,
"pages": [
"widgets": [
{
"id": "maintenance",
"name": "Maintenance",
"icon": "🔧",
"body": "Server down tonight\nBring snacks",
"id": "upcoming",
"name": "Upcoming",
"icon": "",
"kind": "calendar",
"body": null,
"base_url": "http://internal-host:8080",
"headers": { "X-Api-Key": "redacted" },
"api_prefix": "/api/v3",
"days": 14,
"date_type": "auto",
"limit": 12,
"created_at": "2026-10-28T09:29:31+00:00",
"updated_at": "2026-10-28T09:29:31+00:00"
}
@@ -226,18 +241,29 @@ Notices are native dashboard content - no URL, no proxy. They live in
}
```
- `id` is slugified from the name and stays stable across edits; `body` is capped
at 5000 characters and keeps its line breaks (`white-space: pre-wrap`, escaped).
- Each notice gets a sidebar button; clicking it shows a single card in the viewer
area instead of the iframe.
- The notice board is the **default view**: landing on `/` already shows it in the
viewer area (banner plus every notice, newest first), no click needed. The pinned
**Home** button (`🏠`, always present) re-opens it, and deleting whatever you were
viewing falls back to it. `/` itself stays the dashboard.
- Writes are admin-only when OIDC is on, reads any session - same gate as services.
- `id` is slugified from the name and stays stable across edits.
- Live widgets are fetched **at render time**, never at save time: the first
render after a save fetches, later renders reuse the payload until
`UWUDASH_WIDGET_CACHE_TTL` (default 300 s) expires. The cache key is
`<id>@<updated_at>`, so editing a widget invalidates it immediately. Errors
cache for at most 60 s so a fixed upstream recovers quickly.
- Upstream keys go in `headers` (the form's "Headers" textarea, one `Key: value`
per line) - they stay on the server-to-server hop and never appear in the
browser.
- Unreachable upstreams degrade instead of blanking the board: the card shows
`unreachable: <error>` and the Settings table's Test button relays the exact
upstream message (`connection refused`, Sonarr's `ApiKey was not provided`...).
- Artwork (Radarr posters, Plex thumbs) is relayed through
`/api/widgets/{id}/image?u=...` - the browser cannot reach the internal host,
and the relay only ever fetches from that widget's own origin.
- Home renders one merged calendar card (every `calendar` widget, sorted by
date), one card per `recent` widget, then notices. Each widget also gets its
own sidebar button for a single-card view.
- Writes are admin-only when OIDC is on, reads any session - same gate as
services.
Edit them from the **Settings** page (the "Add notice" form and the notice table),
or via the API.
Edit them from the **Settings** page (the "Add widget" form and the widget
table, which carries a Test button per live widget), or via the API.
## How the proxy works
@@ -285,11 +311,13 @@ Details:
| `PATCH` / `PUT` | `/api/services/{id}` | update |
| `DELETE` | `/api/services/{id}` | delete |
| `GET` | `/api/services/{id}/health` | upstream reachability check |
| `GET` | `/api/pages` | list notices |
| `POST` | `/api/pages` | create - admin only |
| `GET` | `/api/pages/{id}` | read |
| `PATCH` | `/api/pages/{id}` | update - admin only |
| `DELETE` | `/api/pages/{id}` | delete - admin only |
| `GET` | `/api/widgets` | every widget, with live data inlined for `calendar`/`recent` |
| `POST` | `/api/widgets` | create - admin only |
| `GET` | `/api/widgets/{id}` | read the stored config |
| `PATCH` | `/api/widgets/{id}` | update - admin only |
| `POST` | `/api/widgets/{id}/refresh` | bypass the cache and fetch now - admin only |
| `GET` | `/api/widgets/{id}/image?u=...` | relay one artwork URL from that widget's own origin |
| `DELETE` | `/api/widgets/{id}` | delete - admin only |
| `GET` | `/api/config` | effective config (never includes the OIDC secret) |
| `GET` | `/api/me` | current user (only when auth is enabled) |
| `GET` | `/api/users` | list users - admin only |
@@ -306,9 +334,9 @@ Details:
```
src/uwudash_mk3/
config.py env-driven Config dataclass
models.py Service / ServiceInput / ServicePatch / Page / PageInput / PagePatch / User / UserPatch + URL helpers
models.py Service / ServiceInput / ServicePatch / Widget / WidgetInput / WidgetPatch / User / UserPatch + URL helpers
store.py JSON file store (atomic writes, lock)
pages.py JSON notice store (same pattern as store.py)
widgets.py JSON widget store + server-side fetch/cache/relay layer
users.py JSON user store (same pattern as store.py)
auth.py OIDC client, login/callback/logout, role dependencies
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
+80 -29
View File
@@ -1,31 +1,38 @@
"""JSON API used by the dashboard UI to manage services, notices, and users."""
"""JSON API used by the dashboard UI to manage services, widgets, and users."""
from __future__ import annotations
from typing import List, Optional
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from .config import Config
from .models import Page, PageInput, PagePatch, Service, ServiceInput, ServicePatch
from .models import Service, ServiceInput, ServicePatch
from .models import User, UserPatch
from .pages import PageStore
from .models import Widget, WidgetInput, WidgetPatch
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
from .widgets import WidgetData, WidgetStore
def create_api_router(
store: ServiceStore,
pages: PageStore,
widgets: WidgetStore,
widget_data: WidgetData,
proxy: Proxy,
config: Config,
users: Optional[UserStore] = None,
users=None,
require_login=None,
require_admin=None,
) -> APIRouter:
"""Build the ``/api`` router.
Live widget data is fetched here rather than in the browser: the upstream
only has to be reachable from this process, so the UI never sees a token or
an internal hostname.
``users``/``require_login``/``require_admin`` are only supplied when OIDC
is configured; without them the router is exactly the unauthenticated one.
"""
@@ -40,7 +47,8 @@ def create_api_router(
"host": config.host,
"port": config.port,
"services_file": str(config.services_file),
"pages_file": str(config.pages_file),
"widgets_file": str(config.widgets_file),
"widget_cache_ttl": config.widget_cache_ttl,
"strip_frame_options": config.strip_frame_options,
"proxy_timeout": config.proxy_timeout,
"auth_enabled": config.auth_enabled,
@@ -102,43 +110,86 @@ def create_api_router(
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return await proxy.check(service)
@router.get("/pages")
def list_pages() -> List[Page]:
return pages.list()
@router.get("/widgets")
async def list_widgets() -> List[dict]:
"""Every widget, with live data inlined for the ones that have an upstream.
@router.get("/pages/{page_id}")
def get_page(page_id: str) -> Page:
page = pages.get(page_id)
if page is None:
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
return page
Inlining keeps the dashboard to one round trip; the fetch layer caches
per widget (keyed on ``updated_at``) so a reload is a dict lookup.
"""
out = []
for widget in widgets.list():
entry = widget.model_dump()
if widget.kind != "notice":
entry["data"] = await widget_data.data(widget)
out.append(entry)
return out
@router.get("/widgets/{widget_id}")
def get_widget(widget_id: str) -> Widget:
widget = widgets.get(widget_id)
if widget is None:
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
return widget
@router.post(
"/pages",
"/widgets",
status_code=201,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def create_page(payload: PageInput) -> Page:
return pages.create(payload)
def create_widget(payload: WidgetInput) -> Widget:
return widgets.create(payload)
@router.patch(
"/pages/{page_id}",
"/widgets/{widget_id}",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def update_page(page_id: str, payload: PagePatch) -> Page:
page = pages.update(page_id, payload)
if page is None:
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
return page
def update_widget(widget_id: str, payload: WidgetPatch) -> Widget:
try:
widget = widgets.update(widget_id, payload)
except ValueError as exc:
# The store re-validates the merged record, so a patch that leaves
# an inconsistent widget (kind=notice without a body) surfaces here
# as a bad request rather than a crash.
raise HTTPException(status_code=422, detail=str(exc)) from exc
if widget is None:
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
return widget
@router.post(
"/widgets/{widget_id}/refresh",
dependencies=[Depends(require_admin)] if require_admin else [],
)
async def refresh_widget(widget_id: str) -> dict:
"""Force a fetch - the UI's Test button, so a config typo shows up."""
widget = widgets.get(widget_id)
if widget is None:
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
return await widget_data.data(widget, refresh=True)
@router.get("/widgets/{widget_id}/image")
async def widget_image(widget_id: str, u: str) -> Response:
"""Relay one artwork URL from a widget's own upstream to the browser."""
widget = widgets.get(widget_id)
if widget is None:
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
if not widget.base_url:
raise HTTPException(status_code=404, detail=f"widget {widget_id} has no upstream")
body, media_type = await widget_data.image(widget, u)
return Response(
content=body,
media_type=media_type,
headers={"cache-control": f"private, max-age={config.widget_cache_ttl}"},
)
@router.delete(
"/pages/{page_id}",
"/widgets/{widget_id}",
status_code=204,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def delete_page(page_id: str) -> None:
if not pages.delete(page_id):
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
def delete_widget(widget_id: str) -> None:
if not widgets.delete(widget_id):
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
if users is not None:
@router.get("/me")
+10 -5
View File
@@ -25,7 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .api import create_api_router
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
from .config import Config
from .pages import PageStore
from .widgets import WidgetData, WidgetStore
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
@@ -38,7 +38,8 @@ def create_app(config: Config | None = None) -> FastAPI:
config = config or Config.from_env()
store = ServiceStore(config.services_file)
pages = PageStore(config.pages_file)
widgets = WidgetStore(config.widgets_file, legacy=config.data_dir / "pages.json")
widget_data = WidgetData(widgets, config)
proxy = Proxy(store, config)
users = UserStore(config.users_file) if config.auth_enabled else None
oauth = create_oauth(config) if config.auth_enabled else None
@@ -52,6 +53,7 @@ def create_app(config: Config | None = None) -> FastAPI:
)
yield
await proxy.close()
await widget_data.close()
app = FastAPI(
title="UwuDash MK3",
@@ -73,7 +75,8 @@ def create_app(config: Config | None = None) -> FastAPI:
app.state.config = config
app.state.store = store
app.state.pages = pages
app.state.widgets = widgets
app.state.widget_data = widget_data
app.state.proxy = proxy
app.state.users = users
app.state.oauth = oauth
@@ -88,7 +91,9 @@ def create_app(config: Config | None = None) -> FastAPI:
require_login = require_admin = None
app.include_router(
create_api_router(store, pages, proxy, config, users, require_login, require_admin)
create_api_router(
store, widgets, widget_data, proxy, config, users, require_login, require_admin
)
)
@app.get("/")
@@ -101,7 +106,7 @@ def create_app(config: Config | None = None) -> FastAPI:
@app.get("/health")
def health() -> dict:
return {"status": "ok", "services": len(store.list())}
return {"status": "ok", "services": len(store.list()), "widgets": len(widgets.list())}
auth_gate = [Depends(require_login)] if require_login else []
+10 -6
View File
@@ -4,7 +4,8 @@ Everything is driven by environment variables (with sane defaults) so the
dashboard can be deployed without editing code:
UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE,
UWUDASH_PAGES_FILE, UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS
UWUDASH_WIDGETS_FILE, UWUDASH_WIDGET_CACHE_TTL, UWUDASH_PROXY_TIMEOUT,
UWUDASH_STRIP_FRAME_OPTIONS
Authentication is opt-in: the OIDC routes and the session middleware only take
effect when an issuer, client id and client secret are all present.
@@ -29,6 +30,7 @@ DEFAULT_PROXY_TIMEOUT = 30.0
DEFAULT_OIDC_SCOPE = "openid email profile"
DEFAULT_SESSION_TTL = 3600
DEFAULT_ADMIN_VALUES = ("admin",)
DEFAULT_WIDGET_CACHE_TTL = 300
def _int(name: str, default: int) -> int:
@@ -87,13 +89,14 @@ class Config:
session_ttl_seconds: int = DEFAULT_SESSION_TTL
users_file: Optional[Path] = None
public_url: Optional[str] = None
pages_file: Optional[Path] = None
widgets_file: Optional[Path] = None
widget_cache_ttl: int = DEFAULT_WIDGET_CACHE_TTL
def __post_init__(self) -> None:
if self.users_file is None:
object.__setattr__(self, "users_file", self.data_dir / "users.json")
if self.pages_file is None:
object.__setattr__(self, "pages_file", self.data_dir / "pages.json")
if self.widgets_file is None:
object.__setattr__(self, "widgets_file", self.data_dir / "widgets.json")
if self.oidc_issuer:
object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/"))
@@ -126,7 +129,8 @@ class Config:
os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json")
),
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
pages_file=Path(
os.environ.get("UWUDASH_PAGES_FILE", data_dir / "pages.json")
widgets_file=Path(
os.environ.get("UWUDASH_WIDGETS_FILE", data_dir / "widgets.json")
),
widget_cache_ttl=_int("UWUDASH_WIDGET_CACHE_TTL", DEFAULT_WIDGET_CACHE_TTL),
)
+4 -4
View File
@@ -18,7 +18,7 @@ def parse_args(argv=None) -> argparse.Namespace:
parser.add_argument("--port", type=int, help="bind port (default 8000)")
parser.add_argument("--data-dir", help="directory holding services.json")
parser.add_argument("--services-file", help="path to the services JSON file")
parser.add_argument("--pages-file", help="path to the pages JSON file")
parser.add_argument("--widgets-file", help="path to the widgets JSON file")
parser.add_argument("--reload", action="store_true", help="restart on source changes")
parser.add_argument(
"--oidc-issuer",
@@ -43,10 +43,10 @@ def build_config(args: argparse.Namespace) -> Config:
config = replace(config, services_file=Path(args.services_file))
elif args.data_dir:
config = replace(config, services_file=Path(args.data_dir) / "services.json")
if args.pages_file:
config = replace(config, pages_file=Path(args.pages_file))
if args.widgets_file:
config = replace(config, widgets_file=Path(args.widgets_file))
elif args.data_dir:
config = replace(config, pages_file=Path(args.data_dir) / "pages.json")
config = replace(config, widgets_file=Path(args.data_dir) / "widgets.json")
if args.oidc_issuer:
config = replace(config, oidc_issuer=args.oidc_issuer)
if args.public_url:
+98 -8
View File
@@ -6,7 +6,7 @@ import re
from typing import Dict, Optional
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
ALLOWED_SCHEMES = {"http", "https"}
OPEN_MODES = ("iframe", "newtab")
@@ -119,28 +119,118 @@ class ServicePatch(BaseModel):
return value
class PageInput(BaseModel):
"""Payload accepted by the page create/update endpoints."""
WIDGET_KINDS = ("notice", "calendar", "recent")
DATE_TYPES = ("auto", "physical", "digital", "cinemas", "release")
class WidgetInput(BaseModel):
"""Payload accepted by the widget create/update endpoints.
``notice`` widgets are plain text and carry no upstream. ``calendar`` and
``recent`` widgets name their own source (``base_url`` plus whatever
headers that source needs) - they are deliberately **not** tied to the
proxied services, so a widget can exist without ever being framed.
"""
name: str = Field(min_length=1, max_length=120)
icon: str = Field(default="", max_length=8)
body: str = Field(min_length=1, max_length=5000)
kind: str = "notice"
body: Optional[str] = Field(default=None, min_length=1, max_length=5000)
base_url: Optional[str] = Field(default=None, max_length=500)
headers: Dict[str, str] = Field(default_factory=dict)
api_prefix: str = Field(default="/api/v3", max_length=32)
days: int = Field(default=14, ge=1, le=90)
date_type: str = "auto"
limit: int = Field(default=12, ge=1, le=50)
@field_validator("kind")
def _validate_kind(cls, value: str) -> str:
if value not in WIDGET_KINDS:
raise ValueError(f"kind must be one of {WIDGET_KINDS}")
return value
@field_validator("base_url")
def _validate_base_url(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
parts = urlsplit(value.strip())
if parts.scheme not in ALLOWED_SCHEMES:
raise ValueError("base_url must start with http:// or https://")
if not parts.netloc:
raise ValueError("base_url must include a host")
return value.strip()
@field_validator("headers")
def _validate_headers(cls, value: Dict[str, str]) -> Dict[str, str]:
for key in value:
if not key or any(c in key for c in "\r\n"):
raise ValueError("header names must not contain newlines")
return value
@field_validator("date_type")
def _validate_date_type(cls, value: str) -> str:
if value not in DATE_TYPES:
raise ValueError(f"date_type must be one of {DATE_TYPES}")
return value
@model_validator(mode="after")
def _validate_kind_fields(self) -> "WidgetInput":
if self.kind == "notice":
if not self.body:
raise ValueError("notice widgets need a body")
elif not self.base_url:
raise ValueError(f"{self.kind} widgets need a base_url")
return self
class Page(PageInput):
"""A stored notice page."""
class Widget(WidgetInput):
"""A stored widget."""
id: str
created_at: str
updated_at: str
class PagePatch(BaseModel):
"""Partial page update; unset fields are left alone."""
class WidgetPatch(BaseModel):
"""Partial widget update; unset fields are left alone."""
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
icon: Optional[str] = Field(default=None, max_length=8)
kind: Optional[str] = None
body: Optional[str] = Field(default=None, min_length=1, max_length=5000)
base_url: Optional[str] = Field(default=None, max_length=500)
headers: Optional[Dict[str, str]] = None
api_prefix: Optional[str] = Field(default=None, max_length=32)
days: Optional[int] = Field(default=None, ge=1, le=90)
date_type: Optional[str] = None
limit: Optional[int] = Field(default=None, ge=1, le=50)
@field_validator("kind")
def _validate_kind(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
if value not in WIDGET_KINDS:
raise ValueError(f"kind must be one of {WIDGET_KINDS}")
return value
@field_validator("base_url")
def _validate_base_url(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
parts = urlsplit(value.strip())
if parts.scheme not in ALLOWED_SCHEMES:
raise ValueError("base_url must start with http:// or https://")
if not parts.netloc:
raise ValueError("base_url must include a host")
return value.strip()
@field_validator("date_type")
def _validate_date_type(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
if value not in DATE_TYPES:
raise ValueError(f"date_type must be one of {DATE_TYPES}")
return value
ROLES = ("admin", "viewer")
-105
View File
@@ -1,105 +0,0 @@
"""Persistence for notice pages.
The store is a plain JSON file so it is easy to inspect, back up, or seed from
a container. Writes are atomic (temp file + ``os.replace``) and serialised with
a lock so concurrent API requests cannot interleave.
"""
from __future__ import annotations
import json
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from .models import Page, PageInput, PagePatch, slugify
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class PageStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write([])
# ------------------------------------------------------------------ read
def _read(self) -> List[Page]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
except json.JSONDecodeError as exc:
raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc
items = raw.get("pages", []) if isinstance(raw, dict) else raw
return [Page.model_validate(item) for item in items]
def _write(self, pages: List[Page]) -> None:
payload = {
"version": 1,
"pages": [p.model_dump() for p in pages],
}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.path)
def list(self) -> List[Page]:
with self._lock:
return self._read()
def get(self, page_id: str) -> Optional[Page]:
with self._lock:
for page in self._read():
if page.id == page_id:
return page
return None
# ----------------------------------------------------------------- write
def create(self, data: PageInput) -> Page:
with self._lock:
pages = self._read()
base = slugify(data.name)
candidate = base
suffix = 1
existing = {p.id for p in pages}
while candidate in existing:
suffix += 1
candidate = f"{base}-{suffix}"
now = _now()
page = Page(
**data.model_dump(), id=candidate, created_at=now, updated_at=now
)
pages.append(page)
self._write(pages)
return page
def update(self, page_id: str, patch: PagePatch) -> Optional[Page]:
with self._lock:
pages = self._read()
for index, page in enumerate(pages):
if page.id != page_id:
continue
updates = {k: v for k, v in patch.model_dump().items() if v is not None}
if not updates:
return page
updated = page.model_copy(update=updates)
updated.updated_at = _now()
pages[index] = updated
self._write(pages)
return updated
return None
def delete(self, page_id: str) -> bool:
with self._lock:
pages = self._read()
remaining = [p for p in pages if p.id != page_id]
if len(remaining) == len(pages):
return False
self._write(remaining)
return True
+249 -87
View File
@@ -10,13 +10,13 @@ const el = {
viewerFrame: document.getElementById("viewer-frame"),
emptyState: document.getElementById("empty-state"),
emptyText: document.getElementById("empty-text"),
pagePanel: document.getElementById("page-panel"),
widgetPanel: document.getElementById("widget-panel"),
reloadBtn: document.getElementById("reload-btn"),
settings: document.getElementById("settings"),
settingsToggle: document.getElementById("settings-toggle"),
settingsClose: document.getElementById("settings-close"),
serviceTable: document.getElementById("service-table"),
pageTable: document.getElementById("page-table"),
widgetTable: document.getElementById("widget-table"),
form: document.getElementById("service-form"),
formTitle: document.getElementById("form-title"),
name: document.getElementById("f-name"),
@@ -27,12 +27,19 @@ const el = {
headers: document.getElementById("f-headers"),
enabled: document.getElementById("f-enabled"),
formCancel: document.getElementById("form-cancel"),
noticeForm: document.getElementById("notice-form"),
noticeFormTitle: document.getElementById("notice-form-title"),
noticeName: document.getElementById("n-name"),
noticeIcon: document.getElementById("n-icon"),
noticeBody: document.getElementById("n-body"),
noticeCancel: document.getElementById("notice-cancel"),
widgetForm: document.getElementById("widget-form"),
widgetFormTitle: document.getElementById("widget-form-title"),
wName: document.getElementById("w-name"),
wIcon: document.getElementById("w-icon"),
wKind: document.getElementById("w-kind"),
wBody: document.getElementById("w-body"),
wBase: document.getElementById("w-base"),
wHeaders: document.getElementById("w-headers"),
wPrefix: document.getElementById("w-prefix"),
wDays: document.getElementById("w-days"),
wDate: document.getElementById("w-date"),
wLimit: document.getElementById("w-limit"),
widgetCancel: document.getElementById("widget-cancel"),
toast: document.getElementById("toast"),
userChip: document.getElementById("user-chip"),
userName: document.getElementById("user-name"),
@@ -40,16 +47,16 @@ const el = {
userTable: document.getElementById("user-table"),
};
// Sentinel for state.selected while the Home notice board is open; slugify()
// only ever emits [a-z0-9-], so no page id can collide with it.
// Sentinel for state.selected while the Home widget board is open; slugify()
// only ever emits [a-z0-9-], so no widget id can collide with it.
const HOME = "@home";
const state = {
services: [],
pages: [],
widgets: [],
selected: HOME,
editing: null,
editingPage: null,
editingWidget: null,
user: null,
};
@@ -103,8 +110,8 @@ function textToHeaders(text) {
/* ---------------------------------------------------------------- rendering */
function renderSidebar() {
el.serviceList.innerHTML = "";
if (!state.services.length && !state.pages.length) {
el.serviceList.innerHTML = '<p class="status">No services configured yet.</p>';
if (!state.services.length && !state.widgets.length) {
el.serviceList.innerHTML = '<p class="status">No services or widgets yet.</p>';
return;
}
for (const service of state.services) {
@@ -121,19 +128,33 @@ function renderSidebar() {
button.addEventListener("click", () => openService(service));
el.serviceList.appendChild(button);
}
for (const page of state.pages) {
for (const widget of state.widgets) {
const button = document.createElement("button");
button.className = "page-btn" + (state.selected === page.id ? " active" : "");
button.title = page.name;
button.className = "widget-btn" + (state.selected === widget.id ? " active" : "");
button.title = widget.name;
button.innerHTML =
`<span class="icon">${esc(page.icon || "📌")}</span>` +
`<span><span class="label">${esc(page.name)}</span>` +
`<span class="sub">${esc(page.body.slice(0, 60))}</span></span>`;
button.addEventListener("click", () => openPage(page));
`<span class="icon">${esc(widget.icon || WIDGET_ICONS[widget.kind])}</span>` +
`<span><span class="label">${esc(widget.name)}</span>` +
`<span class="sub">${esc(widgetSub(widget))}</span></span>`;
button.addEventListener("click", () => openWidget(widget));
el.serviceList.appendChild(button);
}
}
const WIDGET_ICONS = { notice: "📌", calendar: "📅", recent: "🆕" };
function widgetIcon(widget) {
return esc(widget.icon || WIDGET_ICONS[widget.kind]);
}
function widgetSub(widget) {
if (widget.kind === "notice") return (widget.body || "").slice(0, 60);
const data = widget.data || {};
if (data.error) return `⚠ ${data.error.error}`;
const host = (widget.base_url || "").split("://", 1)[1];
return `${(data.items || []).length} items · ${host}`;
}
function renderTable() {
el.serviceTable.innerHTML = "";
if (!state.services.length) {
@@ -155,51 +176,152 @@ function renderTable() {
}
}
function renderPageTable() {
el.pageTable.innerHTML = "";
if (!state.pages.length) {
el.pageTable.innerHTML = '<p class="status">No notices yet - pin one above.</p>';
function renderWidgetTable() {
el.widgetTable.innerHTML = "";
if (!state.widgets.length) {
el.widgetTable.innerHTML = '<p class="status">No widgets yet - add one on the left.</p>';
return;
}
for (const page of state.pages) {
for (const widget of state.widgets) {
const live = widget.kind !== "notice";
const data = widget.data || {};
const detail = live
? `${widget.base_url || ""}${widget.kind === "calendar" ? ` ${widget.api_prefix}/calendar` : " /library/recentlyAdded"}`
: (widget.body || "").slice(0, 80);
const status = live
? data.error
? `unreachable: ${data.error.error}`
: `${(data.items || []).length} items`
: "pinned";
const row = document.createElement("div");
row.className = "page-row";
row.className = "widget-row";
row.innerHTML =
`<div class="meta"><strong>${esc(page.name)}</strong>` +
`<span>${esc(page.body.slice(0, 80))}</span></div>` +
`<button class="btn ghost" data-action="edit" data-id="${esc(page.id)}">Edit</button>` +
`<button class="btn ghost" data-action="delete" data-id="${esc(page.id)}">Delete</button>`;
el.pageTable.appendChild(row);
`<div class="meta"><strong>${esc(widget.name)}</strong>` +
`<code>${esc(detail)}</code>` +
`<span>${esc(widget.kind)}</span></div>` +
`<span class="status" data-status="${esc(widget.id)}">${esc(status)}</span>` +
(live
? `<button class="btn ghost" data-action="test" data-id="${esc(widget.id)}">Test</button>`
: "") +
`<button class="btn ghost" data-action="edit" data-id="${esc(widget.id)}">Edit</button>` +
`<button class="btn ghost" data-action="delete" data-id="${esc(widget.id)}">Delete</button>`;
el.widgetTable.appendChild(row);
}
}
function noticeCard(page) {
function noticeCard(widget) {
return (
`<div class="notice-card">` +
`<span class="icon">${esc(page.icon || "📌")}</span>` +
`<div class="notice-title">${esc(page.name)}</div>` +
`<div class="notice-body">${esc(page.body)}</div>` +
`<div class="widget-card notice">` +
`<span class="icon">${widgetIcon(widget)}</span>` +
`<div class="widget-title">${esc(widget.name)}</div>` +
`<div class="widget-body">${esc(widget.body || "")}</div>` +
`</div>`
);
}
function itemRow(item) {
const thumb = item.thumb
? `<img class="widget-thumb" src="${esc(item.thumb)}" alt="${esc(item.label)}" loading="lazy" />`
: "";
return (
`<div class="widget-item">` +
thumb +
`<span class="widget-label">${esc(item.label)}</span>` +
(item.sub ? `<span class="widget-sub">${esc(item.sub)}</span>` : "") +
`<span class="widget-src">${esc(item.source || "")}</span>` +
`</div>`
);
}
function groupByDate(items) {
const days = [];
for (const item of items) {
const last = days[days.length - 1];
if (last && last.date === item.date) last.items.push(item);
else days.push({ date: item.date, items: [item] });
}
return days;
}
function dayLabel(date) {
const parts = (date || "").split("-").map(Number);
if (parts.length !== 3) return date || "";
const day = new Date(parts[0], parts[1] - 1, parts[2]);
const now = new Date();
// Compare whole days: `new Date()` carries the current time, so subtracting
// it directly rounds 2026-11-09 to "tomorrow" on a late-in-the-day render.
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diff = (day - today) / 86400000;
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
const options = { month: "short", day: "numeric" };
if (parts[0] !== today.getFullYear()) options.year = "numeric";
return day.toLocaleDateString(undefined, options);
}
function clockOf(iso) {
if (!iso) return "";
return new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
// One card per live widget group: every calendar widget merges into a single
// sorted timeline, each "recently added" widget gets its own card.
function liveCard(widgets, title) {
const items = widgets.flatMap((widget) => (widget.data || {}).items || []);
const days = groupByDate(items);
const notes = widgets
.map((widget) => {
const data = widget.data || {};
return data.error ? `${widget.name}: ${data.error.error}` : null;
})
.filter(Boolean);
const refreshed = widgets.map((widget) => (widget.data || {}).fetched_at || "").filter(Boolean);
const stamp = refreshed.length ? `refreshed ${clockOf(refreshed.sort()[0])}` : "";
const meta = [...notes, stamp].filter(Boolean).join(" · ");
return (
`<div class="widget-card live">` +
`<span class="icon">${widgetIcon(widgets[0])}</span>` +
`<div class="widget-title">${esc(title)}</div>` +
`<div class="widget-meta">${esc(meta)}</div>` +
days.map(
(day) =>
`<div class="widget-day">` +
`<div class="widget-day-head">${esc(dayLabel(day.date))}</div>` +
day.items.map(itemRow).join("") +
`</div>`
).join("") +
(days.length ? "" : `<div class="widget-empty">${esc(meta || "nothing in the window")}</div>`) +
`</div>`
);
}
function widgetCard(widget) {
return widget.kind === "notice" ? noticeCard(widget) : liveCard([widget], widget.name);
}
function renderHome() {
const calendars = state.widgets.filter((widget) => widget.kind === "calendar");
const recents = state.widgets.filter((widget) => widget.kind === "recent");
const notices = state.widgets.filter((widget) => widget.kind === "notice");
const cards = [];
if (calendars.length) cards.push(liveCard(calendars, "Upcoming"));
for (const widget of recents) cards.push(liveCard([widget], widget.name));
for (const widget of notices) cards.push(noticeCard(widget));
const banner =
`<div class="home-banner">` +
`<div class="home-title">Home (uwu)</div>` +
`<div class="home-sub">the notice board — pin anything the crew should know</div>` +
`<div class="home-sub">widgets — release calendars, what just landed, and anything pinned</div>` +
`</div>`;
const cards = state.pages.slice().reverse().map(noticeCard).join("");
el.pagePanel.innerHTML =
banner + (cards || '<p class="home-empty">no notices yet ( 🥯🥯 ) — pin one from Settings!</p>');
el.widgetPanel.innerHTML =
banner + (cards.join("") || '<p class="home-empty">no widgets yet ( 🥯🥯 ) — add one from Settings!</p>');
}
function findService(id) {
return state.services.find((s) => s.id === id);
}
function findPage(id) {
return state.pages.find((p) => p.id === id);
function findWidget(id) {
return state.widgets.find((w) => w.id === id);
}
/* ---------------------------------------------------------------- auth */
@@ -267,7 +389,7 @@ function openService(service) {
if (service.open_in === "newtab") {
window.open(service.proxy_path, "_blank");
el.settings.hidden = true;
el.pagePanel.hidden = true;
el.widgetPanel.hidden = true;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
@@ -279,7 +401,7 @@ function openService(service) {
el.settings.hidden = true;
el.viewer.hidden = false;
el.emptyState.hidden = true;
el.pagePanel.hidden = true;
el.widgetPanel.hidden = true;
el.viewerFrame.hidden = false;
el.reloadBtn.hidden = false;
el.viewerTitle.textContent = service.name;
@@ -287,27 +409,27 @@ function openService(service) {
el.viewerFrame.src = service.proxy_path;
}
function openPage(page) {
state.selected = page.id;
function openWidget(widget) {
state.selected = widget.id;
renderSidebar();
el.settings.hidden = true;
el.viewer.hidden = false;
el.emptyState.hidden = true;
el.pagePanel.hidden = false;
el.widgetPanel.hidden = false;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
el.viewerTitle.textContent = page.name;
el.viewerUrl.textContent = "";
el.pagePanel.innerHTML = noticeCard(page);
el.viewerTitle.textContent = widget.name;
el.viewerUrl.textContent = widget.base_url || "";
el.widgetPanel.innerHTML = widgetCard(widget);
}
// Paint the Home board into the viewer area without touching which section is
// active, so a delete performed from Settings leaves Home ready for settingsClose.
function showHome() {
el.emptyState.hidden = true;
el.pagePanel.hidden = false;
el.widgetPanel.hidden = false;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
@@ -329,7 +451,7 @@ function showSettings() {
el.viewer.hidden = true;
el.viewerFrame.src = "";
renderTable();
renderPageTable();
renderWidgetTable();
renderUsers();
}
@@ -357,28 +479,42 @@ function fillForm(service) {
el.enabled.checked = service.enabled;
}
function resetNoticeForm() {
state.editingPage = null;
el.noticeFormTitle.textContent = "Add notice";
el.noticeName.value = "";
el.noticeIcon.value = "";
el.noticeBody.value = "";
function resetWidgetForm() {
state.editingWidget = null;
el.widgetFormTitle.textContent = "Add widget";
el.wName.value = "";
el.wIcon.value = "";
el.wKind.value = "notice";
el.wBody.value = "";
el.wBase.value = "";
el.wHeaders.value = "";
el.wPrefix.value = "/api/v3";
el.wDays.value = "14";
el.wDate.value = "auto";
el.wLimit.value = "12";
}
function fillNoticeForm(page) {
state.editingPage = page.id;
el.noticeFormTitle.textContent = `Edit ${page.name}`;
el.noticeName.value = page.name;
el.noticeIcon.value = page.icon || "";
el.noticeBody.value = page.body;
function fillWidgetForm(widget) {
state.editingWidget = widget.id;
el.widgetFormTitle.textContent = `Edit ${widget.name}`;
el.wName.value = widget.name;
el.wIcon.value = widget.icon || "";
el.wKind.value = widget.kind;
el.wBody.value = widget.body || "";
el.wBase.value = widget.base_url || "";
el.wHeaders.value = headersToText(widget.headers);
el.wPrefix.value = widget.api_prefix;
el.wDays.value = String(widget.days);
el.wDate.value = widget.date_type;
el.wLimit.value = String(widget.limit);
}
async function refresh() {
state.services = await api("/api/services");
state.pages = await api("/api/pages");
state.widgets = await api("/api/widgets");
renderSidebar();
renderTable();
renderPageTable();
renderWidgetTable();
}
async function saveService(event) {
@@ -404,19 +540,26 @@ async function saveService(event) {
}
}
async function saveNotice(event) {
async function saveWidget(event) {
event.preventDefault();
const payload = {
name: el.noticeName.value.trim(),
icon: el.noticeIcon.value.trim(),
body: el.noticeBody.value.trim(),
name: el.wName.value.trim(),
icon: el.wIcon.value.trim(),
kind: el.wKind.value,
body: el.wBody.value.trim() || null,
base_url: el.wBase.value.trim() || null,
headers: textToHeaders(el.wHeaders.value),
api_prefix: el.wPrefix.value.trim() || "/api/v3",
days: Number(el.wDays.value) || 14,
date_type: el.wDate.value,
limit: Number(el.wLimit.value) || 12,
};
try {
const saved = state.editingPage
? await api(`/api/pages/${state.editingPage}`, { method: "PATCH", body: JSON.stringify(payload) })
: await api("/api/pages", { method: "POST", body: JSON.stringify(payload) });
toast(state.editingPage ? `Updated ${saved.name}` : `Added ${saved.name}`);
resetNoticeForm();
const saved = state.editingWidget
? await api(`/api/widgets/${state.editingWidget}`, { method: "PATCH", body: JSON.stringify(payload) })
: await api("/api/widgets", { method: "POST", body: JSON.stringify(payload) });
toast(state.editingWidget ? `Updated ${saved.name}` : `Added ${saved.name}`);
resetWidgetForm();
await refresh();
} catch (error) {
toast(error.message, "error");
@@ -438,14 +581,14 @@ async function deleteService(id) {
}
}
async function deletePage(id) {
const page = findPage(id);
if (!page || !confirm(`Delete ${page.name}?`)) return;
async function deleteWidget(id) {
const widget = findWidget(id);
if (!widget || !confirm(`Delete ${widget.name}?`)) return;
try {
await api(`/api/pages/${id}`, { method: "DELETE" });
await api(`/api/widgets/${id}`, { method: "DELETE" });
const wasViewing = state.selected === id;
if (wasViewing) state.selected = HOME;
toast(`Deleted ${page.name}`);
toast(`Deleted ${widget.name}`);
await refresh();
if (wasViewing) showHome();
} catch (error) {
@@ -467,6 +610,22 @@ async function testService(id, statusEl) {
}
}
// Bypasses the cache on purpose: a config typo should show up the moment you
// save it, not whenever the TTL happens to expire.
async function testWidget(id, statusEl) {
statusEl.textContent = "fetching...";
try {
const data = await api(`/api/widgets/${id}/refresh`, { method: "POST" });
statusEl.textContent = data.error
? `unreachable: ${data.error.error}`
: `${data.items.length} items`;
statusEl.className = `status ${data.error ? "bad" : "ok"}`;
} catch (error) {
statusEl.textContent = error.message;
statusEl.className = "status bad";
}
}
/* ---------------------------------------------------------------- wiring */
el.serviceTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
@@ -483,22 +642,25 @@ el.serviceTable.addEventListener("click", (event) => {
}
});
el.pageTable.addEventListener("click", (event) => {
el.widgetTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const id = button.dataset.id;
if (button.dataset.action === "edit") {
const page = findPage(id);
if (page) fillNoticeForm(page);
const widget = findWidget(id);
if (widget) fillWidgetForm(widget);
} else if (button.dataset.action === "delete") {
deletePage(id);
deleteWidget(id);
} else if (button.dataset.action === "test") {
const statusEl = el.widgetTable.querySelector(`[data-status="${id}"]`);
testWidget(id, statusEl);
}
});
el.form.addEventListener("submit", saveService);
el.formCancel.addEventListener("click", resetForm);
el.noticeForm.addEventListener("submit", saveNotice);
el.noticeCancel.addEventListener("click", resetNoticeForm);
el.widgetForm.addEventListener("submit", saveWidget);
el.widgetCancel.addEventListener("click", resetWidgetForm);
el.homeBtn.addEventListener("click", openHome);
el.settingsToggle.addEventListener("click", showSettings);
el.settingsClose.addEventListener("click", () => {
+38 -19
View File
@@ -232,9 +232,9 @@ body {
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.btn:hover { filter: brightness(1.1); }
/* ------------------------------------------------------------------ pages */
/* ---------------------------------------------------------------- widgets */
.home-btn,
.page-btn {
.widget-btn {
display: flex;
align-items: center;
gap: 10px;
@@ -252,16 +252,16 @@ body {
border-bottom: 3px solid var(--accent);
}
.home-btn:hover { filter: brightness(1.1); }
.page-btn { border-left: 3px solid var(--accent); }
.page-btn:hover { border-color: var(--accent); }
.page-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
.widget-btn { border-left: 3px solid var(--accent); }
.widget-btn:hover { border-color: var(--accent); }
.widget-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
.home-btn .icon,
.page-btn .icon { font-size: 16px; }
.widget-btn .icon { font-size: 16px; }
.home-btn .label,
.page-btn .label { display: block; font-weight: 600; }
.page-btn .sub { display: block; font-size: 11px; color: var(--muted); }
.widget-btn .label { display: block; font-weight: 600; }
.widget-btn .sub { display: block; font-size: 11px; color: var(--muted); }
.page-panel { flex: 1; overflow: auto; padding: 24px; }
.widget-panel { flex: 1; overflow: auto; padding: 24px; }
.home-banner {
display: flex;
@@ -275,23 +275,41 @@ body {
.home-title { font-size: 22px; font-weight: 800; }
.home-sub { color: var(--muted); font-size: 13px; }
.notice-card {
.widget-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
}
.notice-card .icon { font-size: 16px; }
.notice-card .notice-title { font-weight: 700; }
.notice-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
.widget-card .icon { font-size: 16px; }
.widget-card .widget-title { font-weight: 700; }
.widget-card.notice .widget-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
/* live widgets: one merged timeline for calendars, own card for recents */
.widget-card.live { border-left: 3px solid var(--accent); }
.widget-card.live .widget-meta { color: var(--muted); font-size: 12px; }
.widget-card.live .widget-day { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.widget-card.live .widget-day-head { font-size: 12px; font-weight: 700; color: var(--muted); }
.widget-card.live .widget-item { display: flex; align-items: center; gap: 10px; }
.widget-card.live .widget-thumb {
width: 34px;
height: 50px;
object-fit: cover;
border-radius: 4px;
margin-right: 8px;
}
.widget-card.live .widget-label { font-weight: 600; font-size: 14px; }
.widget-card.live .widget-sub { color: var(--muted); font-size: 12px; }
.widget-card.live .widget-src { font-size: 11px; color: var(--accent); }
.widget-card.live .widget-empty { color: var(--muted); font-size: 13px; margin-top: 8px; }
.home-empty { color: var(--muted); font-size: 14px; }
.page-table { display: flex; flex-direction: column; gap: 10px; }
.page-row {
.widget-table { display: flex; flex-direction: column; gap: 10px; }
.widget-row {
display: grid;
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto auto; /* meta + status + test/edit/delete */
gap: 12px;
align-items: center;
background: var(--panel);
@@ -299,9 +317,10 @@ body {
border-radius: 10px;
padding: 12px 14px;
}
.page-row .meta { min-width: 0; }
.page-row .meta strong { display: block; }
.page-row .meta span { color: var(--muted); font-size: 12px; display: block; }
.widget-row .meta { min-width: 0; }
.widget-row .meta strong { display: block; }
.widget-row .meta code { color: var(--muted); font-size: 12px; display: block; }
.widget-row .meta span { color: var(--muted); font-size: 11px; display: block; }
/* ----------------------------------------------------------------- toast */
.toast {
+47 -9
View File
@@ -38,7 +38,7 @@
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
</header>
<iframe id="viewer-frame" title="Service view" hidden></iframe>
<div class="page-panel" id="page-panel" hidden></div>
<div class="widget-panel" id="widget-panel" hidden></div>
<div class="empty" id="empty-state" hidden>
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
</div>
@@ -89,28 +89,66 @@
</div>
</form>
<form id="notice-form" class="card form" autocomplete="off">
<h2 id="notice-form-title">Add notice</h2>
<form id="widget-form" class="card form" autocomplete="off">
<h2 id="widget-form-title">Add widget</h2>
<label>
Name
<input id="n-name" required maxlength="120" placeholder="Server status" />
<input id="w-name" required maxlength="120" placeholder="Upcoming" />
</label>
<label>
Icon
<input id="n-icon" maxlength="8" placeholder="emoji or initials" />
<input id="w-icon" maxlength="8" placeholder="emoji or initials" />
</label>
<label>
Body
<textarea id="n-body" required rows="6" placeholder="Anything the crew should know"></textarea>
Kind
<select id="w-kind">
<option value="notice" selected>notice</option>
<option value="calendar">calendar</option>
<option value="recent">recently added</option>
</select>
</label>
<label>
Body (notices)
<textarea id="w-body" rows="5" placeholder="Anything the crew should know"></textarea>
</label>
<label>
Source URL (live widgets)
<input id="w-base" placeholder="http://10.6.9.204:8989" />
</label>
<label>
Headers (one per line, <code>Name: value</code>)
<textarea id="w-headers" rows="2" placeholder="X-Api-Key: ..."></textarea>
</label>
<label>
API prefix
<input id="w-prefix" maxlength="32" placeholder="/api/v3" />
</label>
<label>
Window (days)
<input id="w-days" type="number" min="1" max="90" placeholder="14" />
</label>
<label>
Release date
<select id="w-date">
<option value="auto" selected>auto</option>
<option value="physical">physical</option>
<option value="digital">digital</option>
<option value="cinemas">cinemas</option>
<option value="release">release</option>
</select>
</label>
<label>
Limit
<input id="w-limit" type="number" min="1" max="50" placeholder="12" />
</label>
<div class="form-actions">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn ghost" id="notice-cancel">Cancel</button>
<button type="button" class="btn ghost" id="widget-cancel">Cancel</button>
</div>
</form>
<div class="service-table" id="service-table"></div>
<div class="page-table" id="page-table"></div>
<div class="widget-table" id="widget-table"></div>
<div class="user-table" id="user-table" hidden></div>
</div>
</section>
+456
View File
@@ -0,0 +1,456 @@
"""Persistence for widgets, plus the server-side fetch layer behind them.
Two halves live here on purpose:
``WidgetStore`` is the JSON file (same atomic-write/lock pattern as
``store.py``).
``WidgetData`` fetches each live widget's upstream (Sonarr/Radarr calendar,
Plex recently-added), normalises it into one shape the UI can
render, and caches it.
Widgets are **not** wired to the proxied services: a live widget carries its own
``base_url`` and headers, so the upstream only ever has to be reachable from
this process - the browser only ever talks to ``/api/...``.
"""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import httpx
from urllib.parse import quote, urlsplit
from fastapi import HTTPException
from .config import Config
from .models import Widget, WidgetInput, WidgetPatch, slugify
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# Negative results are cached briefly so a broken upstream does not get
# re-fetched on every dashboard load, but a fixed one recovers quickly.
NEGATIVE_TTL = 60.0
MAX_IMAGE_BYTES = 2 * 1024 * 1024
MAX_IMAGE_ENTRIES = 64
# Radarr's MovieResource carries four possible release dates; "auto" walks this
# order so a movie with only a digital release still lands on the right day.
RADARR_DATES = {
"physical": ("physicalRelease",),
"digital": ("digitalRelease",),
"cinemas": ("inCinemas",),
"release": ("releaseDate",),
"auto": ("physicalRelease", "digitalRelease", "inCinemas", "releaseDate"),
}
RADARR_DATE_LABELS = {
"physicalRelease": "physical",
"digitalRelease": "digital",
"inCinemas": "in cinemas",
"releaseDate": "release",
}
class WidgetStore:
def __init__(self, path: Path, legacy: Optional[Path] = None) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write(self._migrate(legacy) if legacy else [])
# ------------------------------------------------------------------ read
def _read(self) -> List[Widget]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
except json.JSONDecodeError as exc:
raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc
items = raw.get("widgets", []) if isinstance(raw, dict) else raw
return [Widget.model_validate(item) for item in items]
def _write(self, widgets: List[Widget]) -> None:
payload = {
"version": 1,
"widgets": [w.model_dump() for w in widgets],
}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.path)
# ---------------------------------------------------------- legacy notices
def _migrate(self, legacy: Optional[Path]) -> List[Widget]:
"""Seed from the old ``pages.json`` so pinned notices survive renaming."""
try:
raw = json.loads(Path(legacy).read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return []
items = raw.get("pages", []) if isinstance(raw, dict) else raw
migrated = []
for item in items:
item.setdefault("kind", "notice")
try:
migrated.append(Widget.model_validate(item))
except ValueError:
continue
if migrated:
os.replace(Path(legacy), Path(legacy).with_suffix(".json.migrated"))
return migrated
def list(self) -> List[Widget]:
with self._lock:
return self._read()
def get(self, widget_id: str) -> Optional[Widget]:
with self._lock:
for widget in self._read():
if widget.id == widget_id:
return widget
return None
# ----------------------------------------------------------------- write
def create(self, data: WidgetInput) -> Widget:
with self._lock:
widgets = self._read()
base = slugify(data.name)
candidate = base
suffix = 1
existing = {w.id for w in widgets}
while candidate in existing:
suffix += 1
candidate = f"{base}-{suffix}"
now = _now()
widget = Widget(
**data.model_dump(), id=candidate, created_at=now, updated_at=now
)
widgets.append(widget)
self._write(widgets)
return widget
def update(self, widget_id: str, patch: WidgetPatch) -> Optional[Widget]:
with self._lock:
widgets = self._read()
for index, widget in enumerate(widgets):
if widget.id != widget_id:
continue
updates = {k: v for k, v in patch.model_dump().items() if v is not None}
if not updates:
return widget
# Re-validate rather than model_copy: a partial update could
# otherwise leave a calendar widget without a base_url.
merged = widget.model_dump()
merged.update(updates)
updated = Widget(**merged)
updated.updated_at = _now()
widgets[index] = updated
self._write(widgets)
return updated
return None
def delete(self, widget_id: str) -> bool:
with self._lock:
widgets = self._read()
remaining = [w for w in widgets if w.id != widget_id]
if len(remaining) == len(widgets):
return False
self._write(remaining)
return True
class WidgetData:
"""Fetch + normalise + cache the live widgets' upstreams."""
def __init__(self, store: WidgetStore, config: Config) -> None:
self.store = store
self.config = config
self.client = httpx.AsyncClient(
timeout=config.proxy_timeout,
follow_redirects=False,
headers={"user-agent": "uwudash-mk3/0.1"},
)
# key: "<widget-id>@<updated-at>" so editing a widget invalidates it.
self._cache: Dict[str, Tuple[float, dict]] = {}
self._images: Dict[str, Tuple[bytes, str]] = {}
async def close(self) -> None:
await self.client.aclose()
# --------------------------------------------------------------- helpers
@staticmethod
def _origin(url: str) -> str:
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}"
def _endpoint(self, widget: Widget) -> str:
base = widget.base_url.rstrip("/")
if widget.kind == "calendar":
return f"{base}{widget.api_prefix.rstrip('/')}/calendar"
return f"{base}/library/recentlyAdded"
def _params(self, widget: Widget) -> Dict[str, str]:
if widget.kind != "calendar":
return {}
today = datetime.now()
end = today + timedelta(days=widget.days)
return {"start": today.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}
def _headers(self, widget: Widget) -> Dict[str, str]:
headers = dict(widget.headers)
if widget.kind == "recent":
# Plex answers XML unless it is told otherwise.
headers.setdefault("Accept", "application/json")
return headers
def _relay(self, widget: Widget, url: str) -> str:
return f"/api/widgets/{widget.id}/image?u={quote(url, safe='')}"
# ---------------------------------------------------------- normalisation
@staticmethod
def _iso_date(value: object) -> Optional[str]:
if isinstance(value, (int, float)) or (isinstance(value, str) and value.isdigit()):
seconds = float(value)
# Plex's addedAt is epoch *milliseconds* (Sonarr/Radarr send ISO
# strings); anything past ~3160 years in seconds is a ms value.
if seconds >= 1e11:
seconds /= 1000.0
try:
return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d")
except (OverflowError, ValueError, OSError):
return None
if isinstance(value, str) and len(value) >= 10:
head = value[:10]
if head[4] == "-" and head[7] == "-":
return head
return None
@staticmethod
def _items(payload: object) -> list:
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("items", "events", "movies", "episodes"):
if isinstance(payload.get(key), list):
return payload[key]
return []
def _calendar_rows(self, widget: Widget, payload: object) -> List[dict]:
rows = []
for item in self._items(payload):
if not isinstance(item, dict):
continue
if "airDate" in item: # Sonarr EpisodeResource
date = self._iso_date(item.get("airDate"))
if not date:
continue
season, episode = item.get("seasonNumber"), item.get("episodeNumber")
bits = []
if season is not None and episode is not None:
bits.append(f"S{season} E{episode}")
if item.get("episodeTitle"):
bits.append(str(item["episodeTitle"]))
rows.append(
{
"date": date,
"label": str(item.get("seriesTitle") or item.get("title") or "?"),
"sub": " · ".join(bits),
"type": "episode",
"source": widget.name,
"thumb": None,
}
)
continue
# Radarr MovieResource: pick the release date the widget asked for.
field, date = None, None
for candidate in RADARR_DATES.get(widget.date_type, RADARR_DATES["auto"]):
date = self._iso_date(item.get(candidate))
if date:
field = candidate
break
if not date:
continue
year = item.get("year")
label = str(item.get("title") or "?")
if year:
label = f"{label} ({year})"
rows.append(
{
"date": date,
"label": label,
"sub": RADARR_DATE_LABELS.get(field, ""),
"type": "movie",
"source": widget.name,
"thumb": self._thumb(widget, self._radarr_image(item)),
}
)
rows.sort(key=lambda row: row["date"])
return rows
def _recent_rows(self, widget: Widget, payload: object) -> List[dict]:
rows = []
for item in self._items(payload):
if not isinstance(item, dict):
continue
date = self._iso_date(item.get("addedAt"))
if not date:
continue
bits = []
if item.get("type"):
bits.append(str(item["type"]))
if item.get("year"):
bits.append(str(item["year"]))
if item.get("parentTitle"):
index = f"{item.get('parentIndex', '')}x{item.get('index', '')}"
bits.append(f"{item['parentTitle']} {index}")
rows.append(
{
"date": date,
"label": str(item.get("title") or "?"),
"sub": " · ".join(bits),
"type": str(item.get("type") or "added"),
"source": widget.name,
"thumb": self._thumb(widget, item.get("thumb")),
}
)
rows.sort(key=lambda row: row["date"], reverse=True)
return rows
@staticmethod
def _radarr_image(item: dict) -> Optional[str]:
images = item.get("images")
if not isinstance(images, list):
return None
urls = [img.get("url") for img in images if isinstance(img, dict) and img.get("url")]
for img in images:
if isinstance(img, dict) and img.get("coverType") in ("Poster", "Main"):
return img["url"]
return urls[0] if urls else None
def _thumb(self, widget: Widget, url: Optional[str]) -> Optional[str]:
if not url or not isinstance(url, str):
return None
return self._relay(widget, url)
# ------------------------------------------------------------------ fetch
async def data(self, widget: Widget, refresh: bool = False) -> dict:
ttl = float(self.config.widget_cache_ttl)
key = f"{widget.id}@{widget.updated_at}"
now = time.monotonic()
if not refresh:
hit = self._cache.get(key)
if hit and now < hit[0]:
return hit[1]
for stale in [k for k, (expiry, _) in self._cache.items() if expiry <= now]:
del self._cache[stale]
payload = await self._fetch(widget)
expiry = now + (ttl if payload["error"] is None else min(ttl, NEGATIVE_TTL))
self._cache[key] = (expiry, payload)
return payload
async def _fetch(self, widget: Widget) -> dict:
fetched_at = _now()
ttl = float(self.config.widget_cache_ttl)
if not widget.base_url:
return {"fetched_at": fetched_at, "ttl_seconds": ttl, "error": None, "items": []}
try:
upstream = await self.client.request(
"GET",
self._endpoint(widget),
params=self._params(widget),
headers=self._headers(widget),
)
except httpx.HTTPError as exc:
return {
"fetched_at": fetched_at,
"ttl_seconds": ttl,
"error": {"reachable": False, "status": None, "error": str(exc)},
"items": [],
}
if upstream.status_code >= 300:
return {
"fetched_at": fetched_at,
"ttl_seconds": ttl,
"error": {
"reachable": True,
"status": upstream.status_code,
"error": f"upstream answered {upstream.status_code}",
},
"items": [],
}
try:
payload = upstream.json()
except ValueError:
return {
"fetched_at": fetched_at,
"ttl_seconds": ttl,
"error": {
"reachable": True,
"status": upstream.status_code,
"error": f"expected JSON, got {upstream.headers.get('content-type', '?')}",
},
"items": [],
}
rows = (
self._calendar_rows(widget, payload)
if widget.kind == "calendar"
else self._recent_rows(widget, payload)
)
return {"fetched_at": fetched_at, "ttl_seconds": ttl, "error": None, "items": rows[: widget.limit]}
# ------------------------------------------------------------- image relay
async def image(self, widget: Widget, url: str) -> Tuple[bytes, str]:
"""Fetch an upstream artwork URL, but only if it is on that upstream.
The browser cannot reach the internal host, so every ``thumb`` in a
payload is a ``/api/widgets/{id}/image?u=...`` URL: this is the only
same-origin way to show artwork, and it must never become a general
URL fetcher.
"""
origin = self._origin(widget.base_url)
target = url if url.startswith(("http://", "https://")) else f"{origin}/{url.lstrip('/')}"
if self._origin(target) != origin:
raise HTTPException(
status_code=404,
detail="image is not on the widget's own origin",
)
cached = self._images.get(target)
if cached:
return cached
try:
upstream = await self.client.request(
"GET", target, headers={**self._headers(widget), "Accept": "image/*"}
)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"upstream {origin} unreachable: {exc}") from exc
if upstream.status_code >= 300:
raise HTTPException(status_code=404, detail=f"no image at {target.rsplit('/', 1)[-1]}")
media_type = upstream.headers.get("content-type", "")
if not media_type.startswith("image/"):
raise HTTPException(
status_code=415,
detail=f"upstream served {media_type or '?'}, not an image",
)
if len(upstream.content) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="image too large")
if len(self._images) >= MAX_IMAGE_ENTRIES:
self._images.clear()
self._images[target] = (upstream.content, media_type)
return upstream.content, media_type