Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
This commit is contained in:
@@ -8,6 +8,7 @@ in the same UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -19,6 +20,8 @@ import paramiko
|
||||
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||
@@ -172,6 +175,9 @@ class SettingsStore:
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
|
||||
if "service_id" not in backup_job_cols:
|
||||
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -244,6 +250,19 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS named_dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -415,6 +434,75 @@ class SettingsStore:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._migrate_jellyseerr_into_jellyfin()
|
||||
|
||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
||||
|
||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
||||
paired -> drop with a logged warning.
|
||||
"""
|
||||
from media_library_viewer_api.services.secrets import decrypt_value
|
||||
|
||||
self.init_schema()
|
||||
jellyseerr_rows: list[sqlite3.Row] = []
|
||||
with self.connect() as conn:
|
||||
jellyseerr_rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||
).fetchall()
|
||||
if not jellyseerr_rows:
|
||||
return
|
||||
|
||||
jellyfin_rows = self.list_services("jellyfin")
|
||||
for js_row in jellyseerr_rows:
|
||||
js_config = json.loads(js_row["config_json"] or "{}")
|
||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||
js_url = str(js_config.get("base_url", "")).strip()
|
||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
||||
if js_api_key:
|
||||
try:
|
||||
js_api_key = decrypt_value(js_api_key)
|
||||
except Exception:
|
||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||
js_api_key = ""
|
||||
js_name = js_row["name"]
|
||||
|
||||
target = None
|
||||
if len(jellyfin_rows) == 1:
|
||||
target = jellyfin_rows[0]
|
||||
elif len(jellyfin_rows) > 1:
|
||||
for jf in jellyfin_rows:
|
||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
||||
target = jf
|
||||
break
|
||||
|
||||
if target:
|
||||
merged_config = dict(target["config"])
|
||||
merged_config["jellyseerr_url"] = js_url
|
||||
merged_config["jellyseerr_api_key"] = js_api_key
|
||||
self.upsert_service(
|
||||
{
|
||||
"id": target["id"],
|
||||
"service_type": "jellyfin",
|
||||
"name": target["name"],
|
||||
"config": merged_config,
|
||||
"enabled": target["enabled"],
|
||||
},
|
||||
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
|
||||
)
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
||||
js_name,
|
||||
)
|
||||
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||
conn.commit()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
@@ -892,6 +980,7 @@ class SettingsStore:
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
||||
"service_id": row["service_id"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
@@ -910,12 +999,18 @@ class SettingsStore:
|
||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||
if schedule_interval_seconds is not None:
|
||||
schedule_interval_seconds = int(schedule_interval_seconds)
|
||||
service_id = str(
|
||||
payload.get("service_id")
|
||||
if payload.get("service_id") is not None
|
||||
else (current or {}).get("service_id", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"id": job_id,
|
||||
"name": name,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"schedule_interval_seconds": schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
|
||||
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
@@ -933,17 +1028,19 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
@@ -951,6 +1048,7 @@ class SettingsStore:
|
||||
job["source"],
|
||||
job["target"],
|
||||
job["schedule_interval_seconds"],
|
||||
job["service_id"],
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
@@ -1566,6 +1664,113 @@ class SettingsStore:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Named dashboards
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _slugify(label: str) -> str:
|
||||
import re
|
||||
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
return slug or "dashboard"
|
||||
|
||||
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
|
||||
self.init_schema()
|
||||
base = slug
|
||||
suffix = 1
|
||||
with self.connect() as conn:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
|
||||
(slug, exclude_id or ""),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return slug
|
||||
suffix += 1
|
||||
slug = f"{base}-{suffix}"
|
||||
|
||||
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"label": row["label"],
|
||||
"slug": row["slug"],
|
||||
"sort_order": row["sort_order"],
|
||||
"payload": json.loads(row["payload_json"] or "{}"),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_dashboards(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [self._row_to_dashboard(row) for row in rows]
|
||||
|
||||
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
|
||||
if not slug:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
current = self.get_dashboard(dashboard_id) if dashboard_id else None
|
||||
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
|
||||
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
|
||||
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
|
||||
slug = self._unique_slug(slug, exclude_id=dash_id)
|
||||
sort_order = payload.get("sort_order")
|
||||
if sort_order is None:
|
||||
sort_order = (current or {}).get("sort_order", 0)
|
||||
sort_order = int(sort_order)
|
||||
payload_data = payload.get("payload")
|
||||
if payload_data is None:
|
||||
payload_data = (current or {}).get("payload", {})
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
slug = excluded.slug,
|
||||
sort_order = excluded.sort_order,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
dash_id,
|
||||
label,
|
||||
slug,
|
||||
sort_order,
|
||||
json.dumps(payload_data),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
|
||||
|
||||
def delete_dashboard(self, dashboard_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user