fixes and improvements

This commit is contained in:
2026-05-07 13:48:10 +02:00
parent bba23165ab
commit efc9b247c7
8 changed files with 344 additions and 245 deletions
@@ -55,7 +55,9 @@ def post_machine(
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
saved = 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() poller = get_monitoring_poller()
poller.start()
poller.kick()
return saved return saved
@@ -68,7 +70,9 @@ def put_machine(
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")
saved = 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() poller = get_monitoring_poller()
poller.start()
poller.kick()
return saved return saved
@@ -85,6 +89,8 @@ class SSHKeyInput(BaseModel):
name: str = Field(default="") name: str = Field(default="")
private_key: str = Field(default="") private_key: str = Field(default="")
passphrase: str = Field(default="") passphrase: str = Field(default="")
public_key: str = Field(default="")
fingerprint: str = Field(default="")
notes: 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) key.write_private_key(private_buffer, password=payload.passphrase or None)
private_key = private_buffer.getvalue() private_key = private_buffer.getvalue()
public_key = f"{key.get_name()} {key.get_base64()}" public_key = f"{key.get_name()} {key.get_base64()}"
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())
return { return {
"name": payload.name, "name": payload.name,
"private_key": private_key, "private_key": private_key,
"passphrase": payload.passphrase, "passphrase": payload.passphrase,
"notes": payload.notes, "notes": payload.notes,
"public_key": public_key, "public_key": public_key,
"fingerprint": fingerprint,
} }
@@ -159,6 +159,8 @@ def run_task(
machine_name = _machine_label(machine) machine_name = _machine_label(machine)
try: try:
result = client.run(command, timeout=1200) result = client.run(command, timeout=1200)
stdout = result.stdout or ""
stderr = result.stderr or ""
status_text = "success" if result.exit_status == 0 else "error" status_text = "success" if result.exit_status == 0 else "error"
store.record_task_run( store.record_task_run(
task, task,
@@ -167,9 +169,9 @@ def run_task(
machine_name=machine_name, machine_name=machine_name,
task_type=task_type, task_type=task_type,
duration_ms=int((time.perf_counter() - start) * 1000), duration_ms=int((time.perf_counter() - start) * 1000),
stdout_tail=result.stdout[-4000:], stdout_tail=stdout[-4000:],
stderr_tail=result.stderr[-4000:], stderr_tail=stderr[-4000:],
error="" if result.exit_status == 0 else (result.stderr or result.stdout or "Task failed"), error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
) )
return { return {
"task_id": task["id"], "task_id": task["id"],
@@ -178,20 +180,31 @@ def run_task(
"machine_name": machine_name, "machine_name": machine_name,
"task_type": task_type, "task_type": task_type,
"exit_status": result.exit_status, "exit_status": result.exit_status,
"stdout": result.stdout, "stdout": stdout,
"stderr": result.stderr, "stderr": stderr,
} }
except Exception as exc: except Exception as exc:
duration_ms = int((time.perf_counter() - start) * 1000)
error_text = str(exc)
store.record_task_run( store.record_task_run(
task, task,
"error", "error",
machine_id=str(machine.get("id") or ""), machine_id=str(machine.get("id") or ""),
machine_name=machine_name, machine_name=machine_name,
task_type=task_type, task_type=task_type,
duration_ms=int((time.perf_counter() - start) * 1000), duration_ms=duration_ms,
stdout_tail="", stdout_tail="",
stderr_tail="", stderr_tail=error_text[-4000:],
error=str(exc), error=error_text,
) )
logger.exception("Task execution failed task_id=%s", task["id"]) 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,
}
@@ -64,6 +64,18 @@ class MonitoringPoller:
self._thread.start() self._thread.start()
logger.info("Monitoring poller started") 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: def stop(self, timeout: float = 5.0) -> None:
"""Stop the worker thread and wait briefly for shutdown.""" """Stop the worker thread and wait briefly for shutdown."""
with self._lock: with self._lock:
@@ -79,12 +79,21 @@ class SettingsStore:
name TEXT NOT NULL, name TEXT NOT NULL,
private_key TEXT NOT NULL, private_key TEXT NOT NULL,
passphrase TEXT NOT NULL, passphrase TEXT NOT NULL,
public_key TEXT NOT NULL DEFAULT '',
fingerprint TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL, notes TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_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( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS saved_tasks ( 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]: 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 "")) 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 { return {
"id": row["id"], "id": row["id"],
"name": row["name"], "name": row["name"],
"private_key_set": bool(row["private_key"]), "private_key_set": bool(row["private_key"]),
"passphrase_set": bool(row["passphrase"]), "passphrase_set": bool(row["passphrase"]),
"public_key": summary["public_key"], "public_key": public_key,
"fingerprint": summary["fingerprint"], "fingerprint": fingerprint,
"usage_count": usage_count, "usage_count": usage_count,
"notes": row["notes"], "notes": row["notes"],
"created_at": row["created_at"], "created_at": row["created_at"],
@@ -512,7 +523,10 @@ class SettingsStore:
passphrase = (current or {}).get("passphrase", "") passphrase = (current or {}).get("passphrase", "")
passphrase = str(passphrase or "") passphrase = str(passphrase or "")
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() 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]]: def list_ssh_keys(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
@@ -535,7 +549,7 @@ class SettingsStore:
if not row: if not row:
return None return None
summary = self._private_key_summary(str(row["private_key"] or "")) 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]: def upsert_ssh_key(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]:
self.init_schema() self.init_schema()
@@ -546,16 +560,18 @@ class SettingsStore:
created_at = int(existing[0]) if existing else now created_at = int(existing[0]) if existing else now
conn.execute( conn.execute(
""" """
INSERT INTO ssh_keys (id, name, private_key, passphrase, notes, created_at, updated_at) INSERT INTO ssh_keys (id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
private_key = excluded.private_key, private_key = excluded.private_key,
passphrase = excluded.passphrase, passphrase = excluded.passphrase,
public_key = excluded.public_key,
fingerprint = excluded.fingerprint,
notes = excluded.notes, notes = excluded.notes,
updated_at = excluded.updated_at 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 return self.get_ssh_key(key["id"]) or key
@@ -644,6 +660,46 @@ class SettingsStore:
).fetchall() ).fetchall()
return [dict(row) for row in rows] 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]: def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
target = json.loads(row["target_json"] or "{}") target = json.loads(row["target_json"] or "{}")
return { return {
+6
View File
@@ -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 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'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 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: 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 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.
@@ -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: 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: 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: 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: 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-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.
@@ -48,15 +48,12 @@ function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`; return `${formatBytes(bytes)}/s`;
} }
function formatTime(epochSeconds: number | null): string { function formatAge(epochSeconds: number | null): string {
if (!epochSeconds) return "-"; if (!epochSeconds) return "-";
const date = new Date(epochSeconds * 1000); const diff = Date.now() / 1000 - epochSeconds;
const yyyy = date.getFullYear(); if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
const mm = String(date.getMonth() + 1).padStart(2, "0"); if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
const dd = String(date.getDate()).padStart(2, "0"); return `${Math.round(diff / 3600)}h ago`;
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 { 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( function formatSummary(
summary: { avg: number; min: number; max: number } | null, summary: { avg: number; min: number; max: number } | null,
formatter: (value: number) => string, formatter: (value: number) => string,
) { ) {
if (!summary) return { value: "-", subtext: "" }; if (!summary) return { value: "-", min: "", max: "" };
return { return {
value: formatter(summary.avg), 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) { function MetricCell({
const [minLine, maxLine] = (subtext || "").split("\n"); value,
const min = minLine?.replace(/^Low\s+/, "").trim(); min,
const max = maxLine?.replace(/^High\s+/, "").trim(); max,
}: {
value: string;
min?: string;
max?: string;
}) {
return ( return (
<Stack <Stack
sx={{ sx={{
width: "100%", width: "100%",
minWidth: 110, minWidth: 0,
height: "100%", height: "100%",
minHeight: 96, minHeight: 118,
textAlign: "center", textAlign: "center",
py: 0.4, justifyContent: "space-between",
py: 0.5,
}} }}
> >
<Box <Box
@@ -168,16 +164,17 @@ function metricCell(value: string, subtext?: string) {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
px: 1.5, px: 1.5,
py: 1.15, py: 1.25,
minHeight: 68,
borderRadius: 1, borderRadius: 1,
}} }}
> >
<Stack spacing={0.1} sx={{ alignItems: "center" }}> <Stack spacing={0.25} sx={{ alignItems: "center" }}>
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ sx={{
fontSize: "0.62rem", fontSize: "0.6rem",
lineHeight: 1, lineHeight: 1,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
@@ -189,7 +186,7 @@ function metricCell(value: string, subtext?: string) {
variant="body1" variant="body1"
sx={{ sx={{
fontWeight: 900, fontWeight: 900,
fontSize: "1.14rem", fontSize: "1.12rem",
lineHeight: 1, lineHeight: 1,
textAlign: "center", textAlign: "center",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@@ -200,46 +197,48 @@ function metricCell(value: string, subtext?: string) {
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
<Stack <Stack spacing={0.25} sx={{ width: "100%" }}>
spacing={0.25}
sx={{
width: "100%",
pt: 0.1,
}}
>
{min ? ( {min ? (
<Chip <Box
size="small"
variant="outlined"
label={`Min ${min}`}
sx={{ sx={{
width: "100%", width: "100%",
height: 18, border: 1,
"& .MuiChip-label": { borderColor: "divider",
px: 0.5, borderRadius: 999,
py: 0, px: 0.75,
fontSize: "0.6rem", py: 0.15,
lineHeight: 1, fontSize: "0.6rem",
}, lineHeight: 1.2,
color: "text.secondary",
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}} }}
/> >
Min {min}
</Box>
) : null} ) : null}
{max ? ( {max ? (
<Chip <Box
size="small"
variant="outlined"
label={`Max ${max}`}
sx={{ sx={{
width: "100%", width: "100%",
height: 18, border: 1,
"& .MuiChip-label": { borderColor: "divider",
px: 0.5, borderRadius: 999,
py: 0, px: 0.75,
fontSize: "0.6rem", py: 0.15,
lineHeight: 1, fontSize: "0.6rem",
}, lineHeight: 1.2,
color: "text.secondary",
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}} }}
/> >
Max {max}
</Box>
) : null} ) : null}
</Stack> </Stack>
</Stack> </Stack>
@@ -302,8 +301,11 @@ export function MonitoringOverviewTable({
label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`} label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`}
/> />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Last success: {formatTime(poller?.last_success_at ?? null)} · last Last success:{" "}
run: {formatAge(poller?.last_run_at ?? null)} {poller?.last_success_at
? new Date(poller.last_success_at * 1000).toLocaleString()
: "-"}{" "}
· last run: {formatAge(poller?.last_run_at ?? null)}
</Typography> </Typography>
</Stack> </Stack>
) : null} ) : null}
@@ -326,13 +328,16 @@ export function MonitoringOverviewTable({
overflowX: "auto", overflowX: "auto",
overflowY: "hidden", overflowY: "hidden",
} }
: undefined : {
maxWidth: "100%",
overflowX: "auto",
}
} }
> >
<Table <Table
size="small" size="small"
sx={{ sx={{
minWidth: 1280, minWidth: 1500,
tableLayout: "fixed", tableLayout: "fixed",
"& .MuiTableCell-root": { "& .MuiTableCell-root": {
px: 1.1, px: 1.1,
@@ -350,6 +355,7 @@ export function MonitoringOverviewTable({
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell <TableCell
sx={{ width: 260 }}
sortDirection={sortKey === "machine" ? sortDirection : false} sortDirection={sortKey === "machine" ? sortDirection : false}
> >
<TableSortLabel <TableSortLabel
@@ -361,6 +367,7 @@ export function MonitoringOverviewTable({
</TableSortLabel> </TableSortLabel>
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ width: 90 }}
sortDirection={sortKey === "mode" ? sortDirection : false} sortDirection={sortKey === "mode" ? sortDirection : false}
> >
<TableSortLabel <TableSortLabel
@@ -372,6 +379,7 @@ export function MonitoringOverviewTable({
</TableSortLabel> </TableSortLabel>
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ width: 120 }}
sortDirection={sortKey === "status" ? sortDirection : false} sortDirection={sortKey === "status" ? sortDirection : false}
> >
<TableSortLabel <TableSortLabel
@@ -382,103 +390,33 @@ export function MonitoringOverviewTable({
Status Status
</TableSortLabel> </TableSortLabel>
</TableCell> </TableCell>
<TableCell {[
align="right" ["cpu", "CPU"],
sortDirection={sortKey === "cpu" ? sortDirection : false} ["iowait", "IO wait"],
> ["mem", "RAM"],
<TableSortLabel ["net_rx", "Net down"],
active={sortKey === "cpu"} ["net_tx", "Net up"],
direction={sortKey === "cpu" ? sortDirection : "asc"} ["disk_read", "Disk read"],
onClick={() => setSort("cpu")} ["disk_write", "Disk write"],
["disk_used", "Disk used"],
].map(([key, label]) => (
<TableCell
key={key}
align="center"
sx={{ width: 150 }}
sortDirection={sortKey === key ? sortDirection : false}
> >
CPU <TableSortLabel
</TableSortLabel> active={sortKey === key}
</TableCell> direction={sortKey === key ? sortDirection : "asc"}
<TableCell onClick={() => setSort(key as SortKey)}
align="right" >
sortDirection={sortKey === "iowait" ? sortDirection : false} {label}
> </TableSortLabel>
<TableSortLabel </TableCell>
active={sortKey === "iowait"} ))}
direction={sortKey === "iowait" ? sortDirection : "asc"}
onClick={() => setSort("iowait")}
>
IO wait
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "mem" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "mem"}
direction={sortKey === "mem" ? sortDirection : "asc"}
onClick={() => setSort("mem")}
>
RAM
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "net_rx" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "net_rx"}
direction={sortKey === "net_rx" ? sortDirection : "asc"}
onClick={() => setSort("net_rx")}
>
Net down
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "net_tx" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "net_tx"}
direction={sortKey === "net_tx" ? sortDirection : "asc"}
onClick={() => setSort("net_tx")}
>
Net up
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_read" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_read"}
direction={sortKey === "disk_read" ? sortDirection : "asc"}
onClick={() => setSort("disk_read")}
>
Disk read
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_write" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_write"}
direction={sortKey === "disk_write" ? sortDirection : "asc"}
onClick={() => setSort("disk_write")}
>
Disk write
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_used" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_used"}
direction={sortKey === "disk_used" ? sortDirection : "asc"}
onClick={() => setSort("disk_used")}
>
Disk used
</TableSortLabel>
</TableCell>
<TableCell <TableCell
sx={{ width: 180 }}
sortDirection={sortKey === "updated" ? sortDirection : false} sortDirection={sortKey === "updated" ? sortDirection : false}
> >
<TableSortLabel <TableSortLabel
@@ -526,16 +464,39 @@ export function MonitoringOverviewTable({
row.disk_write_summary, row.disk_write_summary,
formatRate, formatRate,
); );
const disk = row.disk
? {
value: row.disk.used_pct,
min: `Used ${formatBytes(row.disk.used)}`,
max: `Avail ${formatBytes(row.disk.available)}`,
}
: null;
const updated = formatUpdatedDetails(
row.latest_sample?.ts ?? null,
);
return ( return (
<TableRow key={machine.id}> <TableRow key={machine.id}>
<TableCell> <TableCell sx={{ width: 260 }}>
<Stack spacing={0.25}> <Stack spacing={0.25} sx={{ minWidth: 0 }}>
<Stack <Stack
direction="row" direction="row"
spacing={0.75} spacing={0.75}
sx={{ alignItems: "center", flexWrap: "wrap" }} sx={{
alignItems: "center",
flexWrap: "nowrap",
minWidth: 0,
}}
> >
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography
variant="body2"
sx={{
fontWeight: 700,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{machine.name} {machine.name}
</Typography> </Typography>
{!machine.enabled && ( {!machine.enabled && (
@@ -546,15 +507,24 @@ export function MonitoringOverviewTable({
/> />
)} )}
</Stack> </Stack>
<Typography variant="caption" color="text.secondary"> <Typography
variant="caption"
color="text.secondary"
sx={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{machine.mode === "local" {machine.mode === "local"
? "Local API host" ? "Local API host"
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`} : `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
</Typography> </Typography>
</Stack> </Stack>
</TableCell> </TableCell>
<TableCell>{machine.mode}</TableCell> <TableCell sx={{ width: 90 }}>{machine.mode}</TableCell>
<TableCell> <TableCell sx={{ width: 120 }}>
<Chip <Chip
size="small" size="small"
variant="outlined" variant="outlined"
@@ -563,39 +533,68 @@ export function MonitoringOverviewTable({
/> />
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(cpu.value, cpu.subtext)} <MetricCell
value={cpu.value}
min={cpu.min}
max={cpu.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(iowait.value, iowait.subtext)} <MetricCell
value={iowait.value}
min={iowait.min}
max={iowait.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(mem.value, mem.subtext)} <MetricCell
value={mem.value}
min={mem.min}
max={mem.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(netDown.value, netDown.subtext)} <MetricCell
value={netDown.value}
min={netDown.min}
max={netDown.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(netUp.value, netUp.subtext)} <MetricCell
value={netUp.value}
min={netUp.min}
max={netUp.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(diskRead.value, diskRead.subtext)} <MetricCell
value={diskRead.value}
min={diskRead.min}
max={diskRead.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{metricCell(diskWrite.value, diskWrite.subtext)} <MetricCell
value={diskWrite.value}
min={diskWrite.min}
max={diskWrite.max}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
{row.disk {disk ? (
? metricCell( <MetricCell
row.disk.used_pct, value={disk.value}
`Used ${formatBytes(row.disk.used)}\nAvail ${formatBytes(row.disk.available)}`, min={disk.min}
) max={disk.max}
: "-"} />
) : (
"-"
)}
</TableCell> </TableCell>
<TableCell> <TableCell sx={{ width: 180 }}>
<Stack spacing={0.15} sx={{ alignItems: "center" }}> <Stack spacing={0.1} sx={{ alignItems: "center" }}>
{formatUpdatedDetails( {updated.map((line) => (
row.latest_sample?.ts ?? null,
).map((line) => (
<Typography <Typography
key={line} key={line}
variant="caption" variant="caption"
+46 -43
View File
@@ -13,6 +13,7 @@ import {
FormControlLabel, FormControlLabel,
Grid, Grid,
Stack, Stack,
MenuItem,
Tab, Tab,
TextField, TextField,
Typography, Typography,
@@ -33,7 +34,6 @@ import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard"; import { SelectionRailCard } from "../components/SelectionRailCard";
import { TabbedCard } from "../components/TabbedCard"; import { TabbedCard } from "../components/TabbedCard";
const SERVICE_OPTIONS = [ const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" }, { value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" }, { value: "files", label: "Files" },
@@ -41,9 +41,7 @@ const SERVICE_OPTIONS = [
{ value: "jellyseerr", label: "Jellyseerr" }, { value: "jellyseerr", label: "Jellyseerr" },
{ value: "nextcloud", label: "Nextcloud" }, { value: "nextcloud", label: "Nextcloud" },
]; ];
type SettingsTab = "machines" | "ssh-keys" | "danger"; type SettingsTab = "machines" | "ssh-keys" | "danger";
function emptyMachine( function emptyMachine(
mode: MonitoringMachineInput["mode"] = "local", mode: MonitoringMachineInput["mode"] = "local",
): MonitoringMachineInput { ): MonitoringMachineInput {
@@ -72,7 +70,6 @@ function emptyMachine(
notes: "", notes: "",
}; };
} }
function MachineEditor({ function MachineEditor({
title, title,
hint, hint,
@@ -97,7 +94,6 @@ function MachineEditor({
const enabledServices = draft.services.length; const enabledServices = draft.services.length;
const hasJellyfin = draft.services.includes("jellyfin"); const hasJellyfin = draft.services.includes("jellyfin");
const hasJellyseerr = draft.services.includes("jellyseerr"); const hasJellyseerr = draft.services.includes("jellyseerr");
return ( return (
<Card variant="outlined"> <Card variant="outlined">
<CardContent sx={{ p: 1.5 }}> <CardContent sx={{ p: 1.5 }}>
@@ -139,7 +135,6 @@ function MachineEditor({
/> />
</Stack> </Stack>
</Stack> </Stack>
<Grid container spacing={1.25}> <Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
@@ -239,11 +234,12 @@ function MachineEditor({
} }
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 3 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
select
fullWidth fullWidth
size="small" size="small"
label="SSH key id" label="SSH key"
value={draft.ssh_key_id} value={draft.ssh_key_id}
onChange={(e) => onChange={(e) =>
setDraft((current) => ({ setDraft((current) => ({
@@ -251,7 +247,19 @@ function MachineEditor({
ssh_key_id: e.target.value, 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."
}
>
<MenuItem value="">No key selected</MenuItem>
{sshKeys.map((key) => (
<MenuItem key={key.id} value={key.id}>
{key.name}
</MenuItem>
))}
</TextField>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
@@ -387,15 +395,23 @@ function MachineEditor({
/> />
</Grid>{" "} </Grid>{" "}
</Grid> </Grid>
{selectedSSHKey ? (
{selectedSSHKey && (
<Alert severity="info"> <Alert severity="info">
Selected key: {selectedSSHKey.name} Selected key: {selectedSSHKey.name}
{selectedSSHKey.fingerprint {selectedSSHKey.fingerprint
? ` · ${selectedSSHKey.fingerprint}` ? ` · ${selectedSSHKey.fingerprint}`
: ""} : ""}
</Alert> </Alert>
)} ) : !isLocal && sshKeys.length === 0 ? (
<Alert severity="warning">
No SSH keys have been saved yet. Add one before configuring SSH
machines.
</Alert>
) : draft.ssh_key_id ? (
<Alert severity="warning">
The selected SSH key was not found.
</Alert>
) : null}
{!isLocal && !hasJellyfin && ( {!isLocal && !hasJellyfin && (
<Alert severity="warning"> <Alert severity="warning">
SSH machines usually need monitoring or files enabled. SSH machines usually need monitoring or files enabled.
@@ -411,7 +427,6 @@ function MachineEditor({
</Card> </Card>
); );
} }
function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
const saveKey = useSaveSSHKey(); const saveKey = useSaveSSHKey();
const generateKey = useGenerateSSHKey(); const generateKey = useGenerateSSHKey();
@@ -420,20 +435,22 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
id: null, id: null,
name: "", name: "",
private_key: "", private_key: "",
public_key: "",
fingerprint: "",
passphrase: "", passphrase: "",
notes: "", notes: "",
}); });
const editing = Boolean(draft.id); const editing = Boolean(draft.id);
const clear = () => const clear = () =>
setDraft({ setDraft({
id: null, id: null,
name: "", name: "",
private_key: "", private_key: "",
passphrase: "", passphrase: "",
public_key: "",
fingerprint: "",
notes: "", notes: "",
}); });
return ( return (
<Card variant="outlined"> <Card variant="outlined">
<CardContent sx={{ p: 1.5 }}> <CardContent sx={{ p: 1.5 }}>
@@ -461,7 +478,6 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
label={`${sshKeys.length} saved`} label={`${sshKeys.length} saved`}
/> />
</Stack> </Stack>
<Grid container spacing={1.25}> <Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
@@ -524,7 +540,6 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
/> />
</Grid> </Grid>
</Grid> </Grid>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}> <Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button <Button
variant="contained" variant="contained"
@@ -552,6 +567,8 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
name: generated.name || current.name, name: generated.name || current.name,
private_key: generated.private_key, private_key: generated.private_key,
passphrase: generated.passphrase, passphrase: generated.passphrase,
public_key: generated.public_key,
fingerprint: generated.fingerprint,
notes: generated.notes, notes: generated.notes,
})); }));
}} }}
@@ -562,11 +579,9 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
Clear Clear
</Button> </Button>
</Stack> </Stack>
{saveKey.error && ( {saveKey.error && (
<Alert severity="error">{String(saveKey.error)}</Alert> <Alert severity="error">{String(saveKey.error)}</Alert>
)} )}
{sshKeys.length > 0 ? ( {sshKeys.length > 0 ? (
<Grid container spacing={1.25}> <Grid container spacing={1.25}>
{sshKeys.map((key) => ( {sshKeys.map((key) => (
@@ -658,6 +673,8 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
name: key.name, name: key.name,
private_key: "", private_key: "",
passphrase: "", passphrase: "",
public_key: key.public_key,
fingerprint: key.fingerprint,
notes: key.notes, notes: key.notes,
}) })
} }
@@ -686,7 +703,6 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
</Card> </Card>
); );
} }
function ResetLocalDatabaseCard() { function ResetLocalDatabaseCard() {
const resetDatabase = useResetLocalDatabase(); const resetDatabase = useResetLocalDatabase();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -699,7 +715,6 @@ function ResetLocalDatabaseCard() {
ackSettings && ackSettings &&
ackIndex && ackIndex &&
ackIrreversible; ackIrreversible;
const close = () => { const close = () => {
setOpen(false); setOpen(false);
setPhrase(""); setPhrase("");
@@ -707,7 +722,6 @@ function ResetLocalDatabaseCard() {
setAckIndex(false); setAckIndex(false);
setAckIrreversible(false); setAckIrreversible(false);
}; };
return ( return (
<> <>
<Card variant="outlined"> <Card variant="outlined">
@@ -745,7 +759,6 @@ function ResetLocalDatabaseCard() {
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={open} onClose={close} fullWidth maxWidth="sm"> <Dialog open={open} onClose={close} fullWidth maxWidth="sm">
<DialogTitle>Reset local database</DialogTitle> <DialogTitle>Reset local database</DialogTitle>
<DialogContent> <DialogContent>
@@ -810,7 +823,6 @@ function ResetLocalDatabaseCard() {
</> </>
); );
} }
export function Settings() { export function Settings() {
const { data: machines, error } = useMonitoringSettings(); const { data: machines, error } = useMonitoringSettings();
const { data: sshKeys = [] } = useSSHKeys(); const { data: sshKeys = [] } = useSSHKeys();
@@ -822,7 +834,6 @@ export function Settings() {
emptyMachine(), emptyMachine(),
); );
const [selectedMachineId, setSelectedMachineId] = useState(""); const [selectedMachineId, setSelectedMachineId] = useState("");
const orderedMachines = useMemo(() => machines ?? [], [machines]); const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo( const selectedMachine = useMemo(
() => () =>
@@ -837,7 +848,6 @@ export function Settings() {
const sshMachines = orderedMachines.filter( const sshMachines = orderedMachines.filter(
(machine) => machine.mode === "ssh", (machine) => machine.mode === "ssh",
); );
const beginLocal = () => { const beginLocal = () => {
setMachineDraft(emptyMachine("local")); setMachineDraft(emptyMachine("local"));
setMachineDialogOpen(true); setMachineDialogOpen(true);
@@ -858,7 +868,6 @@ export function Settings() {
setMachineDialogOpen(false); setMachineDialogOpen(false);
setMachineDraft(emptyMachine(draft.mode)); setMachineDraft(emptyMachine(draft.mode));
}; };
return ( return (
<Stack spacing={2.25}> <Stack spacing={2.25}>
<Stack spacing={0.5}> <Stack spacing={0.5}>
@@ -870,7 +879,6 @@ export function Settings() {
tabbed admin workspace. tabbed admin workspace.
</Typography> </Typography>
</Stack> </Stack>
{error && <Alert severity="error">{String(error)}</Alert>} {error && <Alert severity="error">{String(error)}</Alert>}
{saveMachine.error && ( {saveMachine.error && (
<Alert severity="error">{String(saveMachine.error)}</Alert> <Alert severity="error">{String(saveMachine.error)}</Alert>
@@ -878,7 +886,6 @@ export function Settings() {
{deleteMachine.error && ( {deleteMachine.error && (
<Alert severity="error">{String(deleteMachine.error)}</Alert> <Alert severity="error">{String(deleteMachine.error)}</Alert>
)} )}
<TabbedCard <TabbedCard
value={tab} value={tab}
onChange={(value) => setTab(value as SettingsTab)} onChange={(value) => setTab(value as SettingsTab)}
@@ -951,7 +958,6 @@ export function Settings() {
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>
{orderedMachines.length > 0 ? ( {orderedMachines.length > 0 ? (
<Box <Box
sx={{ sx={{
@@ -1014,15 +1020,15 @@ export function Settings() {
host: machine.host, host: machine.host,
port: machine.port, port: machine.port,
username: machine.username, username: machine.username,
key_directory: "", key_directory: "",
key_name: "", key_name: "",
path_prefix: "", path_prefix: "",
ssh_key_id: machine.ssh_key_id, 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,
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,
@@ -1036,7 +1042,6 @@ export function Settings() {
})} })}
</Box> </Box>
</SelectionRailCard> </SelectionRailCard>
<SectionCard <SectionCard
title={selectedMachine?.name || "No machine selected"} title={selectedMachine?.name || "No machine selected"}
description={ description={
@@ -1140,15 +1145,15 @@ export function Settings() {
host: selectedMachine.host, host: selectedMachine.host,
port: selectedMachine.port, port: selectedMachine.port,
username: selectedMachine.username, username: selectedMachine.username,
key_directory: "", key_directory: "",
key_name: "", key_name: "",
path_prefix: "", path_prefix: "",
ssh_key_id: selectedMachine.ssh_key_id, 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,
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: "",
@@ -1179,11 +1184,9 @@ export function Settings() {
) : null} ) : null}
</Stack> </Stack>
)} )}
{tab === "ssh-keys" && <SSHKeyManager sshKeys={sshKeys} />} {tab === "ssh-keys" && <SSHKeyManager sshKeys={sshKeys} />}
{tab === "danger" && <ResetLocalDatabaseCard />} {tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard> </TabbedCard>
<Dialog <Dialog
open={machineDialogOpen} open={machineDialogOpen}
onClose={closeMachineDialog} onClose={closeMachineDialog}
+2
View File
@@ -105,6 +105,8 @@ export interface SSHKeyInput {
name: string; name: string;
private_key: string; private_key: string;
passphrase: string; passphrase: string;
public_key: string;
fingerprint: string;
notes: string; notes: string;
} }