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:
1 parent
298ef9dbf5
commit
24dd017905
12 files changed
+1050
-302
No files matched your search
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Two stages: the wheel is built from the src layout, then installed into a venv
|
# 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
|
# 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
|
# 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.
|
# never get baked into a layer.
|
||||||
|
|
||||||
ARG PYTHON_VERSION=3.11
|
ARG PYTHON_VERSION=3.11
|
||||||
|
|||||||
@@ -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
|
(`UWUDASH_HOST` / `UWUDASH_PORT` are baked in as `ENV` defaults - override both
|
||||||
the `--env` and the `-p` mapping if you change the port).
|
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
|
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/`
|
(`-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
|
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 |
|
| `--port` | `8000` | bind port |
|
||||||
| `--data-dir` | `data` | directory holding `services.json` |
|
| `--data-dir` | `data` | directory holding `services.json` |
|
||||||
| `--services-file` | `<data-dir>/services.json` | explicit store path |
|
| `--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 |
|
| `--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` |
|
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
|
||||||
| `--reload` | off | uvicorn reload on source changes |
|
| `--reload` | off | uvicorn reload on source changes |
|
||||||
@@ -91,7 +91,8 @@ container stays `healthy` with auth on.
|
|||||||
| `UWUDASH_PORT` | `8000` | bind port |
|
| `UWUDASH_PORT` | `8000` | bind port |
|
||||||
| `UWUDASH_DATA_DIR` | `data` | data directory |
|
| `UWUDASH_DATA_DIR` | `data` | data directory |
|
||||||
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
|
| `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_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_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) |
|
| `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.
|
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
|
Widgets are native dashboard content - no iframe, no URL bar. They live in
|
||||||
`data/pages.json` (same atomic-write format as `services.json`):
|
`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
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"pages": [
|
"widgets": [
|
||||||
{
|
{
|
||||||
"id": "maintenance",
|
"id": "upcoming",
|
||||||
"name": "Maintenance",
|
"name": "Upcoming",
|
||||||
"icon": "🔧",
|
"icon": "",
|
||||||
"body": "Server down tonight\nBring snacks",
|
"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",
|
"created_at": "2026-10-28T09:29:31+00:00",
|
||||||
"updated_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
|
- `id` is slugified from the name and stays stable across edits.
|
||||||
at 5000 characters and keeps its line breaks (`white-space: pre-wrap`, escaped).
|
- Live widgets are fetched **at render time**, never at save time: the first
|
||||||
- Each notice gets a sidebar button; clicking it shows a single card in the viewer
|
render after a save fetches, later renders reuse the payload until
|
||||||
area instead of the iframe.
|
`UWUDASH_WIDGET_CACHE_TTL` (default 300 s) expires. The cache key is
|
||||||
- The notice board is the **default view**: landing on `/` already shows it in the
|
`<id>@<updated_at>`, so editing a widget invalidates it immediately. Errors
|
||||||
viewer area (banner plus every notice, newest first), no click needed. The pinned
|
cache for at most 60 s so a fixed upstream recovers quickly.
|
||||||
**Home** button (`🏠`, always present) re-opens it, and deleting whatever you were
|
- Upstream keys go in `headers` (the form's "Headers" textarea, one `Key: value`
|
||||||
viewing falls back to it. `/` itself stays the dashboard.
|
per line) - they stay on the server-to-server hop and never appear in the
|
||||||
- Writes are admin-only when OIDC is on, reads any session - same gate as services.
|
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),
|
Edit them from the **Settings** page (the "Add widget" form and the widget
|
||||||
or via the API.
|
table, which carries a Test button per live widget), or via the API.
|
||||||
|
|
||||||
## How the proxy works
|
## How the proxy works
|
||||||
|
|
||||||
@@ -285,11 +311,13 @@ Details:
|
|||||||
| `PATCH` / `PUT` | `/api/services/{id}` | update |
|
| `PATCH` / `PUT` | `/api/services/{id}` | update |
|
||||||
| `DELETE` | `/api/services/{id}` | delete |
|
| `DELETE` | `/api/services/{id}` | delete |
|
||||||
| `GET` | `/api/services/{id}/health` | upstream reachability check |
|
| `GET` | `/api/services/{id}/health` | upstream reachability check |
|
||||||
| `GET` | `/api/pages` | list notices |
|
| `GET` | `/api/widgets` | every widget, with live data inlined for `calendar`/`recent` |
|
||||||
| `POST` | `/api/pages` | create - admin only |
|
| `POST` | `/api/widgets` | create - admin only |
|
||||||
| `GET` | `/api/pages/{id}` | read |
|
| `GET` | `/api/widgets/{id}` | read the stored config |
|
||||||
| `PATCH` | `/api/pages/{id}` | update - admin only |
|
| `PATCH` | `/api/widgets/{id}` | update - admin only |
|
||||||
| `DELETE` | `/api/pages/{id}` | delete - 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/config` | effective config (never includes the OIDC secret) |
|
||||||
| `GET` | `/api/me` | current user (only when auth is enabled) |
|
| `GET` | `/api/me` | current user (only when auth is enabled) |
|
||||||
| `GET` | `/api/users` | list users - admin only |
|
| `GET` | `/api/users` | list users - admin only |
|
||||||
@@ -306,9 +334,9 @@ Details:
|
|||||||
```
|
```
|
||||||
src/uwudash_mk3/
|
src/uwudash_mk3/
|
||||||
config.py env-driven Config dataclass
|
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)
|
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)
|
users.py JSON user store (same pattern as store.py)
|
||||||
auth.py OIDC client, login/callback/logout, role dependencies
|
auth.py OIDC client, login/callback/logout, role dependencies
|
||||||
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
|
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
|
||||||
|
|||||||
+80
-29
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from .config import Config
|
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 .models import User, UserPatch
|
||||||
from .pages import PageStore
|
from .models import Widget, WidgetInput, WidgetPatch
|
||||||
from .proxy import Proxy
|
from .proxy import Proxy
|
||||||
from .store import ServiceStore
|
from .store import ServiceStore
|
||||||
from .users import UserStore
|
from .users import UserStore
|
||||||
|
from .widgets import WidgetData, WidgetStore
|
||||||
|
|
||||||
|
|
||||||
def create_api_router(
|
def create_api_router(
|
||||||
store: ServiceStore,
|
store: ServiceStore,
|
||||||
pages: PageStore,
|
widgets: WidgetStore,
|
||||||
|
widget_data: WidgetData,
|
||||||
proxy: Proxy,
|
proxy: Proxy,
|
||||||
config: Config,
|
config: Config,
|
||||||
users: Optional[UserStore] = None,
|
users=None,
|
||||||
require_login=None,
|
require_login=None,
|
||||||
require_admin=None,
|
require_admin=None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
"""Build the ``/api`` router.
|
"""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
|
``users``/``require_login``/``require_admin`` are only supplied when OIDC
|
||||||
is configured; without them the router is exactly the unauthenticated one.
|
is configured; without them the router is exactly the unauthenticated one.
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +47,8 @@ def create_api_router(
|
|||||||
"host": config.host,
|
"host": config.host,
|
||||||
"port": config.port,
|
"port": config.port,
|
||||||
"services_file": str(config.services_file),
|
"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,
|
"strip_frame_options": config.strip_frame_options,
|
||||||
"proxy_timeout": config.proxy_timeout,
|
"proxy_timeout": config.proxy_timeout,
|
||||||
"auth_enabled": config.auth_enabled,
|
"auth_enabled": config.auth_enabled,
|
||||||
@@ -102,43 +110,86 @@ def create_api_router(
|
|||||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||||
return await proxy.check(service)
|
return await proxy.check(service)
|
||||||
|
|
||||||
@router.get("/pages")
|
@router.get("/widgets")
|
||||||
def list_pages() -> List[Page]:
|
async def list_widgets() -> List[dict]:
|
||||||
return pages.list()
|
"""Every widget, with live data inlined for the ones that have an upstream.
|
||||||
|
|
||||||
@router.get("/pages/{page_id}")
|
Inlining keeps the dashboard to one round trip; the fetch layer caches
|
||||||
def get_page(page_id: str) -> Page:
|
per widget (keyed on ``updated_at``) so a reload is a dict lookup.
|
||||||
page = pages.get(page_id)
|
"""
|
||||||
if page is None:
|
out = []
|
||||||
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
|
for widget in widgets.list():
|
||||||
return page
|
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(
|
@router.post(
|
||||||
"/pages",
|
"/widgets",
|
||||||
status_code=201,
|
status_code=201,
|
||||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||||
)
|
)
|
||||||
def create_page(payload: PageInput) -> Page:
|
def create_widget(payload: WidgetInput) -> Widget:
|
||||||
return pages.create(payload)
|
return widgets.create(payload)
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/pages/{page_id}",
|
"/widgets/{widget_id}",
|
||||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||||
)
|
)
|
||||||
def update_page(page_id: str, payload: PagePatch) -> Page:
|
def update_widget(widget_id: str, payload: WidgetPatch) -> Widget:
|
||||||
page = pages.update(page_id, payload)
|
try:
|
||||||
if page is None:
|
widget = widgets.update(widget_id, payload)
|
||||||
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
|
except ValueError as exc:
|
||||||
return page
|
# 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(
|
@router.delete(
|
||||||
"/pages/{page_id}",
|
"/widgets/{widget_id}",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
dependencies=[Depends(require_admin)] if require_admin else [],
|
dependencies=[Depends(require_admin)] if require_admin else [],
|
||||||
)
|
)
|
||||||
def delete_page(page_id: str) -> None:
|
def delete_widget(widget_id: str) -> None:
|
||||||
if not pages.delete(page_id):
|
if not widgets.delete(widget_id):
|
||||||
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
|
raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}")
|
||||||
|
|
||||||
if users is not None:
|
if users is not None:
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
|
|||||||
+10
-5
@@ -25,7 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from .api import create_api_router
|
from .api import create_api_router
|
||||||
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
|
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .pages import PageStore
|
from .widgets import WidgetData, WidgetStore
|
||||||
from .proxy import Proxy
|
from .proxy import Proxy
|
||||||
from .store import ServiceStore
|
from .store import ServiceStore
|
||||||
from .users import UserStore
|
from .users import UserStore
|
||||||
@@ -38,7 +38,8 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|||||||
config = config or Config.from_env()
|
config = config or Config.from_env()
|
||||||
|
|
||||||
store = ServiceStore(config.services_file)
|
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)
|
proxy = Proxy(store, config)
|
||||||
users = UserStore(config.users_file) if config.auth_enabled else None
|
users = UserStore(config.users_file) if config.auth_enabled else None
|
||||||
oauth = create_oauth(config) 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
|
yield
|
||||||
await proxy.close()
|
await proxy.close()
|
||||||
|
await widget_data.close()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="UwuDash MK3",
|
title="UwuDash MK3",
|
||||||
@@ -73,7 +75,8 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|||||||
|
|
||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.state.store = store
|
app.state.store = store
|
||||||
app.state.pages = pages
|
app.state.widgets = widgets
|
||||||
|
app.state.widget_data = widget_data
|
||||||
app.state.proxy = proxy
|
app.state.proxy = proxy
|
||||||
app.state.users = users
|
app.state.users = users
|
||||||
app.state.oauth = oauth
|
app.state.oauth = oauth
|
||||||
@@ -88,7 +91,9 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|||||||
require_login = require_admin = None
|
require_login = require_admin = None
|
||||||
|
|
||||||
app.include_router(
|
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("/")
|
@app.get("/")
|
||||||
@@ -101,7 +106,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
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 []
|
auth_gate = [Depends(require_login)] if require_login else []
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Everything is driven by environment variables (with sane defaults) so the
|
|||||||
dashboard can be deployed without editing code:
|
dashboard can be deployed without editing code:
|
||||||
|
|
||||||
UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE,
|
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
|
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.
|
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_OIDC_SCOPE = "openid email profile"
|
||||||
DEFAULT_SESSION_TTL = 3600
|
DEFAULT_SESSION_TTL = 3600
|
||||||
DEFAULT_ADMIN_VALUES = ("admin",)
|
DEFAULT_ADMIN_VALUES = ("admin",)
|
||||||
|
DEFAULT_WIDGET_CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def _int(name: str, default: int) -> int:
|
def _int(name: str, default: int) -> int:
|
||||||
@@ -87,13 +89,14 @@ class Config:
|
|||||||
session_ttl_seconds: int = DEFAULT_SESSION_TTL
|
session_ttl_seconds: int = DEFAULT_SESSION_TTL
|
||||||
users_file: Optional[Path] = None
|
users_file: Optional[Path] = None
|
||||||
public_url: Optional[str] = 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:
|
def __post_init__(self) -> None:
|
||||||
if self.users_file is None:
|
if self.users_file is None:
|
||||||
object.__setattr__(self, "users_file", self.data_dir / "users.json")
|
object.__setattr__(self, "users_file", self.data_dir / "users.json")
|
||||||
if self.pages_file is None:
|
if self.widgets_file is None:
|
||||||
object.__setattr__(self, "pages_file", self.data_dir / "pages.json")
|
object.__setattr__(self, "widgets_file", self.data_dir / "widgets.json")
|
||||||
if self.oidc_issuer:
|
if self.oidc_issuer:
|
||||||
object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/"))
|
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")
|
os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json")
|
||||||
),
|
),
|
||||||
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
|
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
|
||||||
pages_file=Path(
|
widgets_file=Path(
|
||||||
os.environ.get("UWUDASH_PAGES_FILE", data_dir / "pages.json")
|
os.environ.get("UWUDASH_WIDGETS_FILE", data_dir / "widgets.json")
|
||||||
),
|
),
|
||||||
|
widget_cache_ttl=_int("UWUDASH_WIDGET_CACHE_TTL", DEFAULT_WIDGET_CACHE_TTL),
|
||||||
)
|
)
|
||||||
@@ -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("--port", type=int, help="bind port (default 8000)")
|
||||||
parser.add_argument("--data-dir", help="directory holding services.json")
|
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("--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("--reload", action="store_true", help="restart on source changes")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--oidc-issuer",
|
"--oidc-issuer",
|
||||||
@@ -43,10 +43,10 @@ def build_config(args: argparse.Namespace) -> Config:
|
|||||||
config = replace(config, services_file=Path(args.services_file))
|
config = replace(config, services_file=Path(args.services_file))
|
||||||
elif args.data_dir:
|
elif args.data_dir:
|
||||||
config = replace(config, services_file=Path(args.data_dir) / "services.json")
|
config = replace(config, services_file=Path(args.data_dir) / "services.json")
|
||||||
if args.pages_file:
|
if args.widgets_file:
|
||||||
config = replace(config, pages_file=Path(args.pages_file))
|
config = replace(config, widgets_file=Path(args.widgets_file))
|
||||||
elif args.data_dir:
|
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:
|
if args.oidc_issuer:
|
||||||
config = replace(config, oidc_issuer=args.oidc_issuer)
|
config = replace(config, oidc_issuer=args.oidc_issuer)
|
||||||
if args.public_url:
|
if args.public_url:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import re
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from urllib.parse import urlsplit
|
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"}
|
ALLOWED_SCHEMES = {"http", "https"}
|
||||||
OPEN_MODES = ("iframe", "newtab")
|
OPEN_MODES = ("iframe", "newtab")
|
||||||
@@ -119,28 +119,118 @@ class ServicePatch(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class PageInput(BaseModel):
|
WIDGET_KINDS = ("notice", "calendar", "recent")
|
||||||
"""Payload accepted by the page create/update endpoints."""
|
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)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
icon: str = Field(default="", max_length=8)
|
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):
|
class Widget(WidgetInput):
|
||||||
"""A stored notice page."""
|
"""A stored widget."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
class PagePatch(BaseModel):
|
class WidgetPatch(BaseModel):
|
||||||
"""Partial page update; unset fields are left alone."""
|
"""Partial widget update; unset fields are left alone."""
|
||||||
|
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
|
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
|
||||||
icon: Optional[str] = Field(default=None, max_length=8)
|
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)
|
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")
|
ROLES = ("admin", "viewer")
|
||||||
|
|||||||
@@ -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
@@ -10,13 +10,13 @@ const el = {
|
|||||||
viewerFrame: document.getElementById("viewer-frame"),
|
viewerFrame: document.getElementById("viewer-frame"),
|
||||||
emptyState: document.getElementById("empty-state"),
|
emptyState: document.getElementById("empty-state"),
|
||||||
emptyText: document.getElementById("empty-text"),
|
emptyText: document.getElementById("empty-text"),
|
||||||
pagePanel: document.getElementById("page-panel"),
|
widgetPanel: document.getElementById("widget-panel"),
|
||||||
reloadBtn: document.getElementById("reload-btn"),
|
reloadBtn: document.getElementById("reload-btn"),
|
||||||
settings: document.getElementById("settings"),
|
settings: document.getElementById("settings"),
|
||||||
settingsToggle: document.getElementById("settings-toggle"),
|
settingsToggle: document.getElementById("settings-toggle"),
|
||||||
settingsClose: document.getElementById("settings-close"),
|
settingsClose: document.getElementById("settings-close"),
|
||||||
serviceTable: document.getElementById("service-table"),
|
serviceTable: document.getElementById("service-table"),
|
||||||
pageTable: document.getElementById("page-table"),
|
widgetTable: document.getElementById("widget-table"),
|
||||||
form: document.getElementById("service-form"),
|
form: document.getElementById("service-form"),
|
||||||
formTitle: document.getElementById("form-title"),
|
formTitle: document.getElementById("form-title"),
|
||||||
name: document.getElementById("f-name"),
|
name: document.getElementById("f-name"),
|
||||||
@@ -27,12 +27,19 @@ const el = {
|
|||||||
headers: document.getElementById("f-headers"),
|
headers: document.getElementById("f-headers"),
|
||||||
enabled: document.getElementById("f-enabled"),
|
enabled: document.getElementById("f-enabled"),
|
||||||
formCancel: document.getElementById("form-cancel"),
|
formCancel: document.getElementById("form-cancel"),
|
||||||
noticeForm: document.getElementById("notice-form"),
|
widgetForm: document.getElementById("widget-form"),
|
||||||
noticeFormTitle: document.getElementById("notice-form-title"),
|
widgetFormTitle: document.getElementById("widget-form-title"),
|
||||||
noticeName: document.getElementById("n-name"),
|
wName: document.getElementById("w-name"),
|
||||||
noticeIcon: document.getElementById("n-icon"),
|
wIcon: document.getElementById("w-icon"),
|
||||||
noticeBody: document.getElementById("n-body"),
|
wKind: document.getElementById("w-kind"),
|
||||||
noticeCancel: document.getElementById("notice-cancel"),
|
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"),
|
toast: document.getElementById("toast"),
|
||||||
userChip: document.getElementById("user-chip"),
|
userChip: document.getElementById("user-chip"),
|
||||||
userName: document.getElementById("user-name"),
|
userName: document.getElementById("user-name"),
|
||||||
@@ -40,16 +47,16 @@ const el = {
|
|||||||
userTable: document.getElementById("user-table"),
|
userTable: document.getElementById("user-table"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sentinel for state.selected while the Home notice board is open; slugify()
|
// Sentinel for state.selected while the Home widget board is open; slugify()
|
||||||
// only ever emits [a-z0-9-], so no page id can collide with it.
|
// only ever emits [a-z0-9-], so no widget id can collide with it.
|
||||||
const HOME = "@home";
|
const HOME = "@home";
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
services: [],
|
services: [],
|
||||||
pages: [],
|
widgets: [],
|
||||||
selected: HOME,
|
selected: HOME,
|
||||||
editing: null,
|
editing: null,
|
||||||
editingPage: null,
|
editingWidget: null,
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,8 +110,8 @@ function textToHeaders(text) {
|
|||||||
/* ---------------------------------------------------------------- rendering */
|
/* ---------------------------------------------------------------- rendering */
|
||||||
function renderSidebar() {
|
function renderSidebar() {
|
||||||
el.serviceList.innerHTML = "";
|
el.serviceList.innerHTML = "";
|
||||||
if (!state.services.length && !state.pages.length) {
|
if (!state.services.length && !state.widgets.length) {
|
||||||
el.serviceList.innerHTML = '<p class="status">No services configured yet.</p>';
|
el.serviceList.innerHTML = '<p class="status">No services or widgets yet.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const service of state.services) {
|
for (const service of state.services) {
|
||||||
@@ -121,19 +128,33 @@ function renderSidebar() {
|
|||||||
button.addEventListener("click", () => openService(service));
|
button.addEventListener("click", () => openService(service));
|
||||||
el.serviceList.appendChild(button);
|
el.serviceList.appendChild(button);
|
||||||
}
|
}
|
||||||
for (const page of state.pages) {
|
for (const widget of state.widgets) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.className = "page-btn" + (state.selected === page.id ? " active" : "");
|
button.className = "widget-btn" + (state.selected === widget.id ? " active" : "");
|
||||||
button.title = page.name;
|
button.title = widget.name;
|
||||||
button.innerHTML =
|
button.innerHTML =
|
||||||
`<span class="icon">${esc(page.icon || "📌")}</span>` +
|
`<span class="icon">${esc(widget.icon || WIDGET_ICONS[widget.kind])}</span>` +
|
||||||
`<span><span class="label">${esc(page.name)}</span>` +
|
`<span><span class="label">${esc(widget.name)}</span>` +
|
||||||
`<span class="sub">${esc(page.body.slice(0, 60))}</span></span>`;
|
`<span class="sub">${esc(widgetSub(widget))}</span></span>`;
|
||||||
button.addEventListener("click", () => openPage(page));
|
button.addEventListener("click", () => openWidget(widget));
|
||||||
el.serviceList.appendChild(button);
|
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() {
|
function renderTable() {
|
||||||
el.serviceTable.innerHTML = "";
|
el.serviceTable.innerHTML = "";
|
||||||
if (!state.services.length) {
|
if (!state.services.length) {
|
||||||
@@ -155,51 +176,152 @@ function renderTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPageTable() {
|
function renderWidgetTable() {
|
||||||
el.pageTable.innerHTML = "";
|
el.widgetTable.innerHTML = "";
|
||||||
if (!state.pages.length) {
|
if (!state.widgets.length) {
|
||||||
el.pageTable.innerHTML = '<p class="status">No notices yet - pin one above.</p>';
|
el.widgetTable.innerHTML = '<p class="status">No widgets yet - add one on the left.</p>';
|
||||||
return;
|
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");
|
const row = document.createElement("div");
|
||||||
row.className = "page-row";
|
row.className = "widget-row";
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
`<div class="meta"><strong>${esc(page.name)}</strong>` +
|
`<div class="meta"><strong>${esc(widget.name)}</strong>` +
|
||||||
`<span>${esc(page.body.slice(0, 80))}</span></div>` +
|
`<code>${esc(detail)}</code>` +
|
||||||
`<button class="btn ghost" data-action="edit" data-id="${esc(page.id)}">Edit</button>` +
|
`<span>${esc(widget.kind)}</span></div>` +
|
||||||
`<button class="btn ghost" data-action="delete" data-id="${esc(page.id)}">Delete</button>`;
|
`<span class="status" data-status="${esc(widget.id)}">${esc(status)}</span>` +
|
||||||
el.pageTable.appendChild(row);
|
(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 (
|
return (
|
||||||
`<div class="notice-card">` +
|
`<div class="widget-card notice">` +
|
||||||
`<span class="icon">${esc(page.icon || "📌")}</span>` +
|
`<span class="icon">${widgetIcon(widget)}</span>` +
|
||||||
`<div class="notice-title">${esc(page.name)}</div>` +
|
`<div class="widget-title">${esc(widget.name)}</div>` +
|
||||||
`<div class="notice-body">${esc(page.body)}</div>` +
|
`<div class="widget-body">${esc(widget.body || "")}</div>` +
|
||||||
`</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() {
|
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 =
|
const banner =
|
||||||
`<div class="home-banner">` +
|
`<div class="home-banner">` +
|
||||||
`<div class="home-title">Home (uwu)</div>` +
|
`<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>`;
|
`</div>`;
|
||||||
const cards = state.pages.slice().reverse().map(noticeCard).join("");
|
el.widgetPanel.innerHTML =
|
||||||
el.pagePanel.innerHTML =
|
banner + (cards.join("") || '<p class="home-empty">no widgets yet ( 🥯🥯 ) — add one from Settings!</p>');
|
||||||
banner + (cards || '<p class="home-empty">no notices yet ( 🥯🥯 ) — pin one from Settings!</p>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findService(id) {
|
function findService(id) {
|
||||||
return state.services.find((s) => s.id === id);
|
return state.services.find((s) => s.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPage(id) {
|
function findWidget(id) {
|
||||||
return state.pages.find((p) => p.id === id);
|
return state.widgets.find((w) => w.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- auth */
|
/* ---------------------------------------------------------------- auth */
|
||||||
@@ -267,7 +389,7 @@ function openService(service) {
|
|||||||
if (service.open_in === "newtab") {
|
if (service.open_in === "newtab") {
|
||||||
window.open(service.proxy_path, "_blank");
|
window.open(service.proxy_path, "_blank");
|
||||||
el.settings.hidden = true;
|
el.settings.hidden = true;
|
||||||
el.pagePanel.hidden = true;
|
el.widgetPanel.hidden = true;
|
||||||
el.viewerFrame.hidden = true;
|
el.viewerFrame.hidden = true;
|
||||||
el.viewerFrame.src = "";
|
el.viewerFrame.src = "";
|
||||||
el.reloadBtn.hidden = true;
|
el.reloadBtn.hidden = true;
|
||||||
@@ -279,7 +401,7 @@ function openService(service) {
|
|||||||
el.settings.hidden = true;
|
el.settings.hidden = true;
|
||||||
el.viewer.hidden = false;
|
el.viewer.hidden = false;
|
||||||
el.emptyState.hidden = true;
|
el.emptyState.hidden = true;
|
||||||
el.pagePanel.hidden = true;
|
el.widgetPanel.hidden = true;
|
||||||
el.viewerFrame.hidden = false;
|
el.viewerFrame.hidden = false;
|
||||||
el.reloadBtn.hidden = false;
|
el.reloadBtn.hidden = false;
|
||||||
el.viewerTitle.textContent = service.name;
|
el.viewerTitle.textContent = service.name;
|
||||||
@@ -287,27 +409,27 @@ function openService(service) {
|
|||||||
el.viewerFrame.src = service.proxy_path;
|
el.viewerFrame.src = service.proxy_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPage(page) {
|
function openWidget(widget) {
|
||||||
state.selected = page.id;
|
state.selected = widget.id;
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
|
|
||||||
el.settings.hidden = true;
|
el.settings.hidden = true;
|
||||||
el.viewer.hidden = false;
|
el.viewer.hidden = false;
|
||||||
el.emptyState.hidden = true;
|
el.emptyState.hidden = true;
|
||||||
el.pagePanel.hidden = false;
|
el.widgetPanel.hidden = false;
|
||||||
el.viewerFrame.hidden = true;
|
el.viewerFrame.hidden = true;
|
||||||
el.viewerFrame.src = "";
|
el.viewerFrame.src = "";
|
||||||
el.reloadBtn.hidden = true;
|
el.reloadBtn.hidden = true;
|
||||||
el.viewerTitle.textContent = page.name;
|
el.viewerTitle.textContent = widget.name;
|
||||||
el.viewerUrl.textContent = "";
|
el.viewerUrl.textContent = widget.base_url || "";
|
||||||
el.pagePanel.innerHTML = noticeCard(page);
|
el.widgetPanel.innerHTML = widgetCard(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paint the Home board into the viewer area without touching which section is
|
// 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.
|
// active, so a delete performed from Settings leaves Home ready for settingsClose.
|
||||||
function showHome() {
|
function showHome() {
|
||||||
el.emptyState.hidden = true;
|
el.emptyState.hidden = true;
|
||||||
el.pagePanel.hidden = false;
|
el.widgetPanel.hidden = false;
|
||||||
el.viewerFrame.hidden = true;
|
el.viewerFrame.hidden = true;
|
||||||
el.viewerFrame.src = "";
|
el.viewerFrame.src = "";
|
||||||
el.reloadBtn.hidden = true;
|
el.reloadBtn.hidden = true;
|
||||||
@@ -329,7 +451,7 @@ function showSettings() {
|
|||||||
el.viewer.hidden = true;
|
el.viewer.hidden = true;
|
||||||
el.viewerFrame.src = "";
|
el.viewerFrame.src = "";
|
||||||
renderTable();
|
renderTable();
|
||||||
renderPageTable();
|
renderWidgetTable();
|
||||||
renderUsers();
|
renderUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,28 +479,42 @@ function fillForm(service) {
|
|||||||
el.enabled.checked = service.enabled;
|
el.enabled.checked = service.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetNoticeForm() {
|
function resetWidgetForm() {
|
||||||
state.editingPage = null;
|
state.editingWidget = null;
|
||||||
el.noticeFormTitle.textContent = "Add notice";
|
el.widgetFormTitle.textContent = "Add widget";
|
||||||
el.noticeName.value = "";
|
el.wName.value = "";
|
||||||
el.noticeIcon.value = "";
|
el.wIcon.value = "";
|
||||||
el.noticeBody.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) {
|
function fillWidgetForm(widget) {
|
||||||
state.editingPage = page.id;
|
state.editingWidget = widget.id;
|
||||||
el.noticeFormTitle.textContent = `Edit ${page.name}`;
|
el.widgetFormTitle.textContent = `Edit ${widget.name}`;
|
||||||
el.noticeName.value = page.name;
|
el.wName.value = widget.name;
|
||||||
el.noticeIcon.value = page.icon || "";
|
el.wIcon.value = widget.icon || "";
|
||||||
el.noticeBody.value = page.body;
|
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() {
|
async function refresh() {
|
||||||
state.services = await api("/api/services");
|
state.services = await api("/api/services");
|
||||||
state.pages = await api("/api/pages");
|
state.widgets = await api("/api/widgets");
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderTable();
|
renderTable();
|
||||||
renderPageTable();
|
renderWidgetTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveService(event) {
|
async function saveService(event) {
|
||||||
@@ -404,19 +540,26 @@ async function saveService(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNotice(event) {
|
async function saveWidget(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = {
|
const payload = {
|
||||||
name: el.noticeName.value.trim(),
|
name: el.wName.value.trim(),
|
||||||
icon: el.noticeIcon.value.trim(),
|
icon: el.wIcon.value.trim(),
|
||||||
body: el.noticeBody.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 {
|
try {
|
||||||
const saved = state.editingPage
|
const saved = state.editingWidget
|
||||||
? await api(`/api/pages/${state.editingPage}`, { method: "PATCH", body: JSON.stringify(payload) })
|
? await api(`/api/widgets/${state.editingWidget}`, { method: "PATCH", body: JSON.stringify(payload) })
|
||||||
: await api("/api/pages", { method: "POST", body: JSON.stringify(payload) });
|
: await api("/api/widgets", { method: "POST", body: JSON.stringify(payload) });
|
||||||
toast(state.editingPage ? `Updated ${saved.name}` : `Added ${saved.name}`);
|
toast(state.editingWidget ? `Updated ${saved.name}` : `Added ${saved.name}`);
|
||||||
resetNoticeForm();
|
resetWidgetForm();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message, "error");
|
toast(error.message, "error");
|
||||||
@@ -438,14 +581,14 @@ async function deleteService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePage(id) {
|
async function deleteWidget(id) {
|
||||||
const page = findPage(id);
|
const widget = findWidget(id);
|
||||||
if (!page || !confirm(`Delete ${page.name}?`)) return;
|
if (!widget || !confirm(`Delete ${widget.name}?`)) return;
|
||||||
try {
|
try {
|
||||||
await api(`/api/pages/${id}`, { method: "DELETE" });
|
await api(`/api/widgets/${id}`, { method: "DELETE" });
|
||||||
const wasViewing = state.selected === id;
|
const wasViewing = state.selected === id;
|
||||||
if (wasViewing) state.selected = HOME;
|
if (wasViewing) state.selected = HOME;
|
||||||
toast(`Deleted ${page.name}`);
|
toast(`Deleted ${widget.name}`);
|
||||||
await refresh();
|
await refresh();
|
||||||
if (wasViewing) showHome();
|
if (wasViewing) showHome();
|
||||||
} catch (error) {
|
} 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 */
|
/* ---------------------------------------------------------------- wiring */
|
||||||
el.serviceTable.addEventListener("click", (event) => {
|
el.serviceTable.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("button[data-action]");
|
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]");
|
const button = event.target.closest("button[data-action]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const id = button.dataset.id;
|
const id = button.dataset.id;
|
||||||
if (button.dataset.action === "edit") {
|
if (button.dataset.action === "edit") {
|
||||||
const page = findPage(id);
|
const widget = findWidget(id);
|
||||||
if (page) fillNoticeForm(page);
|
if (widget) fillWidgetForm(widget);
|
||||||
} else if (button.dataset.action === "delete") {
|
} 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.form.addEventListener("submit", saveService);
|
||||||
el.formCancel.addEventListener("click", resetForm);
|
el.formCancel.addEventListener("click", resetForm);
|
||||||
el.noticeForm.addEventListener("submit", saveNotice);
|
el.widgetForm.addEventListener("submit", saveWidget);
|
||||||
el.noticeCancel.addEventListener("click", resetNoticeForm);
|
el.widgetCancel.addEventListener("click", resetWidgetForm);
|
||||||
el.homeBtn.addEventListener("click", openHome);
|
el.homeBtn.addEventListener("click", openHome);
|
||||||
el.settingsToggle.addEventListener("click", showSettings);
|
el.settingsToggle.addEventListener("click", showSettings);
|
||||||
el.settingsClose.addEventListener("click", () => {
|
el.settingsClose.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ body {
|
|||||||
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); }
|
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); }
|
||||||
.btn:hover { filter: brightness(1.1); }
|
.btn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ pages */
|
/* ---------------------------------------------------------------- widgets */
|
||||||
.home-btn,
|
.home-btn,
|
||||||
.page-btn {
|
.widget-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -252,16 +252,16 @@ body {
|
|||||||
border-bottom: 3px solid var(--accent);
|
border-bottom: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
.home-btn:hover { filter: brightness(1.1); }
|
.home-btn:hover { filter: brightness(1.1); }
|
||||||
.page-btn { border-left: 3px solid var(--accent); }
|
.widget-btn { border-left: 3px solid var(--accent); }
|
||||||
.page-btn:hover { border-color: var(--accent); }
|
.widget-btn:hover { border-color: var(--accent); }
|
||||||
.page-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
|
.widget-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
|
||||||
.home-btn .icon,
|
.home-btn .icon,
|
||||||
.page-btn .icon { font-size: 16px; }
|
.widget-btn .icon { font-size: 16px; }
|
||||||
.home-btn .label,
|
.home-btn .label,
|
||||||
.page-btn .label { display: block; font-weight: 600; }
|
.widget-btn .label { display: block; font-weight: 600; }
|
||||||
.page-btn .sub { display: block; font-size: 11px; color: var(--muted); }
|
.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 {
|
.home-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -275,23 +275,41 @@ body {
|
|||||||
.home-title { font-size: 22px; font-weight: 800; }
|
.home-title { font-size: 22px; font-weight: 800; }
|
||||||
.home-sub { color: var(--muted); font-size: 13px; }
|
.home-sub { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
.notice-card {
|
.widget-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.notice-card .icon { font-size: 16px; }
|
.widget-card .icon { font-size: 16px; }
|
||||||
.notice-card .notice-title { font-weight: 700; }
|
.widget-card .widget-title { font-weight: 700; }
|
||||||
.notice-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
|
.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; }
|
.home-empty { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
.page-table { display: flex; flex-direction: column; gap: 10px; }
|
.widget-table { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.page-row {
|
.widget-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: 1fr auto auto auto auto; /* meta + status + test/edit/delete */
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -299,9 +317,10 @@ body {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.page-row .meta { min-width: 0; }
|
.widget-row .meta { min-width: 0; }
|
||||||
.page-row .meta strong { display: block; }
|
.widget-row .meta strong { display: block; }
|
||||||
.page-row .meta span { color: var(--muted); font-size: 12px; 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 */
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
|
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
|
||||||
</header>
|
</header>
|
||||||
<iframe id="viewer-frame" title="Service view" hidden></iframe>
|
<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>
|
<div class="empty" id="empty-state" hidden>
|
||||||
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
|
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,28 +89,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="notice-form" class="card form" autocomplete="off">
|
<form id="widget-form" class="card form" autocomplete="off">
|
||||||
<h2 id="notice-form-title">Add notice</h2>
|
<h2 id="widget-form-title">Add widget</h2>
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
<input id="n-name" required maxlength="120" placeholder="Server status" />
|
<input id="w-name" required maxlength="120" placeholder="Upcoming" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Icon
|
Icon
|
||||||
<input id="n-icon" maxlength="8" placeholder="emoji or initials" />
|
<input id="w-icon" maxlength="8" placeholder="emoji or initials" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Body
|
Kind
|
||||||
<textarea id="n-body" required rows="6" placeholder="Anything the crew should know"></textarea>
|
<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>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn">Save</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="service-table" id="service-table"></div>
|
<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 class="user-table" id="user-table" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user