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 (