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
|
||||
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user