diff --git a/backend/src/media_library_viewer_api/routers/settings.py b/backend/src/media_library_viewer_api/routers/settings.py index 97a393f..5d97485 100644 --- a/backend/src/media_library_viewer_api/routers/settings.py +++ b/backend/src/media_library_viewer_api/routers/settings.py @@ -55,7 +55,9 @@ def post_machine( store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) - get_monitoring_poller().start() + poller = get_monitoring_poller() + poller.start() + poller.kick() return saved @@ -68,7 +70,9 @@ def put_machine( if not store.get_machine(machine_id): raise HTTPException(status_code=404, detail="Machine not found") saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) - get_monitoring_poller().start() + poller = get_monitoring_poller() + poller.start() + poller.kick() return saved @@ -85,6 +89,8 @@ class SSHKeyInput(BaseModel): name: str = Field(default="") private_key: str = Field(default="") passphrase: str = Field(default="") + public_key: str = Field(default="") + fingerprint: str = Field(default="") notes: str = Field(default="") @@ -104,12 +110,14 @@ def generate_ssh_key( key.write_private_key(private_buffer, password=payload.passphrase or None) private_key = private_buffer.getvalue() public_key = f"{key.get_name()} {key.get_base64()}" + fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint()) return { "name": payload.name, "private_key": private_key, "passphrase": payload.passphrase, "notes": payload.notes, "public_key": public_key, + "fingerprint": fingerprint, } diff --git a/backend/src/media_library_viewer_api/routers/tasks.py b/backend/src/media_library_viewer_api/routers/tasks.py index 6c66689..110d51a 100644 --- a/backend/src/media_library_viewer_api/routers/tasks.py +++ b/backend/src/media_library_viewer_api/routers/tasks.py @@ -159,6 +159,8 @@ def run_task( machine_name = _machine_label(machine) try: result = client.run(command, timeout=1200) + stdout = result.stdout or "" + stderr = result.stderr or "" status_text = "success" if result.exit_status == 0 else "error" store.record_task_run( task, @@ -167,9 +169,9 @@ def run_task( machine_name=machine_name, task_type=task_type, duration_ms=int((time.perf_counter() - start) * 1000), - stdout_tail=result.stdout[-4000:], - stderr_tail=result.stderr[-4000:], - error="" if result.exit_status == 0 else (result.stderr or result.stdout or "Task failed"), + stdout_tail=stdout[-4000:], + stderr_tail=stderr[-4000:], + error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"), ) return { "task_id": task["id"], @@ -178,20 +180,31 @@ def run_task( "machine_name": machine_name, "task_type": task_type, "exit_status": result.exit_status, - "stdout": result.stdout, - "stderr": result.stderr, + "stdout": stdout, + "stderr": stderr, } except Exception as exc: + duration_ms = int((time.perf_counter() - start) * 1000) + error_text = str(exc) store.record_task_run( task, "error", machine_id=str(machine.get("id") or ""), machine_name=machine_name, task_type=task_type, - duration_ms=int((time.perf_counter() - start) * 1000), + duration_ms=duration_ms, stdout_tail="", - stderr_tail="", - error=str(exc), + stderr_tail=error_text[-4000:], + error=error_text, ) logger.exception("Task execution failed task_id=%s", task["id"]) - raise + return { + "task_id": task["id"], + "task_name": task["name"], + "machine_id": str(machine.get("id") or ""), + "machine_name": machine_name, + "task_type": task_type, + "exit_status": 1, + "stdout": "", + "stderr": error_text, + } diff --git a/backend/src/media_library_viewer_api/services/monitoring_poller.py b/backend/src/media_library_viewer_api/services/monitoring_poller.py index 23dce06..8c2739b 100644 --- a/backend/src/media_library_viewer_api/services/monitoring_poller.py +++ b/backend/src/media_library_viewer_api/services/monitoring_poller.py @@ -64,6 +64,18 @@ class MonitoringPoller: self._thread.start() logger.info("Monitoring poller started") + def kick(self) -> None: + """Run one immediate snapshot cycle in the background.""" + store = get_settings_store() + config = self._config() + threading.Thread( + target=self._run_cycle, + args=(store, config), + name="monitoring-poller-kick", + daemon=True, + ).start() + logger.info("Monitoring poller kick requested") + def stop(self, timeout: float = 5.0) -> None: """Stop the worker thread and wait briefly for shutdown.""" with self._lock: 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 35fdb0a..9332cb3 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -79,12 +79,21 @@ class SettingsStore: name TEXT NOT NULL, private_key TEXT NOT NULL, passphrase TEXT NOT NULL, + public_key TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) """ ) + existing_key_columns = { + row[1] for row in conn.execute("PRAGMA table_info(ssh_keys)").fetchall() + } + if "public_key" not in existing_key_columns: + conn.execute("ALTER TABLE ssh_keys ADD COLUMN public_key TEXT NOT NULL DEFAULT ''") + if "fingerprint" not in existing_key_columns: + conn.execute("ALTER TABLE ssh_keys ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''") conn.execute( """ CREATE TABLE IF NOT EXISTS saved_tasks ( @@ -486,13 +495,15 @@ class SettingsStore: def _row_to_ssh_key(self, row: sqlite3.Row, usage_count: int = 0) -> dict[str, Any]: summary = self._private_key_summary(str(row["private_key"] or "")) + public_key = str(row["public_key"] or summary["public_key"] or "") + fingerprint = str(row["fingerprint"] or summary["fingerprint"] or "") return { "id": row["id"], "name": row["name"], "private_key_set": bool(row["private_key"]), "passphrase_set": bool(row["passphrase"]), - "public_key": summary["public_key"], - "fingerprint": summary["fingerprint"], + "public_key": public_key, + "fingerprint": fingerprint, "usage_count": usage_count, "notes": row["notes"], "created_at": row["created_at"], @@ -512,7 +523,10 @@ class SettingsStore: passphrase = (current or {}).get("passphrase", "") passphrase = str(passphrase or "") notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() - return {"id": key_id, "name": name, "private_key": private_key, "passphrase": passphrase, "notes": notes} + summary = self._private_key_summary(private_key) + public_key = str(payload.get("public_key") if payload.get("public_key") is not None else (current or {}).get("public_key", "") or summary["public_key"] or "").strip() + fingerprint = str(payload.get("fingerprint") if payload.get("fingerprint") is not None else (current or {}).get("fingerprint", "") or summary["fingerprint"] or "").strip() + return {"id": key_id, "name": name, "private_key": private_key, "passphrase": passphrase, "public_key": public_key, "fingerprint": fingerprint, "notes": notes} def list_ssh_keys(self) -> list[dict[str, Any]]: self.init_schema() @@ -535,7 +549,7 @@ class SettingsStore: if not row: return None summary = self._private_key_summary(str(row["private_key"] or "")) - return {"id": row["id"], "name": row["name"], "private_key": row["private_key"], "passphrase": row["passphrase"], "notes": row["notes"], "public_key": summary["public_key"], "fingerprint": summary["fingerprint"]} + return {"id": row["id"], "name": row["name"], "private_key": row["private_key"], "passphrase": row["passphrase"], "notes": row["notes"], "public_key": str(row["public_key"] or summary["public_key"] or ""), "fingerprint": str(row["fingerprint"] or summary["fingerprint"] or "")} def upsert_ssh_key(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]: self.init_schema() @@ -546,16 +560,18 @@ class SettingsStore: created_at = int(existing[0]) if existing else now conn.execute( """ - INSERT INTO ssh_keys (id, name, private_key, passphrase, notes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO ssh_keys (id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, private_key = excluded.private_key, passphrase = excluded.passphrase, + public_key = excluded.public_key, + fingerprint = excluded.fingerprint, notes = excluded.notes, updated_at = excluded.updated_at """, - (key["id"], key["name"], key["private_key"], key["passphrase"], key["notes"], created_at, now), + (key["id"], key["name"], key["private_key"], key["passphrase"], key["public_key"], key["fingerprint"], key["notes"], created_at, now), ) return self.get_ssh_key(key["id"]) or key @@ -644,6 +660,46 @@ 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: + self.init_schema() + now = int(time.time()) + with self.connect() as conn: + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + uuid.uuid4().hex, + str(task.get("id") or ""), + str(task.get("name") or ""), + machine_id, + machine_name, + task_type, + status, + now, + duration_ms, + request_id, + stdout_tail, + stderr_tail, + error, + ), + ) + def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]: target = json.loads(row["target_json"] or "{}") return { diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e8c8c3a..8a27e20 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -229,7 +229,10 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 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: The monitoring overview table was rewritten so the machine, mode, status, metric, and updated columns each have explicit widths and the metric cells use a clean two-tier layout with full-width min/max rows. - 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-07: Creating or updating a monitoring machine should kick the poller immediately so the backend starts collecting snapshots right away instead of waiting for the next interval. +- 2026-05-07: The monitoring overview machine cell should reserve more room for the machine name and mode columns so the name does not overlap the mode text. - 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. @@ -247,6 +250,9 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 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: SSH key records should persist and display the derived public key and fingerprint, not just the private key blob, so imports and generated keys are auditable without recomputation. +- 2026-05-07: SSH machine creation/editing should present a saved-key dropdown and warn when no SSH keys exist yet, instead of forcing manual key-id entry. +- 2026-05-07: Saved task runs should return structured failure output for local execution problems instead of surfacing a generic 500 error. - 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. diff --git a/frontend/src/components/MonitoringOverviewTable.tsx b/frontend/src/components/MonitoringOverviewTable.tsx index 2ae8db2..4d9f575 100644 --- a/frontend/src/components/MonitoringOverviewTable.tsx +++ b/frontend/src/components/MonitoringOverviewTable.tsx @@ -48,15 +48,12 @@ function formatRate(bytes: number): string { return `${formatBytes(bytes)}/s`; } -function formatTime(epochSeconds: number | null): string { +function formatAge(epochSeconds: number | null): string { if (!epochSeconds) return "-"; - 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}`; + const diff = Date.now() / 1000 - epochSeconds; + if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`; + if (diff < 3600) return `${Math.round(diff / 60)}m ago`; + return `${Math.round(diff / 3600)}h ago`; } function formatReadableTime(epochSeconds: number | null): string { @@ -89,22 +86,15 @@ function formatUpdatedDetails(epochSeconds: number | null): [string, string] { ]; } -function formatAge(epochSeconds: number | null): string { - if (!epochSeconds) return "-"; - const diff = Date.now() / 1000 - epochSeconds; - if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`; - if (diff < 3600) return `${Math.round(diff / 60)}m ago`; - return `${Math.round(diff / 3600)}h ago`; -} - function formatSummary( summary: { avg: number; min: number; max: number } | null, formatter: (value: number) => string, ) { - if (!summary) return { value: "-", subtext: "" }; + if (!summary) return { value: "-", min: "", max: "" }; return { value: formatter(summary.avg), - subtext: `Low ${formatter(summary.min)}\nHigh ${formatter(summary.max)}`, + min: formatter(summary.min), + max: formatter(summary.max), }; } @@ -146,19 +136,25 @@ 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(); +function MetricCell({ + value, + min, + max, +}: { + value: string; + min?: string; + max?: string; +}) { return ( - + - + {min ? ( - + > + Min {min} + ) : null} {max ? ( - + > + Max {max} + ) : null} @@ -302,8 +301,11 @@ export function MonitoringOverviewTable({ label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`} /> - Last success: {formatTime(poller?.last_success_at ?? null)} · last - run: {formatAge(poller?.last_run_at ?? null)} + Last success:{" "} + {poller?.last_success_at + ? new Date(poller.last_success_at * 1000).toLocaleString() + : "-"}{" "} + · last run: {formatAge(poller?.last_run_at ?? null)} ) : null} @@ -326,13 +328,16 @@ export function MonitoringOverviewTable({ overflowX: "auto", overflowY: "hidden", } - : undefined + : { + maxWidth: "100%", + overflowX: "auto", + } } > - - setSort("cpu")} + {[ + ["cpu", "CPU"], + ["iowait", "IO wait"], + ["mem", "RAM"], + ["net_rx", "Net down"], + ["net_tx", "Net up"], + ["disk_read", "Disk read"], + ["disk_write", "Disk write"], + ["disk_used", "Disk used"], + ].map(([key, label]) => ( + - CPU - - - - setSort("iowait")} - > - IO wait - - - - setSort("mem")} - > - RAM - - - - setSort("net_rx")} - > - Net down - - - - setSort("net_tx")} - > - Net up - - - - setSort("disk_read")} - > - Disk read - - - - setSort("disk_write")} - > - Disk write - - - - setSort("disk_used")} - > - Disk used - - + setSort(key as SortKey)} + > + {label} + + + ))} - - + + - + {machine.name} {!machine.enabled && ( @@ -546,15 +507,24 @@ export function MonitoringOverviewTable({ /> )} - + {machine.mode === "local" ? "Local API host" : `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`} - {machine.mode} - + {machine.mode} + - {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, - `Used ${formatBytes(row.disk.used)}\nAvail ${formatBytes(row.disk.available)}`, - ) - : "-"} + {disk ? ( + + ) : ( + "-" + )} - - - {formatUpdatedDetails( - row.latest_sample?.ts ?? null, - ).map((line) => ( + + + {updated.map((line) => ( @@ -139,7 +135,6 @@ function MachineEditor({ /> - - + setDraft((current) => ({ @@ -251,7 +247,19 @@ function MachineEditor({ ssh_key_id: e.target.value, })) } - /> + helperText={ + sshKeys.length > 0 + ? "Select a saved SSH key." + : "No SSH keys are saved yet. Add one in the SSH Keys tab." + } + > + No key selected + {sshKeys.map((key) => ( + + {key.name} + + ))} + {" "} - - {selectedSSHKey && ( + {selectedSSHKey ? ( Selected key: {selectedSSHKey.name} {selectedSSHKey.fingerprint ? ` · ${selectedSSHKey.fingerprint}` : ""} - )} + ) : !isLocal && sshKeys.length === 0 ? ( + + No SSH keys have been saved yet. Add one before configuring SSH + machines. + + ) : draft.ssh_key_id ? ( + + The selected SSH key was not found. + + ) : null} {!isLocal && !hasJellyfin && ( SSH machines usually need monitoring or files enabled. @@ -411,7 +427,6 @@ function MachineEditor({ ); } - function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { const saveKey = useSaveSSHKey(); const generateKey = useGenerateSSHKey(); @@ -420,20 +435,22 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { id: null, name: "", private_key: "", + public_key: "", + fingerprint: "", passphrase: "", notes: "", }); const editing = Boolean(draft.id); - const clear = () => setDraft({ id: null, name: "", private_key: "", passphrase: "", + public_key: "", + fingerprint: "", notes: "", }); - return ( @@ -461,7 +478,6 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { label={`${sshKeys.length} saved`} /> - -