fixes and improvements
This commit is contained in:
@@ -6,8 +6,10 @@ itself. They are used for the built-in local monitoring machine.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -55,6 +57,40 @@ class LocalCommandClient:
|
|||||||
)
|
)
|
||||||
return result
|
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
|
@staticmethod
|
||||||
def join(parent: str, child: str) -> str:
|
def join(parent: str, child: str) -> str:
|
||||||
return posixpath.normpath(posixpath.join(parent, child))
|
return posixpath.normpath(posixpath.join(parent, child))
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths
|
|||||||
"""Return a short human-readable status string for the dashboard."""
|
"""Return a short human-readable status string for the dashboard."""
|
||||||
command = f"""
|
command = f"""
|
||||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
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
|
else
|
||||||
echo "not running"
|
echo "not running"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from fastapi import Request
|
|||||||
|
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
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.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.services.known_hosts import ensure_known_host
|
from media_library_viewer_api.services.known_hosts import ensure_known_host
|
||||||
from media_library_viewer_api.config import get_settings
|
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)
|
return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key)
|
||||||
|
|
||||||
|
|
||||||
def get_ssh_client(request: Request = None) -> RemoteSSHClient:
|
def get_ssh_client(request: Request = None):
|
||||||
"""Return a cached SSH client for the selected machine or legacy env fallback."""
|
"""Return a command client for the selected machine or legacy env fallback."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
machine_id = _request_machine_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||||
if machine is None:
|
if machine is None:
|
||||||
machine_ref = _resolve_machine("ssh", request)
|
machine_ref = _resolve_machine("ssh", request)
|
||||||
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
|
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"):
|
if machine and machine.get("host") and machine.get("username"):
|
||||||
known_hosts_path = get_settings().ssh_known_hosts_file
|
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)
|
ensure_known_host(str(machine.get("host")), int(machine.get("port") or 22), known_hosts_path)
|
||||||
|
|||||||
@@ -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]]:
|
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import paramiko
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
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.db_maintenance import remove_sqlite_database
|
||||||
from media_library_viewer_api.services.media_index import MediaIndex
|
from media_library_viewer_api.services.media_index import MediaIndex
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
@@ -54,7 +54,9 @@ def post_machine(
|
|||||||
machine: MonitoringMachineInput,
|
machine: MonitoringMachineInput,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> 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}")
|
@router.put("/machines/{machine_id}")
|
||||||
@@ -65,7 +67,9 @@ def put_machine(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not store.get_machine(machine_id):
|
if not store.get_machine(machine_id):
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
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}")
|
@router.delete("/machines/{machine_id}")
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
"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(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time ON monitoring_machine_actions(machine_id, created_at DESC)"
|
"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()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def record_task_run(
|
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
self,
|
target = json.loads(row["target_json"] or "{}")
|
||||||
task: dict[str, Any],
|
return {
|
||||||
status: str,
|
"id": row["id"],
|
||||||
*,
|
"label": row["label"],
|
||||||
machine_id: str,
|
"shortcut_type": row["shortcut_type"],
|
||||||
machine_name: str,
|
"enabled": bool(row["enabled"]),
|
||||||
task_type: str,
|
"icon": target.get("icon", ""),
|
||||||
duration_ms: int,
|
"url": target.get("url", ""),
|
||||||
request_id: str = "",
|
"task_id": target.get("task_id", ""),
|
||||||
stdout_tail: str = "",
|
"machine_id": target.get("machine_id", ""),
|
||||||
stderr_tail: str = "",
|
"user_id": target.get("user_id", ""),
|
||||||
error: str = "",
|
"notes": target.get("notes", ""),
|
||||||
) -> None:
|
"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()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
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(
|
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)
|
INSERT INTO dashboard_shortcuts (id, label, shortcut_type, target_json, enabled, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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,
|
shortcut["id"],
|
||||||
str(task.get("id") or ""),
|
shortcut["label"],
|
||||||
str(task.get("name") or ""),
|
shortcut["shortcut_type"],
|
||||||
machine_id,
|
json.dumps(shortcut["target"]),
|
||||||
machine_name,
|
1 if shortcut["enabled"] else 0,
|
||||||
task_type,
|
created_at,
|
||||||
status,
|
now,
|
||||||
int(time.time()),
|
|
||||||
duration_ms,
|
|
||||||
request_id,
|
|
||||||
stdout_tail,
|
|
||||||
stderr_tail,
|
|
||||||
error,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
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
|
_store: SettingsStore | None = None
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
### Dashboard / Server Monitoring
|
### 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 CPU and RAM usage for the last hour.
|
||||||
- Show IO wait percentage 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: 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 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-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 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 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.
|
- 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 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 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-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: 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import type {
|
|||||||
ResolvedPath,
|
ResolvedPath,
|
||||||
ResetLocalDatabaseInput,
|
ResetLocalDatabaseInput,
|
||||||
ResetLocalDatabaseResponse,
|
ResetLocalDatabaseResponse,
|
||||||
|
DashboardShortcut,
|
||||||
|
DashboardShortcutInput,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
|
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
|
||||||
@@ -165,6 +167,30 @@ export const fetchMonitoringPoller = () =>
|
|||||||
get<MonitoringPollerStatus>("/api/monitoring/poller");
|
get<MonitoringPollerStatus>("/api/monitoring/poller");
|
||||||
export const fetchMonitoringOverview = () =>
|
export const fetchMonitoringOverview = () =>
|
||||||
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
|
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
|
||||||
|
export const fetchDashboardShortcuts = () =>
|
||||||
|
get<DashboardShortcut[]>("/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<DashboardShortcut>;
|
||||||
|
});
|
||||||
|
export const deleteDashboardShortcut = (shortcutId: string) =>
|
||||||
|
del<{ status: string }>(
|
||||||
|
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
||||||
|
);
|
||||||
export const fetchMonitoringStatus = (machineId?: string) =>
|
export const fetchMonitoringStatus = (machineId?: string) =>
|
||||||
get<MonitoringStatus>(
|
get<MonitoringStatus>(
|
||||||
"/api/monitoring/status",
|
"/api/monitoring/status",
|
||||||
|
|||||||
@@ -50,7 +50,43 @@ function formatRate(bytes: number): string {
|
|||||||
|
|
||||||
function formatTime(epochSeconds: number | null): string {
|
function formatTime(epochSeconds: number | null): string {
|
||||||
if (!epochSeconds) return "-";
|
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 {
|
function formatAge(epochSeconds: number | null): string {
|
||||||
@@ -111,40 +147,101 @@ function metricSortValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function metricCell(value: string, subtext?: string) {
|
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 (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
spacing={0.1}
|
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
minWidth: 110,
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 96,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
py: 0.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Box
|
||||||
variant="body2"
|
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
flex: 1,
|
||||||
fontSize: "0.95rem",
|
display: "flex",
|
||||||
lineHeight: 1.1,
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.15,
|
||||||
|
borderRadius: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
<Stack spacing={0.1} sx={{ alignItems: "center" }}>
|
||||||
</Typography>
|
<Typography
|
||||||
{subtext ? (
|
variant="caption"
|
||||||
<Typography
|
color="text.secondary"
|
||||||
variant="caption"
|
sx={{
|
||||||
color="text.secondary"
|
fontSize: "0.62rem",
|
||||||
sx={{
|
lineHeight: 1,
|
||||||
whiteSpace: "pre-line",
|
textTransform: "uppercase",
|
||||||
lineHeight: 1.0,
|
letterSpacing: "0.04em",
|
||||||
fontSize: "0.64rem",
|
}}
|
||||||
opacity: 0.9,
|
>
|
||||||
}}
|
10m avg
|
||||||
>
|
</Typography>
|
||||||
{subtext}
|
<Typography
|
||||||
</Typography>
|
variant="body1"
|
||||||
) : null}
|
sx={{
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: "1.14rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Stack
|
||||||
|
spacing={0.25}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
pt: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{min ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={`Min ${min}`}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 18,
|
||||||
|
"& .MuiChip-label": {
|
||||||
|
px: 0.5,
|
||||||
|
py: 0,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{max ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={`Max ${max}`}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 18,
|
||||||
|
"& .MuiChip-label": {
|
||||||
|
px: 0.5,
|
||||||
|
py: 0,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,11 +294,7 @@ export function MonitoringOverviewTable({
|
|||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color={poller?.worker_running ? "success" : "default"}
|
color={poller?.worker_running ? "success" : "default"}
|
||||||
label={
|
label={poller?.worker_running ? "running" : "stopped"}
|
||||||
poller?.worker_running
|
|
||||||
? `Poller running · ${poller.interval_seconds}s`
|
|
||||||
: "Poller stopped"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -230,12 +323,30 @@ export function MonitoringOverviewTable({
|
|||||||
border: 1,
|
border: 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
overflow: "hidden",
|
overflowX: "auto",
|
||||||
|
overflowY: "hidden",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table size="small">
|
<Table
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
minWidth: 1280,
|
||||||
|
tableLayout: "fixed",
|
||||||
|
"& .MuiTableCell-root": {
|
||||||
|
px: 1.1,
|
||||||
|
py: 0.9,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
},
|
||||||
|
"& .MuiTableHead .MuiTableCell-root": {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
@@ -393,12 +504,6 @@ export function MonitoringOverviewTable({
|
|||||||
sortedRows.map((row) => {
|
sortedRows.map((row) => {
|
||||||
const machine = row.machine;
|
const machine = row.machine;
|
||||||
const status = row.status || row.status_error || "-";
|
const status = row.status || row.status_error || "-";
|
||||||
const note =
|
|
||||||
row.metrics_error ||
|
|
||||||
row.disk_error ||
|
|
||||||
machine.notes ||
|
|
||||||
row.disk?.mount ||
|
|
||||||
"";
|
|
||||||
const cpu = formatSummary(
|
const cpu = formatSummary(
|
||||||
row.cpu_summary,
|
row.cpu_summary,
|
||||||
(value) => `${value.toFixed(1)}%`,
|
(value) => `${value.toFixed(1)}%`,
|
||||||
@@ -457,28 +562,28 @@ export function MonitoringOverviewTable({
|
|||||||
label={status}
|
label={status}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(cpu.value, cpu.subtext)}
|
{metricCell(cpu.value, cpu.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(iowait.value, iowait.subtext)}
|
{metricCell(iowait.value, iowait.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(mem.value, mem.subtext)}
|
{metricCell(mem.value, mem.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(netDown.value, netDown.subtext)}
|
{metricCell(netDown.value, netDown.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(netUp.value, netUp.subtext)}
|
{metricCell(netUp.value, netUp.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(diskRead.value, diskRead.subtext)}
|
{metricCell(diskRead.value, diskRead.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{metricCell(diskWrite.value, diskWrite.subtext)}
|
{metricCell(diskWrite.value, diskWrite.subtext)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
{row.disk
|
{row.disk
|
||||||
? metricCell(
|
? metricCell(
|
||||||
row.disk.used_pct,
|
row.disk.used_pct,
|
||||||
@@ -487,22 +592,19 @@ export function MonitoringOverviewTable({
|
|||||||
: "-"}
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack spacing={0.25}>
|
<Stack spacing={0.15} sx={{ alignItems: "center" }}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
{formatUpdatedDetails(
|
||||||
{formatTime(row.latest_sample?.ts ?? null)}
|
row.latest_sample?.ts ?? null,
|
||||||
</Typography>
|
).map((line) => (
|
||||||
{note && (
|
|
||||||
<Typography
|
<Typography
|
||||||
|
key={line}
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color={
|
color="text.secondary"
|
||||||
row.metrics_error || row.disk_error
|
sx={{ lineHeight: 1.1 }}
|
||||||
? "error.main"
|
|
||||||
: "text.secondary"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{note}
|
{line}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
deleteDashboardShortcut,
|
||||||
fetchActivity,
|
fetchActivity,
|
||||||
fetchCounts,
|
fetchCounts,
|
||||||
|
fetchDashboardShortcuts,
|
||||||
fetchLibraries,
|
fetchLibraries,
|
||||||
fetchMonitoringOverview,
|
fetchMonitoringOverview,
|
||||||
|
saveDashboardShortcut,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
import type { DashboardShortcutInput } from "../types";
|
||||||
|
|
||||||
export function useCounts(machineId?: string) {
|
export function useCounts(machineId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -40,3 +44,32 @@ export function useMonitoringOverview() {
|
|||||||
|
|
||||||
// Backward-compatible alias used by older code.
|
// Backward-compatible alias used by older code.
|
||||||
export const useNowPlaying = useActivity;
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 { NowPlaying } from "../components/NowPlaying";
|
||||||
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
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 (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||||
|
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Label"
|
||||||
|
value={draft.label}
|
||||||
|
onChange={(e) => onChange({ ...draft, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Icon"
|
||||||
|
value={draft.icon}
|
||||||
|
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||||
|
helperText="Emoji or glyph"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
value={draft.shortcut_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
shortcut_type: e.target
|
||||||
|
.value as DashboardShortcutInput["shortcut_type"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="website">Website</MenuItem>
|
||||||
|
<MenuItem value="action">Saved action</MenuItem>
|
||||||
|
<MenuItem value="user">User</MenuItem>
|
||||||
|
</Select>
|
||||||
|
<FormHelperText>
|
||||||
|
Website opens a URL. Saved actions jump to a task. Users
|
||||||
|
deep-link.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{draft.shortcut_type === "website" ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Website URL"
|
||||||
|
value={draft.url}
|
||||||
|
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||||
|
helperText="https:// is added if missing."
|
||||||
|
/>
|
||||||
|
) : draft.shortcut_type === "action" ? (
|
||||||
|
<Grid container spacing={1.25}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Task ID"
|
||||||
|
value={draft.task_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, task_id: e.target.value })
|
||||||
|
}
|
||||||
|
helperText="Saved action ID."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Machine ID"
|
||||||
|
value={draft.machine_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, machine_id: e.target.value })
|
||||||
|
}
|
||||||
|
helperText="Optional machine target."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="User ID"
|
||||||
|
value={draft.user_id}
|
||||||
|
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
|
||||||
|
helperText="Jellyfin user ID."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, enabled: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enabled"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={onSave}
|
||||||
|
confirmLabel="Save shortcut"
|
||||||
|
confirmBusyLabel="Save shortcut"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||||
|
<CardContent sx={{ p: 1.25 }}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700 }} noWrap>
|
||||||
|
{shortcut.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" noWrap>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
{shortcut.icon ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shortcut.icon}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={shortcut.shortcut_type}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{shortcut.notes ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{shortcut.notes}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!shortcut.enabled || !href}
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" onClick={onEdit}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: activity } = useActivity();
|
const { data: activity } = useActivity();
|
||||||
const { data: monitoringOverview } = useMonitoringOverview();
|
const { data: monitoringOverview } = useMonitoringOverview();
|
||||||
|
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||||
|
const saveShortcut = useSaveDashboardShortcut();
|
||||||
|
const deleteShortcut = useDeleteDashboardShortcut();
|
||||||
|
const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false);
|
||||||
|
const [shortcutDraft, setShortcutDraft] = useState<DashboardShortcutInput>(
|
||||||
|
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 (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<Stack spacing={2.25}>
|
||||||
|
<SectionCard
|
||||||
|
title="Shortcuts"
|
||||||
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
|
action={
|
||||||
|
<Button variant="outlined" onClick={openCreateShortcut}>
|
||||||
|
Add shortcut
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shortcuts.length ? (
|
||||||
|
<Grid container spacing={1.25}>
|
||||||
|
{shortcuts.map((shortcut) => (
|
||||||
|
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
|
||||||
|
<ShortcutCard
|
||||||
|
shortcut={shortcut}
|
||||||
|
onOpen={() => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info">
|
||||||
|
No shortcuts yet. Add a website now, then add action or user
|
||||||
|
shortcuts later.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Jellyfin activity"
|
title="Jellyfin activity"
|
||||||
description="Live sessions and idle users from Jellyfin."
|
description="Live sessions and idle users from Jellyfin."
|
||||||
@@ -32,6 +406,14 @@ export function Dashboard() {
|
|||||||
>
|
>
|
||||||
<MonitoringOverviewTable overview={monitoringOverview} embedded />
|
<MonitoringOverviewTable overview={monitoringOverview} embedded />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
<ShortcutDialog
|
||||||
|
open={shortcutDialogOpen}
|
||||||
|
draft={shortcutDraft}
|
||||||
|
onChange={setShortcutDraft}
|
||||||
|
onClose={() => setShortcutDialogOpen(false)}
|
||||||
|
onSave={saveShortcutDraft}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ export function Monitoring() {
|
|||||||
Fleet overview
|
Fleet overview
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{poller?.worker_running
|
{poller?.worker_running ? "running" : "stopped"}
|
||||||
? `Poller running · ${poller.interval_seconds}s`
|
|
||||||
: "Poller stopped"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -78,13 +78,20 @@ function MachineEditor({
|
|||||||
hint,
|
hint,
|
||||||
machine,
|
machine,
|
||||||
sshKeys,
|
sshKeys,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
machine: MonitoringMachineInput;
|
machine: MonitoringMachineInput;
|
||||||
sshKeys: SSHKey[];
|
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 isLocal = draft.mode === "local";
|
||||||
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
||||||
const enabledServices = draft.services.length;
|
const enabledServices = draft.services.length;
|
||||||
@@ -246,34 +253,6 @@ function MachineEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Key directory"
|
|
||||||
value={draft.key_directory}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
key_directory: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Key name"
|
|
||||||
value={draft.key_name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
key_name: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -305,20 +284,6 @@ function MachineEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Path prefix"
|
|
||||||
value={draft.path_prefix}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
path_prefix: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
{hasJellyfin && (
|
{hasJellyfin && (
|
||||||
<>
|
<>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
@@ -1049,15 +1014,15 @@ export function Settings() {
|
|||||||
host: machine.host,
|
host: machine.host,
|
||||||
port: machine.port,
|
port: machine.port,
|
||||||
username: machine.username,
|
username: machine.username,
|
||||||
key_directory: machine.key_directory,
|
key_directory: "",
|
||||||
key_name: machine.key_name,
|
key_name: "",
|
||||||
ssh_key_id: machine.ssh_key_id,
|
path_prefix: "",
|
||||||
|
ssh_key_id: machine.ssh_key_id,
|
||||||
ssh_private_key: "",
|
ssh_private_key: "",
|
||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: machine.media_root,
|
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_user_id: machine.jellyfin_user_id,
|
||||||
jellyfin_api_key: "",
|
jellyfin_api_key: "",
|
||||||
jellyseerr_url: machine.jellyseerr_url,
|
jellyseerr_url: machine.jellyseerr_url,
|
||||||
@@ -1148,12 +1113,6 @@ export function Settings() {
|
|||||||
value={selectedMachine.username}
|
value={selectedMachine.username}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Path prefix"
|
|
||||||
value={selectedMachine.path_prefix}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1181,15 +1140,15 @@ export function Settings() {
|
|||||||
host: selectedMachine.host,
|
host: selectedMachine.host,
|
||||||
port: selectedMachine.port,
|
port: selectedMachine.port,
|
||||||
username: selectedMachine.username,
|
username: selectedMachine.username,
|
||||||
key_directory: selectedMachine.key_directory,
|
key_directory: "",
|
||||||
key_name: selectedMachine.key_name,
|
key_name: "",
|
||||||
ssh_key_id: selectedMachine.ssh_key_id,
|
path_prefix: "",
|
||||||
|
ssh_key_id: selectedMachine.ssh_key_id,
|
||||||
ssh_private_key: "",
|
ssh_private_key: "",
|
||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: selectedMachine.media_root,
|
media_root: selectedMachine.media_root,
|
||||||
path_prefix: selectedMachine.path_prefix,
|
jellyfin_url: selectedMachine.jellyfin_url,
|
||||||
jellyfin_url: selectedMachine.jellyfin_url,
|
|
||||||
jellyfin_user_id:
|
jellyfin_user_id:
|
||||||
selectedMachine.jellyfin_user_id,
|
selectedMachine.jellyfin_user_id,
|
||||||
jellyfin_api_key: "",
|
jellyfin_api_key: "",
|
||||||
@@ -1247,6 +1206,7 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
machine={machineDraft}
|
machine={machineDraft}
|
||||||
sshKeys={sshKeys}
|
sshKeys={sshKeys}
|
||||||
|
onChange={setMachineDraft}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
|
|||||||
@@ -458,3 +458,31 @@ export interface ResolvedPath {
|
|||||||
original: string;
|
original: string;
|
||||||
resolved: string;
|
resolved: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardShortcut {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
shortcut_type: "website" | "action" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
icon: string;
|
||||||
|
url: string;
|
||||||
|
task_id: string;
|
||||||
|
machine_id: string;
|
||||||
|
user_id: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardShortcutInput {
|
||||||
|
id?: string | null;
|
||||||
|
label: string;
|
||||||
|
shortcut_type: "website" | "action" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
icon: string;
|
||||||
|
url: string;
|
||||||
|
task_id: string;
|
||||||
|
machine_id: string;
|
||||||
|
user_id: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user