From bba23165ab926d8600e6ebafefe245f4212e3a1a Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 7 May 2026 12:34:16 +0200 Subject: [PATCH] fixes and improvements --- .../media_library_viewer_api/clients/local.py | 36 ++ .../clients/resources.py | 2 +- .../media_library_viewer_api/dependencies.py | 8 +- .../routers/dashboard.py | 41 ++ .../routers/settings.py | 10 +- .../services/settings_store.py | 131 ++++-- docs/REQUIREMENTS.md | 15 + frontend/src/api/client.ts | 26 ++ .../components/MonitoringOverviewTable.tsx | 218 +++++++--- frontend/src/hooks/useDashboard.ts | 35 +- frontend/src/pages/Dashboard.tsx | 386 +++++++++++++++++- frontend/src/pages/Monitoring.tsx | 4 +- frontend/src/pages/Settings.tsx | 78 +--- frontend/src/types/index.ts | 28 ++ 14 files changed, 860 insertions(+), 158 deletions(-) diff --git a/backend/src/media_library_viewer_api/clients/local.py b/backend/src/media_library_viewer_api/clients/local.py index e07ec17..8031ae3 100644 --- a/backend/src/media_library_viewer_api/clients/local.py +++ b/backend/src/media_library_viewer_api/clients/local.py @@ -6,8 +6,10 @@ itself. They are used for the built-in local monitoring machine. from __future__ import annotations +import json import logging import posixpath +import shlex import subprocess from dataclasses import dataclass @@ -55,6 +57,40 @@ class LocalCommandClient: ) return result + def list_dir(self, path: str) -> CommandResult: + quoted = shlex.quote(path) + not_dir_message = shlex.quote(f"Not a directory: {path}") + command = ( + f"test -d {quoted} || " + f"{{ echo {not_dir_message} >&2; exit 20; }}; " + f"find {quoted} -maxdepth 1 -mindepth 1 -printf " + "'%y\\t%s\\t%T@\\t%f\\0' | python3 -c " + + shlex.quote( + "import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); " + "rows=[]\n" + "for row in data:\n" + " if not row: continue\n" + " t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n" + " rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n" + "print(json.dumps(rows))" + ) + ) + return self.run(command) + + def stat_path(self, path: str) -> CommandResult: + quoted = shlex.quote(path) + return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}") + + def ffprobe_json(self, path: str) -> dict[str, object]: + quoted = shlex.quote(path) + result = self.run( + "ffprobe -v error -show_format -show_streams -print_format json " + quoted, + timeout=60, + ) + if result.exit_status != 0: + raise RuntimeError(result.stderr or result.stdout or "ffprobe failed") + return json.loads(result.stdout) + @staticmethod def join(parent: str, child: str) -> str: return posixpath.normpath(posixpath.join(parent, child)) diff --git a/backend/src/media_library_viewer_api/clients/resources.py b/backend/src/media_library_viewer_api/clients/resources.py index ad88fa2..48da5fe 100644 --- a/backend/src/media_library_viewer_api/clients/resources.py +++ b/backend/src/media_library_viewer_api/clients/resources.py @@ -255,7 +255,7 @@ def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths """Return a short human-readable status string for the dashboard.""" command = f""" if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then - echo "running pid=$(cat {shlex.quote(paths.pid_file)})" + echo "running" else echo "not running" fi diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index 2c5940e..2c5fc12 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -16,6 +16,7 @@ from fastapi import Request from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyseerr import JellyseerrClient +from media_library_viewer_api.clients.local import LocalCommandClient from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.services.known_hosts import ensure_known_host from media_library_viewer_api.config import get_settings @@ -137,14 +138,17 @@ def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key) -def get_ssh_client(request: Request = None) -> RemoteSSHClient: - """Return a cached SSH client for the selected machine or legacy env fallback.""" +def get_ssh_client(request: Request = None): + """Return a command client for the selected machine or legacy env fallback.""" store = get_settings_store() machine_id = _request_machine_id(request) machine = store.get_machine_config(machine_id) if machine_id else None if machine is None: machine_ref = _resolve_machine("ssh", request) machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None + if machine and str(machine.get("mode") or "local").strip().lower() == "local": + logger.info("Creating LocalCommandClient machine_id=%s", machine["id"]) + return LocalCommandClient() if machine and machine.get("host") and machine.get("username"): known_hosts_path = get_settings().ssh_known_hosts_file ensure_known_host(str(machine.get("host")), int(machine.get("port") or 22), known_hosts_path) diff --git a/backend/src/media_library_viewer_api/routers/dashboard.py b/backend/src/media_library_viewer_api/routers/dashboard.py index d20fb95..4d673d6 100644 --- a/backend/src/media_library_viewer_api/routers/dashboard.py +++ b/backend/src/media_library_viewer_api/routers/dashboard.py @@ -56,6 +56,47 @@ def get_monitoring_overview( } +@router.get("/shortcuts") +def get_shortcuts( + store=Depends(get_settings_store), +) -> list[dict[str, Any]]: + """Return dashboard shortcut records.""" + shortcuts = store.list_shortcuts() + logger.info("Dashboard shortcuts count=%s", len(shortcuts)) + return shortcuts + + +@router.post("/shortcuts") +def create_shortcut( + payload: dict[str, Any], + store=Depends(get_settings_store), +) -> dict[str, Any]: + shortcut = store.upsert_shortcut(payload) + logger.info("Created dashboard shortcut id=%s type=%s", shortcut.get("id"), shortcut.get("shortcut_type")) + return shortcut + + +@router.put("/shortcuts/{shortcut_id}") +def update_shortcut( + shortcut_id: str, + payload: dict[str, Any], + store=Depends(get_settings_store), +) -> dict[str, Any]: + shortcut = store.upsert_shortcut(payload, shortcut_id) + logger.info("Updated dashboard shortcut id=%s type=%s", shortcut.get("id"), shortcut.get("shortcut_type")) + return shortcut + + +@router.delete("/shortcuts/{shortcut_id}") +def delete_shortcut( + shortcut_id: str, + store=Depends(get_settings_store), +) -> dict[str, str]: + store.delete_shortcut(shortcut_id) + logger.info("Deleted dashboard shortcut id=%s", shortcut_id) + return {"status": "deleted"} + + def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]: """Normalize Jellyfin sessions into dashboard activity rows.""" results: list[dict[str, Any]] = [] diff --git a/backend/src/media_library_viewer_api/routers/settings.py b/backend/src/media_library_viewer_api/routers/settings.py index 6e768e5..97a393f 100644 --- a/backend/src/media_library_viewer_api/routers/settings.py +++ b/backend/src/media_library_viewer_api/routers/settings.py @@ -9,7 +9,7 @@ import paramiko from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field -from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store from media_library_viewer_api.services.db_maintenance import remove_sqlite_database from media_library_viewer_api.services.media_index import MediaIndex from media_library_viewer_api.services.settings_store import SettingsStore @@ -54,7 +54,9 @@ def post_machine( machine: MonitoringMachineInput, store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: - return store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) + saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) + get_monitoring_poller().start() + return saved @router.put("/machines/{machine_id}") @@ -65,7 +67,9 @@ def put_machine( ) -> dict[str, Any]: if not store.get_machine(machine_id): raise HTTPException(status_code=404, detail="Machine not found") - return store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) + saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) + get_monitoring_poller().start() + return saved @router.delete("/machines/{machine_id}") diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index e36cbb1..35fdb0a 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -125,6 +125,22 @@ class SettingsStore: conn.execute( "CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS dashboard_shortcuts ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + shortcut_type TEXT NOT NULL, + target_json TEXT NOT NULL, + enabled INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_dashboard_shortcuts_type ON dashboard_shortcuts(shortcut_type)" + ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time ON monitoring_machine_actions(machine_id, created_at DESC)" ) @@ -628,43 +644,100 @@ class SettingsStore: ).fetchall() return [dict(row) for row in rows] - def record_task_run( - self, - task: dict[str, Any], - status: str, - *, - machine_id: str, - machine_name: str, - task_type: str, - duration_ms: int, - request_id: str = "", - stdout_tail: str = "", - stderr_tail: str = "", - error: str = "", - ) -> None: + def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]: + target = json.loads(row["target_json"] or "{}") + return { + "id": row["id"], + "label": row["label"], + "shortcut_type": row["shortcut_type"], + "enabled": bool(row["enabled"]), + "icon": target.get("icon", ""), + "url": target.get("url", ""), + "task_id": target.get("task_id", ""), + "machine_id": target.get("machine_id", ""), + "user_id": target.get("user_id", ""), + "notes": target.get("notes", ""), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def _normalize_shortcut_payload(self, payload: dict[str, Any], shortcut_id: str | None = None) -> dict[str, Any]: + current = self.get_shortcut(shortcut_id) if shortcut_id else None + shortcut_id = str(payload.get("id") or shortcut_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12] + shortcut_type = str(payload.get("shortcut_type") or (current or {}).get("shortcut_type") or "website").strip().lower() + if shortcut_type not in {"website", "action", "user"}: + shortcut_type = "website" + label = str(payload.get("label") or (current or {}).get("label") or "").strip() or shortcut_id + enabled = bool(payload.get("enabled", (current or {}).get("enabled", True))) + icon = str(payload.get("icon") if payload.get("icon") is not None else (current or {}).get("icon", "") or "").strip() + url = str(payload.get("url") if payload.get("url") is not None else (current or {}).get("url", "") or "").strip() + task_id = str(payload.get("task_id") if payload.get("task_id") is not None else (current or {}).get("task_id", "") or "").strip() + machine_id = str(payload.get("machine_id") if payload.get("machine_id") is not None else (current or {}).get("machine_id", "") or "").strip() + user_id = str(payload.get("user_id") if payload.get("user_id") is not None else (current or {}).get("user_id", "") or "").strip() + notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() + return { + "id": shortcut_id, + "label": label, + "shortcut_type": shortcut_type, + "enabled": enabled, + "target": { + "icon": icon, + "url": url, + "task_id": task_id, + "machine_id": machine_id, + "user_id": user_id, + "notes": notes, + }, + } + + def list_shortcuts(self) -> list[dict[str, Any]]: self.init_schema() with self.connect() as conn: + rows = conn.execute("SELECT * FROM dashboard_shortcuts ORDER BY label COLLATE NOCASE").fetchall() + return [self._row_to_shortcut(row) for row in rows] + + def get_shortcut(self, shortcut_id: str | None) -> dict[str, Any] | None: + if not shortcut_id: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM dashboard_shortcuts WHERE id = ?", (shortcut_id,)).fetchone() + return self._row_to_shortcut(row) if row else None + + def upsert_shortcut(self, payload: dict[str, Any], shortcut_id: str | None = None) -> dict[str, Any]: + self.init_schema() + shortcut = self._normalize_shortcut_payload(payload, shortcut_id) + now = int(time.time()) + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM dashboard_shortcuts WHERE id = ?", (shortcut["id"],)).fetchone() + created_at = int(existing[0]) if existing else now conn.execute( """ - INSERT INTO saved_task_runs (id, task_id, task_name, machine_id, machine_name, task_type, status, created_at, duration_ms, request_id, stdout_tail, stderr_tail, error) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO dashboard_shortcuts (id, label, shortcut_type, target_json, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + label = excluded.label, + shortcut_type = excluded.shortcut_type, + target_json = excluded.target_json, + enabled = excluded.enabled, + updated_at = excluded.updated_at """, ( - uuid.uuid4().hex, - str(task.get("id") or ""), - str(task.get("name") or ""), - machine_id, - machine_name, - task_type, - status, - int(time.time()), - duration_ms, - request_id, - stdout_tail, - stderr_tail, - error, + shortcut["id"], + shortcut["label"], + shortcut["shortcut_type"], + json.dumps(shortcut["target"]), + 1 if shortcut["enabled"] else 0, + created_at, + now, ), ) + return self.get_shortcut(shortcut["id"]) or shortcut + + def delete_shortcut(self, shortcut_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM dashboard_shortcuts WHERE id = ?", (shortcut_id,)) _store: SettingsStore | None = None diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index def6346..e8c8c3a 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -132,6 +132,8 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo ### Dashboard / Server Monitoring - Provide a dashboard tab with a compact Jellyfin media library overview and a sortable table-style server resource overview covering all configured monitoring machines. +- Provide a dashboard shortcuts area that can open external websites now and later support internal shortcut types such as saved actions and user deep-links without redesigning the container. +- Dashboard shortcuts should support a small icon/preview field so cards can be visually recognizable without changing the underlying model later. - Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests. - Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage. - Provide Docker Compose deployment files at the repository root for production and local development. @@ -153,6 +155,8 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - The Monitoring tab should present one section per configured machine, and local vs remote machines should be treated the same in the UI with different connection/configuration data. - Provide a Settings tab where monitoring machines can be added, edited, enabled/disabled, or deleted persistently. - The app should start with no pre-seeded monitoring machines; users must explicitly add a local or SSH target before Monitoring shows anything. +- Local machines should be supported across Monitoring, Files, Jobs, and other remote-inspection tools without requiring SSH credentials. +- Creating a machine should automatically start the backend monitoring worker/poller so the new machine begins collecting snapshots without a separate manual step. - The Monitoring tab should request all retained collector samples by default, while the dashboard overview can continue to use a shorter recent window. - Show CPU and RAM usage for the last hour. - Show IO wait percentage for the last hour. @@ -220,6 +224,12 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-06: Monitoring history collection was shifted to a backend-scheduled poller that reads the defined machines over SSH/local shell and stores snapshots in SQLite, avoiding any remote agent or push requirement. - 2026-05-06: The dashboard monitoring section was converted from summary cards into a table of all configured machines, paired with backend poller status so the whole fleet can be reviewed at a glance. - 2026-05-06: The dashboard monitoring table now shows 10-minute averages with min/max subtext and can be sorted by machine, status, and metric columns. +- 2026-05-07: The dashboard monitoring table should render each metric as a two-tier cell with a dominant centered 10-minute average in the upper area and a smaller lower area for min/max chips so the average is the visual focus. +- 2026-05-07: The lower min/max area in each monitoring metric cell should span the full width of the cell, and table/header spacing should stay tuned for a dense admin-console layout. +- 2026-05-07: The monitoring metric average should be explicitly labeled as a 10m average in the cell so the summary value is not ambiguous. +- 2026-05-07: The monitoring overview's Updated column should use a compact fixed timestamp format instead of locale-specific output for easier scanning, and the cell should show two stacked lines: a readable month/day timestamp and a compact clock-plus-age line. +- 2026-05-07: The monitoring overview table should allow horizontal scrolling when the dense column set exceeds the viewport width. +- 2026-05-07: Monitoring collector status should show a simple running/not-running state in the UI rather than surfacing backend process IDs. - 2026-05-06: The Monitoring page now includes a poller-health badge in the header so users can quickly see whether backend collection is active. - 2026-05-06: The dashboard monitoring table now renders each metric summary with compact stacked low/high lines to keep the table narrower, and the activity/session table no longer hides columns on mobile so all details remain available. - 2026-05-06: The dashboard monitoring table now renders the 10-minute value as the visual focus and keeps the low/high lines smaller as supporting detail. @@ -234,6 +244,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote SSH machines. - 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases. - 2026-05-06: The Actions page was redesigned into a compact tabbed workspace with a left tab rail of saved actions, and both new-action creation and editing now open in popups instead of inline forms. +- 2026-05-07: Added a reusable dashboard shortcuts container with persisted records so the dashboard can link to external websites now and later support action/user shortcut types from the same model. +- 2026-05-07: Dashboard shortcuts gained an optional icon/preview field so cards can be visually differentiated while keeping future shortcut types extensible. +- 2026-05-07: The dashboard shortcut editor was tightened with compact type guidance and shorter helper text so the popup stays readable without wasting vertical space. +- 2026-05-07: Machine creation was adjusted so dialog edits are controlled by the parent form state, ensuring all entered fields are actually saved. +- 2026-05-07: Local machines now work through the same Files/Jobs/monitoring tool paths without SSH credentials, and creating a machine starts the monitoring worker automatically. - 2026-05-06: Closing an edited Action popup now warns before discarding unsaved changes. - 2026-05-06: The Settings page was reworked into a compact tabbed layout with separate Machines, SSH Keys, and Danger Zone sections, and irrelevant machine/service fields now hide when that mode or service is not selected. - 2026-05-06: The Settings machine list was converted into a compact table with popup editing so the page no longer repeats the full machine form for every entry. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3ef5ed2..feede63 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -33,6 +33,8 @@ import type { ResolvedPath, ResetLocalDatabaseInput, ResetLocalDatabaseResponse, + DashboardShortcut, + DashboardShortcutInput, } from "../types"; const BASE_URL = import.meta.env.VITE_API_URL || "/api"; @@ -165,6 +167,30 @@ export const fetchMonitoringPoller = () => get("/api/monitoring/poller"); export const fetchMonitoringOverview = () => get("/api/dashboard/monitoring"); +export const fetchDashboardShortcuts = () => + get("/api/dashboard/shortcuts"); +export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) => + fetch( + buildUrl( + shortcut.id + ? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}` + : "/api/dashboard/shortcuts", + ), + { + method: shortcut.id ? "PUT" : "POST", + headers: buildHeaders(true), + body: JSON.stringify(shortcut), + }, + ).then(async (response) => { + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json() as Promise; + }); +export const deleteDashboardShortcut = (shortcutId: string) => + del<{ status: string }>( + `/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`, + ); export const fetchMonitoringStatus = (machineId?: string) => get( "/api/monitoring/status", diff --git a/frontend/src/components/MonitoringOverviewTable.tsx b/frontend/src/components/MonitoringOverviewTable.tsx index 820af0b..2ae8db2 100644 --- a/frontend/src/components/MonitoringOverviewTable.tsx +++ b/frontend/src/components/MonitoringOverviewTable.tsx @@ -50,7 +50,43 @@ function formatRate(bytes: number): string { function formatTime(epochSeconds: number | null): string { if (!epochSeconds) return "-"; - return new Date(epochSeconds * 1000).toLocaleString(); + const date = new Date(epochSeconds * 1000); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const min = String(date.getMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${min}`; +} + +function formatReadableTime(epochSeconds: number | null): string { + if (!epochSeconds) return "-"; + const date = new Date(epochSeconds * 1000); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function formatClockTime(epochSeconds: number | null): string { + if (!epochSeconds) return "-"; + const date = new Date(epochSeconds * 1000); + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function formatUpdatedDetails(epochSeconds: number | null): [string, string] { + if (!epochSeconds) return ["-", "-"]; + return [ + formatReadableTime(epochSeconds), + `${formatClockTime(epochSeconds)} · ${formatAge(epochSeconds)}`, + ]; } function formatAge(epochSeconds: number | null): string { @@ -111,40 +147,101 @@ function metricSortValue( } function metricCell(value: string, subtext?: string) { + const [minLine, maxLine] = (subtext || "").split("\n"); + const min = minLine?.replace(/^Low\s+/, "").trim(); + const max = maxLine?.replace(/^High\s+/, "").trim(); return ( - - {value} - - {subtext ? ( - - {subtext} - - ) : null} + + + 10m avg + + + {value} + + + + + {min ? ( + + ) : null} + {max ? ( + + ) : null} + ); } @@ -197,11 +294,7 @@ export function MonitoringOverviewTable({ size="small" variant="outlined" color={poller?.worker_running ? "success" : "default"} - label={ - poller?.worker_running - ? `Poller running · ${poller.interval_seconds}s` - : "Poller stopped" - } + label={poller?.worker_running ? "running" : "stopped"} /> - +
{ const machine = row.machine; const status = row.status || row.status_error || "-"; - const note = - row.metrics_error || - row.disk_error || - machine.notes || - row.disk?.mount || - ""; const cpu = formatSummary( row.cpu_summary, (value) => `${value.toFixed(1)}%`, @@ -457,28 +562,28 @@ export function MonitoringOverviewTable({ label={status} /> - + {metricCell(cpu.value, cpu.subtext)} - + {metricCell(iowait.value, iowait.subtext)} - + {metricCell(mem.value, mem.subtext)} - + {metricCell(netDown.value, netDown.subtext)} - + {metricCell(netUp.value, netUp.subtext)} - + {metricCell(diskRead.value, diskRead.subtext)} - + {metricCell(diskWrite.value, diskWrite.subtext)} - + {row.disk ? metricCell( row.disk.used_pct, @@ -487,22 +592,19 @@ export function MonitoringOverviewTable({ : "-"} - - - {formatTime(row.latest_sample?.ts ?? null)} - - {note && ( + + {formatUpdatedDetails( + row.latest_sample?.ts ?? null, + ).map((line) => ( - {note} + {line} - )} + ))} diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index 83a04ab..3d8302c 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -1,10 +1,14 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + deleteDashboardShortcut, fetchActivity, fetchCounts, + fetchDashboardShortcuts, fetchLibraries, fetchMonitoringOverview, + saveDashboardShortcut, } from "../api/client"; +import type { DashboardShortcutInput } from "../types"; export function useCounts(machineId?: string) { return useQuery({ @@ -40,3 +44,32 @@ export function useMonitoringOverview() { // Backward-compatible alias used by older code. export const useNowPlaying = useActivity; + +export function useDashboardShortcuts() { + return useQuery({ + queryKey: ["dashboard", "shortcuts"], + queryFn: fetchDashboardShortcuts, + refetchInterval: 30_000, + }); +} + +export function useSaveDashboardShortcut() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shortcut: DashboardShortcutInput) => + saveDashboardShortcut(shortcut), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboard", "shortcuts"] }); + }, + }); +} + +export function useDeleteDashboardShortcut() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shortcutId: string) => deleteDashboardShortcut(shortcutId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboard", "shortcuts"] }); + }, + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 00fb465..d376377 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,17 +1,391 @@ -import { Stack } from "@mui/material"; +import { useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Dialog, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormHelperText, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; import { useNavigate } from "react-router-dom"; -import { useActivity, useMonitoringOverview } from "../hooks/useDashboard"; +import { + useActivity, + useDashboardShortcuts, + useDeleteDashboardShortcut, + useMonitoringOverview, + useSaveDashboardShortcut, +} from "../hooks/useDashboard"; +import type { DashboardShortcut, DashboardShortcutInput } from "../types"; import { NowPlaying } from "../components/NowPlaying"; import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable"; import { SectionCard } from "../components/SectionCard"; +import { DialogFooter } from "../components/DialogFooter"; + +function emptyShortcut(): DashboardShortcutInput { + return { + id: null, + label: "", + shortcut_type: "website", + enabled: true, + icon: "", + url: "", + task_id: "", + machine_id: "", + user_id: "", + notes: "", + }; +} + +function normalizeWebsiteUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +function shortcutHref(shortcut: DashboardShortcut): string { + if (shortcut.shortcut_type === "website") { + return normalizeWebsiteUrl(shortcut.url); + } + if (shortcut.shortcut_type === "action") { + if (!shortcut.task_id) return ""; + const params = new URLSearchParams({ task: shortcut.task_id }); + if (shortcut.machine_id) params.set("machine_id", shortcut.machine_id); + return `/actions?${params.toString()}`; + } + if (!shortcut.user_id) return ""; + return `/users?user=${encodeURIComponent(shortcut.user_id)}`; +} + +function ShortcutDialog({ + open, + draft, + onChange, + onClose, + onSave, +}: { + open: boolean; + draft: DashboardShortcutInput; + onChange: (shortcut: DashboardShortcutInput) => void; + onClose: () => void; + onSave: () => void; +}) { + return ( + + {draft.id ? "Edit shortcut" : "New shortcut"} + + + + + onChange({ ...draft, label: e.target.value })} + /> + + + onChange({ ...draft, icon: e.target.value })} + helperText="Emoji or glyph" + /> + + + + Type + + + Website opens a URL. Saved actions jump to a task. Users + deep-link. + + + + + + {draft.shortcut_type === "website" ? ( + onChange({ ...draft, url: e.target.value })} + helperText="https:// is added if missing." + /> + ) : draft.shortcut_type === "action" ? ( + + + + onChange({ ...draft, task_id: e.target.value }) + } + helperText="Saved action ID." + /> + + + + onChange({ ...draft, machine_id: e.target.value }) + } + helperText="Optional machine target." + /> + + + ) : ( + onChange({ ...draft, user_id: e.target.value })} + helperText="Jellyfin user ID." + /> + )} + + onChange({ ...draft, notes: e.target.value })} + /> + + onChange({ ...draft, enabled: e.target.checked }) + } + /> + } + label="Enabled" + /> + + + + + ); +} + +function ShortcutCard({ + shortcut, + onOpen, + onEdit, + onDelete, +}: { + shortcut: DashboardShortcut; + onOpen: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const href = shortcutHref(shortcut); + const subtitle = + shortcut.shortcut_type === "website" + ? shortcut.url || "No URL configured" + : shortcut.shortcut_type === "action" + ? [ + shortcut.task_id || "task pending", + shortcut.machine_id + ? `machine ${shortcut.machine_id}` + : "any machine", + ].join(" · ") + : shortcut.user_id || "No user configured"; + + return ( + + + + + + + {shortcut.label} + + + {subtitle} + + + + {shortcut.icon ? ( + + {shortcut.icon} + + ) : null} + + + + {shortcut.notes ? ( + + {shortcut.notes} + + ) : null} + + + + + + + + + ); +} export function Dashboard() { const navigate = useNavigate(); const { data: activity } = useActivity(); const { data: monitoringOverview } = useMonitoringOverview(); + const { data: shortcuts = [] } = useDashboardShortcuts(); + const saveShortcut = useSaveDashboardShortcut(); + const deleteShortcut = useDeleteDashboardShortcut(); + const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false); + const [shortcutDraft, setShortcutDraft] = useState( + emptyShortcut(), + ); + + const openCreateShortcut = () => { + setShortcutDraft(emptyShortcut()); + setShortcutDialogOpen(true); + }; + + const openEditShortcut = (shortcut: DashboardShortcut) => { + setShortcutDraft({ + id: shortcut.id, + label: shortcut.label, + shortcut_type: shortcut.shortcut_type, + enabled: shortcut.enabled, + icon: shortcut.icon, + url: shortcut.url, + task_id: shortcut.task_id, + machine_id: shortcut.machine_id, + user_id: shortcut.user_id, + notes: shortcut.notes, + }); + setShortcutDialogOpen(true); + }; + + const saveShortcutDraft = async () => { + await saveShortcut.mutateAsync(shortcutDraft); + setShortcutDialogOpen(false); + setShortcutDraft(emptyShortcut()); + }; return ( + + Add shortcut + + } + > + {shortcuts.length ? ( + + {shortcuts.map((shortcut) => ( + + { + const href = shortcutHref(shortcut); + if (shortcut.shortcut_type === "website") { + window.open(href, "_blank", "noopener,noreferrer"); + } else if (href) { + navigate(href); + } + }} + onEdit={() => openEditShortcut(shortcut)} + onDelete={() => deleteShortcut.mutate(shortcut.id)} + /> + + ))} + + ) : ( + + No shortcuts yet. Add a website now, then add action or user + shortcuts later. + + )} + + + + setShortcutDialogOpen(false)} + onSave={saveShortcutDraft} + /> ); } diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index 626c836..4e050b9 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -63,9 +63,7 @@ export function Monitoring() { Fleet overview - {poller?.worker_running - ? `Poller running · ${poller.interval_seconds}s` - : "Poller stopped"} + {poller?.worker_running ? "running" : "stopped"} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2cafff5..e95b0c2 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -78,13 +78,20 @@ function MachineEditor({ hint, machine, sshKeys, + onChange, }: { title: string; hint?: string; machine: MonitoringMachineInput; sshKeys: SSHKey[]; + onChange: ( + draft: + | MonitoringMachineInput + | ((current: MonitoringMachineInput) => MonitoringMachineInput), + ) => void; }) { - const [draft, setDraft] = useState(machine); + const draft = machine; + const setDraft = onChange; const isLocal = draft.mode === "local"; const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id); const enabledServices = draft.services.length; @@ -246,34 +253,6 @@ function MachineEditor({ } /> - - - setDraft((current) => ({ - ...current, - key_directory: e.target.value, - })) - } - /> - - - - setDraft((current) => ({ - ...current, - key_name: e.target.value, - })) - } - /> - - - - setDraft((current) => ({ - ...current, - path_prefix: e.target.value, - })) - } - /> - {hasJellyfin && ( <> @@ -1049,15 +1014,15 @@ export function Settings() { host: machine.host, port: machine.port, username: machine.username, - key_directory: machine.key_directory, - key_name: machine.key_name, - ssh_key_id: machine.ssh_key_id, + key_directory: "", + key_name: "", + path_prefix: "", + ssh_key_id: machine.ssh_key_id, ssh_private_key: "", ssh_private_key_passphrase: "", password: "", media_root: machine.media_root, - path_prefix: machine.path_prefix, - jellyfin_url: machine.jellyfin_url, + jellyfin_url: machine.jellyfin_url, jellyfin_user_id: machine.jellyfin_user_id, jellyfin_api_key: "", jellyseerr_url: machine.jellyseerr_url, @@ -1148,12 +1113,6 @@ export function Settings() { value={selectedMachine.username} disabled /> - ) : (