feat(observability): add Prometheus/Grafana/Loki/Alertmanager/Alloy stack and remove legacy Monitoring UI
This commit is contained in:
@@ -53,7 +53,9 @@ class JellyfinClient:
|
||||
{
|
||||
"X-Emby-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
"X-Emby-Authorization": 'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"',
|
||||
"X-Emby-Authorization": (
|
||||
'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -61,9 +63,7 @@ class JellyfinClient:
|
||||
"""GET a Jellyfin endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
logger.debug("Jellyfin GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
||||
)
|
||||
response = self.session.get(f"{self.base_url}{path}", params=clean_params, timeout=self.timeout)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
@@ -175,14 +175,16 @@ class JellyfinClient:
|
||||
series = self.item_count(user_id, "Series", parent_id=lib_id)
|
||||
episodes = self.item_count(user_id, "Episode", parent_id=lib_id)
|
||||
total = self.item_count(user_id, "Movie,Episode,Video,Audio,Series", parent_id=lib_id)
|
||||
results.append({
|
||||
"library": lib_name,
|
||||
"type": lib_type,
|
||||
"movies": movies,
|
||||
"series": series,
|
||||
"episodes": episodes,
|
||||
"total": total,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"library": lib_name,
|
||||
"type": lib_type,
|
||||
"movies": movies,
|
||||
"series": series,
|
||||
"episodes": episodes,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def sessions(self, active_within_seconds: int | None = None) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -40,16 +40,12 @@ class JellyseerrClient:
|
||||
"""GET a Jellyseerr endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
logger.debug("Jellyseerr GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/api/v1{path}", params=clean_params, timeout=self.timeout
|
||||
)
|
||||
response = self.session.get(f"{self.base_url}/api/v1{path}", params=clean_params, timeout=self.timeout)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning(
|
||||
"Jellyseerr GET %s failed status=%s url=%s", path, response.status_code, response.url
|
||||
)
|
||||
logger.warning("Jellyseerr GET %s failed status=%s url=%s", path, response.status_code, response.url)
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
@@ -105,7 +101,9 @@ class JellyseerrClient:
|
||||
return results
|
||||
|
||||
page_results = payload.get("results") or []
|
||||
page_items = [item for item in page_results if isinstance(item, dict)] if isinstance(page_results, list) else []
|
||||
page_items = (
|
||||
[item for item in page_results if isinstance(item, dict)] if isinstance(page_results, list) else []
|
||||
)
|
||||
results.extend(page_items)
|
||||
|
||||
page_info = payload.get("pageInfo") or {}
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Remote resource collection helpers.
|
||||
|
||||
The app does not require Prometheus, Netdata, or sysstat. Instead it can install
|
||||
and manage a tiny POSIX-sh collector under /tmp on the remote server. The
|
||||
collector samples Linux /proc and /sys counters every 10 seconds and appends JSON
|
||||
Lines. This module starts/stops the collector and reads those JSONL samples.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# POSIX shell script copied to the remote server by start_resource_collector().
|
||||
# Keep this script bash-free because many NAS/media servers have minimal shells.
|
||||
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||
set -u
|
||||
OUT="${1:-/tmp/media_library_viewer_metrics.jsonl}"
|
||||
INTERVAL="${2:-10}"
|
||||
RETENTION_SECONDS="${3:-604800}"
|
||||
MAX_LINES="${4:-70000}"
|
||||
PRUNE_EVERY_SAMPLES="${5:-60}"
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
echo "collector starting at $(date -Is 2>/dev/null || date), interval=${INTERVAL}s, retention=${RETENTION_SECONDS}s, max_lines=${MAX_LINES}, out=${OUT}"
|
||||
|
||||
read_cpu() {
|
||||
awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6, $6}' /proc/stat
|
||||
}
|
||||
|
||||
read_mem_pct() {
|
||||
awk '
|
||||
/^MemTotal:/ {total=$2}
|
||||
/^MemAvailable:/ {avail=$2}
|
||||
END {if (total > 0) printf "%.2f", (total-avail)*100/total; else printf "0"}
|
||||
' /proc/meminfo
|
||||
}
|
||||
|
||||
read_net_bytes() {
|
||||
awk '
|
||||
NR > 2 {
|
||||
split($0, parts, ":")
|
||||
iface = parts[1]
|
||||
stats = parts[2]
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", iface)
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", stats)
|
||||
if (iface == "lo" || iface == "" || stats == "") next
|
||||
split(stats, values, /[ \t]+/)
|
||||
# /proc/net/dev after the colon:
|
||||
# receive bytes are field 1, transmit bytes are field 9.
|
||||
# Trim the stats block before split; otherwise leading whitespace can make
|
||||
# values[1] empty in some awk implementations, resulting in zero rates.
|
||||
rx += values[1] + 0
|
||||
tx += values[9] + 0
|
||||
}
|
||||
END {printf "%.0f %.0f", rx, tx}
|
||||
' /proc/net/dev
|
||||
}
|
||||
|
||||
read_disk_bytes() {
|
||||
read_sectors=0
|
||||
written_sectors=0
|
||||
for dev in /sys/block/*; do
|
||||
[ -r "$dev/stat" ] || continue
|
||||
name="$(basename "$dev")"
|
||||
case "$name" in
|
||||
loop*|ram*|fd*|sr*) continue ;;
|
||||
esac
|
||||
# Linux /sys/block/<dev>/stat fields: 3=sectors read, 7=sectors written.
|
||||
# Use POSIX sh parsing instead of bash arrays so this works on minimal systems.
|
||||
set -- $(cat "$dev/stat")
|
||||
sectors_read="${3:-0}"
|
||||
sectors_written="${7:-0}"
|
||||
read_sectors=$((read_sectors + sectors_read))
|
||||
written_sectors=$((written_sectors + sectors_written))
|
||||
done
|
||||
printf "%s %s" "$((read_sectors * 512))" "$((written_sectors * 512))"
|
||||
}
|
||||
|
||||
set -- $(read_cpu)
|
||||
prev_total="${1:-0}"
|
||||
prev_idle="${2:-0}"
|
||||
prev_iowait="${3:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
prev_rx="${1:-0}"
|
||||
prev_tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
prev_disk_read="${1:-0}"
|
||||
prev_disk_write="${2:-0}"
|
||||
prev_ts="$(date +%s)"
|
||||
sample_count=0
|
||||
|
||||
prune_metrics_file() {
|
||||
[ -f "$OUT" ] || return 0
|
||||
cutoff="$1"
|
||||
tmp="${OUT}.$$.tmp"
|
||||
awk -v cutoff="$cutoff" '
|
||||
match($0, /"ts":[0-9]+/) {
|
||||
ts = substr($0, RSTART + 5, RLENGTH - 5)
|
||||
if (ts >= cutoff) print $0
|
||||
}
|
||||
' "$OUT" | tail -n "$MAX_LINES" > "$tmp" && mv "$tmp" "$OUT"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
while true; do
|
||||
now_ts="$(date +%s)"
|
||||
dt=$((now_ts - prev_ts))
|
||||
if [ "$dt" -le 0 ]; then dt=1; fi
|
||||
|
||||
set -- $(read_cpu)
|
||||
total="${1:-0}"
|
||||
idle="${2:-0}"
|
||||
iowait="${3:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
rx="${1:-0}"
|
||||
tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
disk_read="${1:-0}"
|
||||
disk_write="${2:-0}"
|
||||
mem_pct="$(read_mem_pct)"
|
||||
|
||||
total_delta=$((total - prev_total))
|
||||
idle_delta=$((idle - prev_idle))
|
||||
iowait_delta=$((iowait - prev_iowait))
|
||||
rx_delta=$((rx - prev_rx))
|
||||
tx_delta=$((tx - prev_tx))
|
||||
disk_read_delta=$((disk_read - prev_disk_read))
|
||||
disk_write_delta=$((disk_write - prev_disk_write))
|
||||
|
||||
cpu_pct="$(awk -v total="$total_delta" -v idle="$idle_delta" 'BEGIN {if (total > 0) printf "%.2f", (total-idle)*100/total; else printf "0"}')"
|
||||
iowait_pct="$(awk -v total="$total_delta" -v iow="$iowait_delta" 'BEGIN {if (total > 0) printf "%.2f", iow*100/total; else printf "0"}')"
|
||||
rx_bytes_per_sec="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
tx_bytes_per_sec="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
rx_bps="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
tx_bps="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
disk_read_bps="$(awk -v bytes="$disk_read_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
disk_write_bps="$(awk -v bytes="$disk_write_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
|
||||
printf '{"ts":%s,"cpu_pct":%s,"iowait_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \
|
||||
"$now_ts" "$cpu_pct" "$iowait_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
|
||||
|
||||
sample_count=$((sample_count + 1))
|
||||
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
||||
prune_metrics_file "$((now_ts - RETENTION_SECONDS))"
|
||||
fi
|
||||
|
||||
prev_total="$total"
|
||||
prev_idle="$idle"
|
||||
prev_iowait="$iowait"
|
||||
prev_rx="$rx"
|
||||
prev_tx="$tx"
|
||||
prev_disk_read="$disk_read"
|
||||
prev_disk_write="$disk_write"
|
||||
prev_ts="$now_ts"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceMonitorPaths:
|
||||
"""Remote file locations used by the lightweight resource collector."""
|
||||
|
||||
metrics_file: str = "/tmp/media_library_viewer_metrics.jsonl"
|
||||
pid_file: str = "/tmp/media_library_viewer_metrics.pid"
|
||||
script_file: str = "/tmp/media_library_viewer_metrics_collector.sh"
|
||||
log_file: str = "/tmp/media_library_viewer_metrics.log"
|
||||
|
||||
|
||||
def start_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
"""Install and start the remote metrics collector if it is not running.
|
||||
|
||||
Starting a fresh collector removes old metrics/log files because schema
|
||||
changes during development can otherwise leave mixed JSONL records behind.
|
||||
The collector prunes its own metrics file to 7 days / max_lines.
|
||||
"""
|
||||
command = f"""
|
||||
cat > {shlex.quote(paths.script_file)} <<'MLV_RESOURCE_COLLECTOR'
|
||||
{COLLECTOR_SCRIPT}
|
||||
MLV_RESOURCE_COLLECTOR
|
||||
chmod +x {shlex.quote(paths.script_file)}
|
||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||
echo "already running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
else
|
||||
rm -f {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)}
|
||||
nohup {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {int(interval_seconds)} {int(retention_seconds)} {int(max_lines)} >> {shlex.quote(paths.log_file)} 2>&1 &
|
||||
echo $! > {shlex.quote(paths.pid_file)}
|
||||
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
fi
|
||||
"""
|
||||
logger.info(
|
||||
"Starting remote resource collector interval=%ss retention=%ss max_lines=%s", interval_seconds, retention_seconds, max_lines
|
||||
)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to start remote resource collector: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
|
||||
logger.info("Remote resource collector start response: %s", result.stdout.strip())
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def stop_resource_collector(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Stop the remote collector process if the pid file points to one."""
|
||||
command = f"""
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
echo "stopped pid=$pid"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
rm -f {shlex.quote(paths.pid_file)}
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
logger.info("Stopping remote resource collector")
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to stop remote resource collector: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
|
||||
logger.info("Remote resource collector stop response: %s", result.stdout.strip())
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def restart_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
logger.info("Restarting remote resource collector")
|
||||
stop_message = stop_resource_collector(ssh, paths)
|
||||
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||
return f"{stop_message}\n{start_message}"
|
||||
|
||||
|
||||
def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""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"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=10)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to read collector status: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
|
||||
status = result.stdout.strip()
|
||||
logger.info("Resource collector status: %s", status)
|
||||
return status
|
||||
|
||||
|
||||
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Collect remote diagnostics for troubleshooting missing metrics."""
|
||||
command = f"""
|
||||
echo "status:"
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
echo "pid_file=$pid"
|
||||
if kill -0 "$pid" 2>/dev/null; then echo "process=running"; else echo "process=not-running"; fi
|
||||
else
|
||||
echo "pid_file=missing"
|
||||
fi
|
||||
|
||||
echo "files:"
|
||||
ls -l {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)} 2>&1 || true
|
||||
|
||||
echo "sample_count:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then wc -l < {shlex.quote(paths.metrics_file)}; else echo 0; fi
|
||||
|
||||
echo "last_samples:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then tail -n 5 {shlex.quote(paths.metrics_file)}; fi
|
||||
|
||||
echo "log_tail:"
|
||||
if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_file)}; fi
|
||||
|
||||
echo "netdev_snapshot:"
|
||||
cat /proc/net/dev 2>&1 || true
|
||||
"""
|
||||
logger.info("Collecting resource collector diagnostics")
|
||||
result = ssh.run(command, timeout=20)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
logger.debug("Resource collector diagnostics length=%s", len(output))
|
||||
return output
|
||||
|
||||
|
||||
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
|
||||
"""Read recent JSONL metric samples from the remote collector file."""
|
||||
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
|
||||
logger.debug("Reading up to %s resource metric lines", max_lines)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to read resource metrics: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||
rows = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
||||
"""Return df information for the filesystem containing ``path``."""
|
||||
command = (
|
||||
"df -P -B1 -- "
|
||||
+ shlex.quote(path or "/")
|
||||
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
logger.debug("Reading disk space for path=%s", path)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
data = json.loads(result.stdout)
|
||||
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
|
||||
return data
|
||||
@@ -75,14 +75,10 @@ class RemoteSSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
client.load_system_host_keys()
|
||||
known_hosts_file = Path(self.known_hosts_path) if self.known_hosts_path else None
|
||||
trusted_before = bool(
|
||||
known_hosts_file and has_known_host(self.host, self.port, known_hosts_file)
|
||||
)
|
||||
trusted_before = bool(known_hosts_file and has_known_host(self.host, self.port, known_hosts_file))
|
||||
if known_hosts_file and known_hosts_file.is_file():
|
||||
client.load_host_keys(str(known_hosts_file))
|
||||
client.set_missing_host_key_policy(
|
||||
paramiko.RejectPolicy() if trusted_before else paramiko.AutoAddPolicy()
|
||||
)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy() if trusted_before else paramiko.AutoAddPolicy())
|
||||
connect_kwargs: dict[str, Any] = {
|
||||
"hostname": self.host,
|
||||
"port": self.port,
|
||||
|
||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "text" # "text" or "json"
|
||||
|
||||
# Auth / OIDC (Authentik-compatible JWT validation)
|
||||
auth_enabled: bool = False
|
||||
@@ -48,7 +49,6 @@ class Settings(BaseSettings):
|
||||
ssh_port: int = 22
|
||||
ssh_key_directory: str = ""
|
||||
ssh_key_name: str = ""
|
||||
ssh_key_file: str = "/run/secrets/ssh_private_key"
|
||||
ssh_password: str = ""
|
||||
ssh_known_hosts_path: str = ""
|
||||
|
||||
@@ -57,6 +57,12 @@ class Settings(BaseSettings):
|
||||
monitoring_poll_initial_delay_seconds: int = 20
|
||||
monitoring_action_retention_days: int = 30
|
||||
|
||||
# Observability
|
||||
prometheus_enabled: bool = True
|
||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||
alertmanager_url: str = "http://alertmanager:9093"
|
||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||
|
||||
# Remote paths
|
||||
remote_media_root: str = ""
|
||||
remote_path_prefix: str = ""
|
||||
@@ -72,8 +78,6 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def ssh_key_path(self) -> str:
|
||||
if self.ssh_key_file:
|
||||
return self.ssh_key_file
|
||||
if not self.ssh_key_directory or not self.ssh_key_name:
|
||||
return ""
|
||||
return str(Path(self.ssh_key_directory) / self.ssh_key_name)
|
||||
@@ -82,7 +86,7 @@ class Settings(BaseSettings):
|
||||
def ssh_known_hosts_file(self) -> Path:
|
||||
return Path(self.ssh_known_hosts_path or ".cache/media_library_viewer/known_hosts")
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _find_env_file() -> str | None:
|
||||
|
||||
@@ -19,12 +19,16 @@ 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.config import get_settings
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
|
||||
from media_library_viewer_api.services.monitoring_poller import (
|
||||
MonitoringPoller,
|
||||
)
|
||||
from media_library_viewer_api.services.monitoring_poller import (
|
||||
get_monitoring_poller as _get_monitoring_poller,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store as _get_settings_store
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +43,9 @@ def _request_machine_id(request: Request | None) -> str | None:
|
||||
@lru_cache(maxsize=32)
|
||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||
machine_id, url, api_key = cache_key
|
||||
logger.info("Creating Jellyfin client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>")
|
||||
logger.info(
|
||||
"Creating Jellyfin client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
|
||||
)
|
||||
return JellyfinClient(url, api_key)
|
||||
|
||||
|
||||
@@ -52,13 +58,19 @@ def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | Non
|
||||
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
|
||||
if not api_key:
|
||||
return None
|
||||
logger.info("Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>")
|
||||
logger.info(
|
||||
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
|
||||
)
|
||||
return JellyseerrClient(url, api_key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) -> RemoteSSHClient:
|
||||
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = cache_key
|
||||
def _ssh_client_for(
|
||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||
) -> RemoteSSHClient:
|
||||
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = (
|
||||
cache_key
|
||||
)
|
||||
logger.info(
|
||||
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s passphrase=%s",
|
||||
machine_id or "<default>",
|
||||
@@ -141,7 +153,9 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
raise RuntimeError("No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings.")
|
||||
raise RuntimeError(
|
||||
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
|
||||
)
|
||||
|
||||
|
||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
@@ -160,6 +174,37 @@ def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
return None
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
"""Build a RemoteSSHClient from a machine config dict."""
|
||||
store = store or get_settings_store()
|
||||
known_hosts_path = get_settings().ssh_known_hosts_file
|
||||
key_data = None
|
||||
key_passphrase = None
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
key_data = ssh_key.get("private_key") or None
|
||||
key_passphrase = ssh_key.get("passphrase") or None
|
||||
if not key_data and machine.get("ssh_private_key"):
|
||||
key_data = machine.get("ssh_private_key") or None
|
||||
key_passphrase = machine.get("ssh_private_key_passphrase") or None
|
||||
cache_key = (
|
||||
machine["id"],
|
||||
machine["host"],
|
||||
machine["username"],
|
||||
int(machine.get("port") or 22),
|
||||
f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
||||
if machine.get("key_directory") and machine.get("key_name")
|
||||
else "",
|
||||
machine.get("password") or None,
|
||||
key_data,
|
||||
key_passphrase,
|
||||
str(known_hosts_path),
|
||||
)
|
||||
return _ssh_client_for(cache_key)
|
||||
|
||||
|
||||
def get_ssh_client(request: Request = None):
|
||||
"""Return a command client for the selected machine or legacy env fallback."""
|
||||
store = get_settings_store()
|
||||
@@ -172,30 +217,7 @@ def get_ssh_client(request: Request = None):
|
||||
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
|
||||
key_data = None
|
||||
key_passphrase = None
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
key_data = ssh_key.get("private_key") or None
|
||||
key_passphrase = ssh_key.get("passphrase") or None
|
||||
if not key_data and machine.get("ssh_private_key"):
|
||||
key_data = machine.get("ssh_private_key") or None
|
||||
key_passphrase = machine.get("ssh_private_key_passphrase") or None
|
||||
cache_key = (
|
||||
machine["id"],
|
||||
machine["host"],
|
||||
machine["username"],
|
||||
int(machine.get("port") or 22),
|
||||
f"{machine.get('key_directory')}/{machine.get('key_name')}" if machine.get("key_directory") and machine.get("key_name") else "",
|
||||
machine.get("password") or None,
|
||||
key_data,
|
||||
key_passphrase,
|
||||
str(known_hosts_path),
|
||||
)
|
||||
return _ssh_client_for(cache_key)
|
||||
return _ssh_client_from_machine_config(machine, store)
|
||||
|
||||
settings = get_settings()
|
||||
logger.info(
|
||||
@@ -209,7 +231,19 @@ def get_ssh_client(request: Request = None):
|
||||
)
|
||||
if not settings.ssh_key_path:
|
||||
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
|
||||
return _ssh_client_for(("legacy", settings.ssh_host, settings.ssh_username, settings.ssh_port, settings.ssh_key_path, settings.ssh_password or None, None, None, str(settings.ssh_known_hosts_file)))
|
||||
return _ssh_client_for(
|
||||
(
|
||||
"legacy",
|
||||
settings.ssh_host,
|
||||
settings.ssh_username,
|
||||
settings.ssh_port,
|
||||
settings.ssh_key_path,
|
||||
settings.ssh_password or None,
|
||||
None,
|
||||
None,
|
||||
str(settings.ssh_known_hosts_file),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_mail_queue() -> MailQueue:
|
||||
|
||||
@@ -32,7 +32,11 @@ def media_streams(item: dict[str, Any], stream_type: str | None = None) -> list[
|
||||
streams.extend(source.get("MediaStreams") or [])
|
||||
if stream_type is None:
|
||||
return streams
|
||||
return [stream for stream in streams if str(stream.get("Type") or stream.get("codec_type") or "").lower() == stream_type.lower()]
|
||||
return [
|
||||
stream
|
||||
for stream in streams
|
||||
if str(stream.get("Type") or stream.get("codec_type") or "").lower() == stream_type.lower()
|
||||
]
|
||||
|
||||
|
||||
def stream_value(stream: dict[str, Any], *keys: str) -> Any:
|
||||
|
||||
@@ -52,6 +52,42 @@ JOB_TEMPLATES: dict[str, JobTemplate] = {
|
||||
description="Lists empty directories under the selected path. Does not delete anything.",
|
||||
command_template="find {path} -type d -empty -print",
|
||||
),
|
||||
"install_node_exporter": JobTemplate(
|
||||
name="Install Node Exporter",
|
||||
description="Downloads and installs prometheus-node-exporter via package manager (apt/dnf/yum/zypper).",
|
||||
command_template=(
|
||||
"set -e; "
|
||||
"if command -v apt-get >/dev/null 2>&1; then "
|
||||
"sudo apt-get update && sudo apt-get install -y prometheus-node-exporter; "
|
||||
"elif command -v dnf >/dev/null 2>&1; then "
|
||||
"sudo dnf install -y prometheus-node-exporter; "
|
||||
"elif command -v yum >/dev/null 2>&1; then "
|
||||
"sudo yum install -y prometheus-node-exporter; "
|
||||
"elif command -v zypper >/dev/null 2>&1; then "
|
||||
"sudo zypper install -y prometheus-node-exporter; "
|
||||
"else echo 'No supported package manager found' >&2; exit 1; "
|
||||
"fi; "
|
||||
"sudo systemctl enable --now prometheus-node-exporter; "
|
||||
"echo installed at {path}"
|
||||
),
|
||||
),
|
||||
"restart_node_exporter": JobTemplate(
|
||||
name="Restart Node Exporter",
|
||||
description="Restarts the prometheus-node-exporter systemd service.",
|
||||
command_template="sudo systemctl restart prometheus-node-exporter; echo restarted at {path}",
|
||||
),
|
||||
"node_exporter_status": JobTemplate(
|
||||
name="Node Exporter status",
|
||||
description="Checks whether prometheus-node-exporter is installed, enabled, and running.",
|
||||
command_template=(
|
||||
"systemctl status prometheus-node-exporter --no-pager || true; "
|
||||
"echo '---'; "
|
||||
"command -v node_exporter >/dev/null 2>&1 "
|
||||
"&& node_exporter --version 2>&1 | head -1 "
|
||||
"|| echo 'node_exporter binary not found'; "
|
||||
"echo checked {path}"
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,19 +4,42 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
def configure_logging(level_name: str | None = None) -> int:
|
||||
|
||||
def _json_formatter() -> logging.Formatter:
|
||||
"""Return a JSON formatter that preserves timestamp, level, logger, and message."""
|
||||
return jsonlogger.JsonFormatter(
|
||||
fmt="%(timestamp)s %(level)s %(name)s %(message)s",
|
||||
rename_fields={"asctime": "timestamp", "levelname": "level"},
|
||||
)
|
||||
|
||||
|
||||
def _text_formatter() -> logging.Formatter:
|
||||
"""Return the legacy plain-text formatter."""
|
||||
return logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
def configure_logging(level_name: str | None = None, log_format: str | None = None) -> int:
|
||||
"""Configure root logging once and return the numeric log level."""
|
||||
resolved_name = (level_name or os.getenv("LOG_LEVEL", "INFO")).upper()
|
||||
level = getattr(logging, resolved_name, logging.INFO)
|
||||
use_json = (log_format or os.getenv("LOG_FORMAT", "text")).lower() == "json"
|
||||
|
||||
root = logging.getLogger()
|
||||
if not root.handlers:
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(_json_formatter() if use_json else _text_formatter())
|
||||
logging.basicConfig(level=level, handlers=[handler])
|
||||
else:
|
||||
# Reconfigure existing handler formatters if the format changed.
|
||||
formatter = _json_formatter() if use_json else _text_formatter()
|
||||
for handler in root.handlers:
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root.setLevel(level)
|
||||
logging.getLogger("media_library_viewer_api").setLevel(level)
|
||||
logging.getLogger("uvicorn").setLevel(level)
|
||||
@@ -40,6 +63,7 @@ def describe_settings(settings: object) -> dict[str, str]:
|
||||
"""Return a secret-safe summary of the current backend settings."""
|
||||
return {
|
||||
"log_level": str(getattr(settings, "log_level", "INFO") or "INFO").upper(),
|
||||
"log_format": str(getattr(settings, "log_format", "text") or "text").lower(),
|
||||
"auth_enabled": str(bool(getattr(settings, "auth_enabled", True))),
|
||||
"oidc_issuer_url": _sanitize_url(getattr(settings, "oidc_issuer_url", "")),
|
||||
"oidc_audience": getattr(settings, "oidc_audience", "") or "<unset>",
|
||||
@@ -60,3 +84,18 @@ def describe_settings(settings: object) -> dict[str, str]:
|
||||
"remote_media_root": getattr(settings, "remote_media_root", "") or "<unset>",
|
||||
"remote_path_prefix": getattr(settings, "remote_path_prefix", "") or "<unset>",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_log_extra(extra: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Remove obvious secret keys from log extra fields before emission."""
|
||||
if not extra:
|
||||
return {}
|
||||
blocked_suffixes = ("_password", "_secret", "_token", "_key", "private_key", "passphrase")
|
||||
sanitized: dict[str, Any] = {}
|
||||
for key, value in extra.items():
|
||||
lower_key = key.lower()
|
||||
if any(lower_key.endswith(suffix) for suffix in blocked_suffixes):
|
||||
sanitized[key] = "<redacted>" if value else ""
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@@ -7,13 +7,20 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response as FastAPIResponse
|
||||
|
||||
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra
|
||||
from media_library_viewer_api.observability import (
|
||||
get_request_id,
|
||||
metrics_payload,
|
||||
record_request,
|
||||
set_current_request_id,
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
@@ -28,10 +35,16 @@ logger = logging.getLogger(__name__)
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan — startup/shutdown."""
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
configure_logging(settings.log_level, settings.log_format)
|
||||
validate_auth_settings(settings)
|
||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||
try:
|
||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
||||
|
||||
write_prometheus_targets(get_settings_store())
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
||||
mail_queue = get_mail_queue()
|
||||
monitoring_poller = get_monitoring_poller()
|
||||
backup_poller = get_backup_poller()
|
||||
@@ -49,8 +62,7 @@ app = FastAPI(
|
||||
title="Manage API",
|
||||
version=get_backend_version(),
|
||||
description=(
|
||||
"Manage API for Jellyfin media browsing, SSH file inspection, "
|
||||
"server monitoring, and JWT-protected access."
|
||||
"Manage API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
@@ -69,30 +81,51 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def enforce_jwt_auth(request: Request, call_next):
|
||||
if request.url.path in {"/api/health", "/api/version"}:
|
||||
if request.url.path in {"/api/health", "/api/version", "/metrics"}:
|
||||
return await call_next(request)
|
||||
return await require_jwt_auth(request, call_next)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""Log every API request with timing and outcome."""
|
||||
"""Log every API request with timing, outcome, and request id."""
|
||||
start = time.perf_counter()
|
||||
request_id = get_request_id(request)
|
||||
set_current_request_id(request_id)
|
||||
request.state.request_id = request_id
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
logger.info("request start %s %s client=%s", request.method, request.url.path, client_host)
|
||||
extra = sanitize_log_extra({"request_id": request_id, "client_host": client_host})
|
||||
logger.info("request start %s %s", request.method, request.url.path, extra=extra)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.exception("request error %s %s client=%s", request.method, request.url.path, client_host)
|
||||
logger.exception(
|
||||
"request error %s %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
extra=sanitize_log_extra({"request_id": request_id, "client_host": client_host}),
|
||||
)
|
||||
raise
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
||||
response.headers["X-Request-Id"] = request_id
|
||||
record_request(request, response, elapsed_ms / 1000.0)
|
||||
logger.info(
|
||||
"request end %s %s status=%s elapsed_ms=%.1f",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
extra=sanitize_log_extra(
|
||||
{
|
||||
"request_id": request_id,
|
||||
"client_host": client_host,
|
||||
"status_code": response.status_code,
|
||||
"elapsed_ms": round(elapsed_ms, 1),
|
||||
}
|
||||
),
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -123,5 +156,12 @@ def version_info() -> dict[str, str]:
|
||||
return get_version_info()
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
def metrics() -> Response:
|
||||
"""Expose Prometheus metrics."""
|
||||
data, content_type = metrics_payload()
|
||||
return FastAPIResponse(content=data, media_type=content_type)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Application observability: metrics and request correlation.
|
||||
|
||||
This module owns Prometheus metrics and request-id generation so that the
|
||||
rest of the backend can stay focused on business logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from prometheus_client import (
|
||||
CONTENT_TYPE_LATEST,
|
||||
Counter,
|
||||
Gauge,
|
||||
Histogram,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
# Context-local request id for code paths that cannot receive a Request object.
|
||||
_current_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
|
||||
# Request metrics
|
||||
REQUESTS_TOTAL = Counter(
|
||||
"manage_api_requests_total",
|
||||
"Total API requests",
|
||||
["method", "path", "status_code"],
|
||||
)
|
||||
REQUEST_DURATION = Histogram(
|
||||
"manage_api_request_duration_seconds",
|
||||
"API request duration",
|
||||
["method", "path"],
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0),
|
||||
)
|
||||
|
||||
# Operation metrics
|
||||
SSH_COMMANDS_TOTAL = Counter(
|
||||
"manage_ssh_commands_total",
|
||||
"Total SSH/local commands executed",
|
||||
["machine_id", "action", "status"],
|
||||
)
|
||||
SSH_COMMAND_DURATION = Histogram(
|
||||
"manage_ssh_command_duration_seconds",
|
||||
"SSH/local command duration",
|
||||
["machine_id", "action"],
|
||||
buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
||||
)
|
||||
|
||||
MEDIA_INDEX_BUILDS_TOTAL = Counter(
|
||||
"manage_media_index_builds_total",
|
||||
"Total media index build attempts",
|
||||
["status"],
|
||||
)
|
||||
MEDIA_INDEX_BUILD_DURATION = Histogram(
|
||||
"manage_media_index_build_duration_seconds",
|
||||
"Media index build duration",
|
||||
buckets=(1.0, 5.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0),
|
||||
)
|
||||
|
||||
BACKUP_RUNS_TOTAL = Counter(
|
||||
"manage_backup_runs_total",
|
||||
"Total backup runs",
|
||||
["job_name", "status"],
|
||||
)
|
||||
|
||||
BACKUP_RUNS_LAST_SUCCESS = Gauge(
|
||||
"manage_backup_runs_last_success_timestamp",
|
||||
"Unix timestamp of the last successful backup run per job",
|
||||
["job_name"],
|
||||
)
|
||||
|
||||
MAIL_QUEUE_SIZE = Counter(
|
||||
"manage_mail_queue_messages_total",
|
||||
"Total messages enqueued",
|
||||
["status"],
|
||||
)
|
||||
|
||||
|
||||
def set_current_request_id(request_id: str | None) -> None:
|
||||
"""Set the context-local request id."""
|
||||
_current_request_id.set(request_id)
|
||||
|
||||
|
||||
def get_current_request_id() -> str | None:
|
||||
"""Return the current context-local request id or None."""
|
||||
return _current_request_id.get()
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""Return a short unique request id."""
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def get_request_id(request: Request | None = None) -> str:
|
||||
"""Resolve a request id from the request header, context, or a new value."""
|
||||
if request is not None:
|
||||
header = request.headers.get("x-request-id") or request.headers.get("x-correlation-id")
|
||||
if header:
|
||||
return header.strip()
|
||||
existing = _current_request_id.get()
|
||||
if existing:
|
||||
return existing
|
||||
new_id = generate_request_id()
|
||||
_current_request_id.set(new_id)
|
||||
return new_id
|
||||
|
||||
|
||||
def metrics_payload() -> tuple[bytes, str]:
|
||||
"""Return the Prometheus metrics payload and content type."""
|
||||
return generate_latest(), CONTENT_TYPE_LATEST
|
||||
|
||||
|
||||
def record_request(request: Request, response: Response, duration_seconds: float) -> None:
|
||||
"""Record Prometheus metrics for a completed request."""
|
||||
status = str(response.status_code)
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc()
|
||||
REQUEST_DURATION.labels(method=method, path=path).observe(duration_seconds)
|
||||
|
||||
|
||||
def record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) -> None:
|
||||
"""Record metrics for an SSH/local command."""
|
||||
SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc()
|
||||
SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe(duration_seconds)
|
||||
|
||||
|
||||
def record_media_index_build(status: str, duration_seconds: float | None = None) -> None:
|
||||
"""Record metrics for a media index build."""
|
||||
MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc()
|
||||
if duration_seconds is not None:
|
||||
MEDIA_INDEX_BUILD_DURATION.observe(duration_seconds)
|
||||
|
||||
|
||||
def record_backup_run(job_name: str, status: str, success: bool = False) -> None:
|
||||
"""Record metrics for a backup run."""
|
||||
job_name = job_name or "unknown"
|
||||
BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc()
|
||||
if success:
|
||||
BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time()
|
||||
|
||||
|
||||
def record_mail_queue(status: str) -> None:
|
||||
"""Record metrics for a mail queue message outcome."""
|
||||
MAIL_QUEUE_SIZE.labels(status=status).inc()
|
||||
|
||||
|
||||
def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Build a standard extra dict for structured logging."""
|
||||
extra: dict[str, Any] = {"request_id": get_request_id(request)}
|
||||
extra.update(kwargs)
|
||||
return extra
|
||||
@@ -57,7 +57,7 @@ def map_path_to_media_root(path: str, media_root: str) -> str:
|
||||
|
||||
if root_anchor in raw_parts:
|
||||
anchor_index = raw_parts.index(root_anchor)
|
||||
remainder_parts = raw_parts[anchor_index + 1:]
|
||||
remainder_parts = raw_parts[anchor_index + 1 :]
|
||||
resolved = posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
|
||||
logger.debug("Mapped path to media root path=%s media_root=%s resolved=%s", path, normalized_root, resolved)
|
||||
return resolved
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..models.backups import (
|
||||
BackupReportRequest,
|
||||
BackupRunResponse,
|
||||
)
|
||||
from ..observability import record_backup_run
|
||||
from ..services.backup_alert_engine import generate_alerts_for_run
|
||||
from ..services.settings_store import SettingsStore, get_settings_store
|
||||
|
||||
@@ -17,20 +18,24 @@ router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
|
||||
job = store.get_backup_job_by_name(report.name)
|
||||
if not job:
|
||||
job = store.upsert_backup_job({
|
||||
"name": report.name,
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
})
|
||||
job = store.upsert_backup_job(
|
||||
{
|
||||
"name": report.name,
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
}
|
||||
)
|
||||
elif report.schedule_interval_seconds:
|
||||
store.upsert_backup_job({
|
||||
"id": job["id"],
|
||||
"name": report.name,
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
})
|
||||
store.upsert_backup_job(
|
||||
{
|
||||
"id": job["id"],
|
||||
"name": report.name,
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
}
|
||||
)
|
||||
job = store.get_backup_job(job["id"])
|
||||
return job
|
||||
|
||||
@@ -61,6 +66,7 @@ def post_backup_report(
|
||||
"details": report.details,
|
||||
}
|
||||
run = store.create_backup_run(run_data)
|
||||
record_backup_run(job_name=report.name, status=report.status, success=report.status == "success")
|
||||
|
||||
# Generate alerts
|
||||
previous_runs = store.list_backup_runs(job_id=job["id"], status="success", limit=20)
|
||||
@@ -93,6 +99,7 @@ def post_backup_start(
|
||||
"status": "in_progress",
|
||||
}
|
||||
run = store.create_backup_run(run_data)
|
||||
record_backup_run(job_name=report.name, status="in_progress")
|
||||
# Map details -> details_json for response model
|
||||
run["details_json"] = run.pop("details", None)
|
||||
return BackupRunResponse(**run)
|
||||
|
||||
@@ -11,12 +11,10 @@ from fastapi import APIRouter, Depends
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_monitoring_poller,
|
||||
get_settings_store,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.monitoring_actions import collect_machine_overview
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,52 +44,6 @@ def get_library_counts(
|
||||
return client.library_item_counts(user_id, libraries)
|
||||
|
||||
|
||||
@router.get("/monitoring")
|
||||
def get_monitoring_overview(
|
||||
store=Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return one lightweight monitoring row per configured machine."""
|
||||
machines = [m for m in store.list_machines() if m.get("enabled")]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for machine in machines:
|
||||
try:
|
||||
rows.append(collect_machine_overview(machine, store))
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Dashboard monitoring overview failed for machine_id=%s",
|
||||
machine.get("id"),
|
||||
)
|
||||
rows.append({
|
||||
"machine": {k: machine.get(k) for k in (
|
||||
"id", "name", "mode", "enabled", "host", "port", "username",
|
||||
"media_root", "path_prefix", "notes",
|
||||
)},
|
||||
"status": "",
|
||||
"status_error": str(exc),
|
||||
"metrics_error": str(exc),
|
||||
"disk_error": str(exc),
|
||||
"sample_count": 0,
|
||||
"latest_sample": None,
|
||||
"cpu_summary": None,
|
||||
"iowait_summary": None,
|
||||
"mem_summary": None,
|
||||
"net_rx_summary": None,
|
||||
"net_tx_summary": None,
|
||||
"disk_read_summary": None,
|
||||
"disk_write_summary": None,
|
||||
"disk": None,
|
||||
})
|
||||
poller = get_monitoring_poller().snapshot()
|
||||
enabled_count = sum(1 for machine in machines if machine.get("enabled"))
|
||||
logger.info("Dashboard monitoring machines=%s enabled=%s", len(machines), enabled_count)
|
||||
return {
|
||||
"poller": poller,
|
||||
"machines": rows,
|
||||
"total": len(machines),
|
||||
"enabled": enabled_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/shortcuts")
|
||||
def get_shortcuts(
|
||||
store=Depends(get_settings_store),
|
||||
@@ -144,8 +96,8 @@ def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
|
||||
) if has_item else "(idle)"
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
@@ -162,16 +114,18 @@ def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append({
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.observability import record_media_index_build
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -169,6 +170,7 @@ def post_build_index(
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
record_media_index_build(status="started")
|
||||
logger.info("Media index build started pid=%s", process.pid)
|
||||
return {"status": "started", **_serialize_status(media_status)}
|
||||
|
||||
@@ -256,6 +258,7 @@ def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str,
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
record_media_index_build(status="force_stopped")
|
||||
return {"status": "force_stopped", **_serialize_status(media_status)}
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,70 @@
|
||||
"""Monitoring router — metrics, collector controls, and per-machine status."""
|
||||
"""Monitoring router — disk checks, action history, and observability stack status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from media_library_viewer_api.clients.resources import (
|
||||
disk_space,
|
||||
read_resource_metrics,
|
||||
resource_collector_status,
|
||||
)
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.monitoring_actions import (
|
||||
poll_machine_diagnostics,
|
||||
disk_space,
|
||||
run_machine_operation,
|
||||
start_collector,
|
||||
stop_collector,
|
||||
restart_collector,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _alertmanager_client() -> Any:
|
||||
"""Return a simple HTTP client for the configured Alertmanager URL."""
|
||||
import requests
|
||||
|
||||
settings = get_settings()
|
||||
return requests.Session(), settings.alertmanager_url
|
||||
|
||||
|
||||
def _webhook_client() -> Any:
|
||||
"""Return a simple HTTP client for the optional webhook receiver URL."""
|
||||
import requests
|
||||
|
||||
settings = get_settings()
|
||||
return requests.Session(), settings.alertmanager_webhook_url
|
||||
|
||||
|
||||
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
|
||||
|
||||
@@ -63,7 +102,11 @@ def get_poller_status() -> dict[str, Any]:
|
||||
from media_library_viewer_api.dependencies import get_monitoring_poller
|
||||
|
||||
poller = get_monitoring_poller().snapshot()
|
||||
logger.info("Monitoring poller status requested running=%s poll_count=%s", poller.get("worker_running"), poller.get("poll_count"))
|
||||
logger.info(
|
||||
"Monitoring poller status requested running=%s poll_count=%s",
|
||||
poller.get("worker_running"),
|
||||
poller.get("poll_count"),
|
||||
)
|
||||
return poller
|
||||
|
||||
|
||||
@@ -81,53 +124,6 @@ def get_machine_actions(
|
||||
return {"items": actions, "total": len(actions)}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_status(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Return collector running status for a given machine."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
status = run_machine_operation(machine, store, "status lookup", resource_collector_status)
|
||||
logger.info("Monitoring status requested machine_id=%s status=%s", machine["id"], status)
|
||||
return {"status": status}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def get_metrics(
|
||||
max_lines: int = 70_000,
|
||||
last_seconds: int | None = None,
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return resource metric samples for a given machine."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
|
||||
def _read_metrics(client):
|
||||
return read_resource_metrics(client, max_lines=max_lines)
|
||||
|
||||
rows = run_machine_operation(machine, store, "metrics read", _read_metrics)
|
||||
if last_seconds is None:
|
||||
filtered = rows
|
||||
cutoff_ts = 0.0
|
||||
else:
|
||||
cutoff_ts = time.time() - last_seconds
|
||||
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
|
||||
logger.info(
|
||||
"Monitoring metrics requested machine_id=%s total=%s filtered=%s last_seconds=%s",
|
||||
machine["id"],
|
||||
len(rows),
|
||||
len(filtered),
|
||||
last_seconds,
|
||||
)
|
||||
return {
|
||||
"samples": filtered,
|
||||
"total_samples": len(rows),
|
||||
"filtered_samples": len(filtered),
|
||||
"cutoff_ts": cutoff_ts,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/disk")
|
||||
def get_disk_space(
|
||||
machine_id: str | None = Query(default=None),
|
||||
@@ -146,49 +142,99 @@ def get_disk_space(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
def post_start(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Start the machine's resource collector."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
message = start_collector(machine, store)
|
||||
logger.info("Monitoring collector start result machine_id=%s: %s", machine["id"], message)
|
||||
return {"message": message}
|
||||
@router.get("/prometheus-targets")
|
||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
"""Return Prometheus file-SD targets for remote Node Exporters.
|
||||
|
||||
The backend writes these targets to a JSON file that Prometheus reads via
|
||||
file_sd_configs. This endpoint returns the same list live from the store so
|
||||
the UI can preview which machines will be scraped.
|
||||
"""
|
||||
targets = build_node_exporter_targets(store)
|
||||
logger.info("Prometheus targets requested count=%s", len(targets))
|
||||
return targets
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
def post_stop(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Stop the machine's resource collector."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
message = stop_collector(machine, store)
|
||||
logger.info("Monitoring collector stop result machine_id=%s: %s", machine["id"], message)
|
||||
return {"message": message}
|
||||
@router.get("/alerts")
|
||||
def get_alertmanager_alerts() -> dict[str, Any]:
|
||||
"""Return a summary of active Alertmanager alerts for the UI.
|
||||
|
||||
Proxies the Alertmanager `/api/v1/alerts` endpoint and reshapes the payload
|
||||
into a stable, UI-friendly format. If Alertmanager is unreachable, the
|
||||
endpoint returns an empty summary and logs the failure so the UI can still
|
||||
render a health card instead of an error page.
|
||||
"""
|
||||
session, base_url = _alertmanager_client()
|
||||
try:
|
||||
response = session.get(f"{base_url}/api/v1/alerts", timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Alertmanager alerts from %s", base_url)
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_unreachable"}
|
||||
|
||||
if data.get("status") != "success":
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": data.get("error", "unknown")}
|
||||
|
||||
summary = _summary_from_alerts(data.get("data", []))
|
||||
logger.info("Alertmanager alerts requested total=%s", summary["total"])
|
||||
return summary
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
def post_restart(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Restart the machine's resource collector."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
message = restart_collector(machine, store)
|
||||
logger.info("Monitoring collector restart result machine_id=%s: %s", machine["id"], message)
|
||||
return {"message": message}
|
||||
@router.get("/alertmanager-status")
|
||||
def get_alertmanager_status() -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card.
|
||||
|
||||
Uses the Alertmanager `/api/v2/status` endpoint and exposes only the high-
|
||||
level fields the UI needs: uptime, version, and whether the cluster is
|
||||
healthy.
|
||||
"""
|
||||
session, base_url = _alertmanager_client()
|
||||
try:
|
||||
response = session.get(f"{base_url}/api/v2/status", timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Alertmanager status from %s", base_url)
|
||||
return {"up": False, "version": "", "uptime": ""}
|
||||
|
||||
cluster = data.get("cluster") or {}
|
||||
status = data.get("clusterStatus") or {}
|
||||
return {
|
||||
"up": True,
|
||||
"version": data.get("versionInfo", {}).get("version", ""),
|
||||
"uptime": status.get("createdAt", ""),
|
||||
"name": "",
|
||||
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/diagnostics")
|
||||
def get_diagnostics(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Return collector debug info for a given machine."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
diagnostics = poll_machine_diagnostics(machine, store)["diagnostics"]
|
||||
logger.info("Monitoring diagnostics requested machine_id=%s", machine["id"])
|
||||
return {"diagnostics": diagnostics}
|
||||
@router.post("/alertmanager-webhook")
|
||||
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
|
||||
"""Receive alerts from Alertmanager and optionally forward to a webhook URL.
|
||||
|
||||
This endpoint is the receiver referenced by the optional `webhook_configs`
|
||||
block in Alertmanager. It logs the payload for audit/debug purposes and, if
|
||||
`ALERTMANAGER_WEBHOOK_URL` is configured, forwards the alert JSON verbatim.
|
||||
Forwarding is best-effort: a failure to reach the downstream webhook does
|
||||
not fail this endpoint, so Alertmanager sees a successful delivery.
|
||||
"""
|
||||
alerts = payload.get("alerts", [])
|
||||
logger.info(
|
||||
"Received Alertmanager webhook with %s alert(s), status=%s",
|
||||
len(alerts),
|
||||
payload.get("status", "unknown"),
|
||||
)
|
||||
|
||||
session, webhook_url = _webhook_client()
|
||||
if webhook_url:
|
||||
try:
|
||||
response = session.post(webhook_url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
logger.info("Forwarded Alertmanager webhook to %s", webhook_url)
|
||||
except Exception:
|
||||
logger.exception("Failed to forward Alertmanager webhook to %s", webhook_url)
|
||||
else:
|
||||
logger.debug("No ALERTMANAGER_WEBHOOK_URL configured; webhook stored in logs only")
|
||||
|
||||
return {"status": "received"}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
@@ -12,11 +13,13 @@ from pydantic import BaseModel, Field
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store
|
||||
from media_library_viewer_api.services.monitoring_actions import start_collector
|
||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@@ -95,9 +98,7 @@ def _raise_ssh_validation_error(host: str, port: int, exc: Exception) -> None:
|
||||
if "protocol banner" in lowered:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=(
|
||||
f"SSH banner not received from {host}:{port}; the backend could not complete the SSH handshake."
|
||||
),
|
||||
detail=(f"SSH banner not received from {host}:{port}; the backend could not complete the SSH handshake."),
|
||||
) from exc
|
||||
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
||||
raise HTTPException(
|
||||
@@ -125,10 +126,12 @@ def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: Settings
|
||||
client.close()
|
||||
|
||||
|
||||
def _start_machine_collector(machine: MonitoringMachineInput, store: SettingsStore) -> None:
|
||||
if "monitoring" not in {str(service).strip().lower() for service in machine.services}:
|
||||
return
|
||||
start_collector(machine.model_dump(exclude_none=True), store)
|
||||
def _write_prometheus_targets(store: SettingsStore) -> None:
|
||||
"""Regenerate Prometheus file-SD targets after machine changes."""
|
||||
try:
|
||||
write_prometheus_targets(store)
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets")
|
||||
|
||||
|
||||
@router.post("/machines/test-ssh")
|
||||
@@ -137,7 +140,9 @@ def test_machine_ssh(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if str(machine.mode or "").strip().lower() != "ssh":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SSH validation only applies to SSH machines")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="SSH validation only applies to SSH machines"
|
||||
)
|
||||
|
||||
client, host, port = _resolve_ssh_client(machine, store)
|
||||
settings = get_settings()
|
||||
@@ -174,7 +179,8 @@ def test_machine_ssh(
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": (
|
||||
f"SSH connection succeeded for {host}:{port}; host key {'was recorded' if known_hosts_updated else 'was already trusted'} and authentication worked."
|
||||
f"SSH connection succeeded for {host}:{port}; host key "
|
||||
f"{'was recorded' if known_hosts_updated else 'was already trusted'} and authentication worked."
|
||||
),
|
||||
"host": host,
|
||||
"port": port,
|
||||
@@ -188,11 +194,11 @@ def post_machine(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||
_write_prometheus_targets(store)
|
||||
poller = get_monitoring_poller()
|
||||
try:
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
_start_machine_collector(saved_machine, store)
|
||||
finally:
|
||||
poller.start()
|
||||
poller.kick()
|
||||
@@ -208,11 +214,11 @@ 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)
|
||||
_write_prometheus_targets(store)
|
||||
poller = get_monitoring_poller()
|
||||
try:
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
_start_machine_collector(saved_machine, store)
|
||||
finally:
|
||||
poller.start()
|
||||
poller.kick()
|
||||
@@ -224,6 +230,7 @@ def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
store.delete_machine(machine_id)
|
||||
_write_prometheus_targets(store)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@@ -311,8 +318,12 @@ def reset_local_database(
|
||||
expected = "RESET LOCAL DATABASE"
|
||||
if payload.confirm_phrase.strip().upper() != expected:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match")
|
||||
if not (payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected")
|
||||
if not (
|
||||
payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected"
|
||||
)
|
||||
|
||||
settings_removed = remove_sqlite_database(store.db_path)
|
||||
media_index = MediaIndex()
|
||||
|
||||
@@ -212,7 +212,8 @@ def _merge_users(
|
||||
"source_summary": summary,
|
||||
"name_source": name_source,
|
||||
"access_source": access_source,
|
||||
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId")) or None,
|
||||
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
|
||||
or None,
|
||||
"jellyseerr_username": str(
|
||||
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
|
||||
),
|
||||
@@ -364,9 +365,10 @@ async def post_user_message(
|
||||
text_body=text_body,
|
||||
attachments=attachment_payloads,
|
||||
)
|
||||
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip() or str(
|
||||
getattr(settings, "smtp_username", "") or ""
|
||||
).strip()
|
||||
from_address = (
|
||||
str(getattr(settings, "smtp_from_address", "") or "").strip()
|
||||
or str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
)
|
||||
logger.info(
|
||||
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
|
||||
request_id,
|
||||
|
||||
@@ -13,20 +13,21 @@ def generate_alerts_for_run(
|
||||
|
||||
# 1. Failed status alert
|
||||
if run["status"] == "failure":
|
||||
alerts.append({
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "failed_status",
|
||||
"severity": "critical",
|
||||
"message": f"Backup job '{job_name}' failed: {run.get('error_message', 'No error details')}",
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "failed_status",
|
||||
"severity": "critical",
|
||||
"message": f"Backup job '{job_name}' failed: {run.get('error_message', 'No error details')}",
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Anomaly size alert
|
||||
bytes_transferred = run.get("bytes_transferred")
|
||||
if bytes_transferred is not None and previous_runs:
|
||||
successful_runs = [
|
||||
r for r in previous_runs
|
||||
if r["status"] == "success" and r.get("bytes_transferred") is not None
|
||||
r for r in previous_runs if r["status"] == "success" and r.get("bytes_transferred") is not None
|
||||
]
|
||||
if len(successful_runs) >= 3:
|
||||
sizes = [r["bytes_transferred"] for r in successful_runs[-7:]]
|
||||
@@ -34,24 +35,28 @@ def generate_alerts_for_run(
|
||||
if median_size > 0:
|
||||
ratio = bytes_transferred / median_size
|
||||
if bytes_transferred == 0:
|
||||
alerts.append({
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_size",
|
||||
"severity": "warning",
|
||||
"message": f"Backup job '{job_name}' transferred 0 bytes (median: {median_size})",
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_size",
|
||||
"severity": "warning",
|
||||
"message": f"Backup job '{job_name}' transferred 0 bytes (median: {median_size})",
|
||||
}
|
||||
)
|
||||
elif ratio < 0.1 or ratio > 3.0:
|
||||
alerts.append({
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_size",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job_name}' size anomaly: "
|
||||
f"{bytes_transferred} bytes (median: {median_size})"
|
||||
),
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_size",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job_name}' size anomaly: "
|
||||
f"{bytes_transferred} bytes (median: {median_size})"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# 3. Anomaly duration alert
|
||||
duration_ms = run.get("duration_ms")
|
||||
@@ -61,16 +66,17 @@ def generate_alerts_for_run(
|
||||
durations = [r["duration_ms"] for r in successful_runs[-7:]]
|
||||
median_duration = statistics.median(durations)
|
||||
if median_duration > 0 and duration_ms / median_duration > 3.0:
|
||||
alerts.append({
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_duration",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job_name}' duration anomaly: "
|
||||
f"{duration_ms}ms (median: {median_duration}ms)"
|
||||
),
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"run_id": run["id"],
|
||||
"alert_type": "anomaly_duration",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job_name}' duration anomaly: {duration_ms}ms (median: {median_duration}ms)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
@@ -82,6 +88,7 @@ def check_missed_schedules(
|
||||
) -> list[dict[str, Any]]:
|
||||
alerts = []
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
for job in jobs:
|
||||
@@ -93,31 +100,36 @@ def check_missed_schedules(
|
||||
if not latest_run:
|
||||
# No runs ever — alert if job is older than interval * 1.5
|
||||
if now - job["created_at"] > interval * 1.5:
|
||||
alerts.append({
|
||||
"job_id": job["id"],
|
||||
"run_id": None,
|
||||
"alert_type": "missed_schedule",
|
||||
"severity": "warning",
|
||||
"message": f"Backup job '{job['name']}' has never run (expected every {interval}s)",
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"run_id": None,
|
||||
"alert_type": "missed_schedule",
|
||||
"severity": "warning",
|
||||
"message": f"Backup job '{job['name']}' has never run (expected every {interval}s)",
|
||||
}
|
||||
)
|
||||
else:
|
||||
last_run_time = latest_run["started_at"]
|
||||
if now - last_run_time > interval * 1.5:
|
||||
# Check if there's already an unresolved missed_schedule alert
|
||||
has_open_alert = any(
|
||||
a["alert_type"] == "missed_schedule" and a["resolved_at"] is None
|
||||
for a in existing_alerts if a["job_id"] == job["id"]
|
||||
for a in existing_alerts
|
||||
if a["job_id"] == job["id"]
|
||||
)
|
||||
if not has_open_alert:
|
||||
alerts.append({
|
||||
"job_id": job["id"],
|
||||
"run_id": None,
|
||||
"alert_type": "missed_schedule",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job['name']}' missed schedule: "
|
||||
f"last run at {last_run_time} (expected every {interval}s)"
|
||||
),
|
||||
})
|
||||
alerts.append(
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"run_id": None,
|
||||
"alert_type": "missed_schedule",
|
||||
"severity": "warning",
|
||||
"message": (
|
||||
f"Backup job '{job['name']}' missed schedule: "
|
||||
f"last run at {last_run_time} (expected every {interval}s)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
@@ -15,6 +15,7 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.observability import record_mail_queue
|
||||
from media_library_viewer_api.services.mailer import EmailAttachment, describe_smtp_error, send_email_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -206,6 +207,7 @@ class MailQueue:
|
||||
result.get("recipient_count", 0),
|
||||
result.get("attachment_count", 0),
|
||||
)
|
||||
record_mail_queue("sent")
|
||||
except Exception as exc:
|
||||
friendly_error = describe_smtp_error(exc)
|
||||
with self._lock:
|
||||
@@ -220,6 +222,7 @@ class MailQueue:
|
||||
getattr(message, "request_id", "unknown"),
|
||||
friendly_error,
|
||||
)
|
||||
record_mail_queue("failed")
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import socket
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
@@ -13,7 +13,6 @@ from email.utils import formataddr
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -164,7 +163,11 @@ def _smtp_sender_not_authorized(error: Exception) -> bool:
|
||||
else:
|
||||
raw_error_text = str(raw_error)
|
||||
text = f"{code} {raw_error_text} {error}".lower()
|
||||
return code in {551, 553} or "not authorised to send from this header address" in text or "not authorized to send from this header address" in text
|
||||
return (
|
||||
code in {551, 553}
|
||||
or "not authorised to send from this header address" in text
|
||||
or "not authorized to send from this header address" in text
|
||||
)
|
||||
|
||||
|
||||
def _smtp_attempt_metadata(mode: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -203,10 +206,15 @@ def describe_smtp_error(error: Exception) -> str:
|
||||
)
|
||||
if isinstance(item, (smtplib.SMTPDataError, smtplib.SMTPResponseException)):
|
||||
smtp_code = getattr(item, "smtp_code", None)
|
||||
if smtp_code in {551, 553} or "not authorised to send from this header address" in lowered or "not authorized to send from this header address" in lowered:
|
||||
if (
|
||||
smtp_code in {551, 553}
|
||||
or "not authorised to send from this header address" in lowered
|
||||
or "not authorized to send from this header address" in lowered
|
||||
):
|
||||
return (
|
||||
"SMTP server rejected the configured From address. Use an authorized alias "
|
||||
"for this account or change SMTP_FROM_ADDRESS to a sender the provider allows."
|
||||
"SMTP server rejected the configured From address. "
|
||||
"Use an authorized alias for this account or change "
|
||||
"SMTP_FROM_ADDRESS to a sender the provider allows."
|
||||
)
|
||||
if isinstance(item, smtplib.SMTPConnectError):
|
||||
return "SMTP connection was rejected by the server. Check the host and port."
|
||||
@@ -247,7 +255,9 @@ def build_email_message(
|
||||
msg.set_content(plain_text or "")
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
|
||||
content_type = (
|
||||
attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
|
||||
)
|
||||
maintype, subtype = content_type.split("/", 1) if "/" in content_type else ("application", "octet-stream")
|
||||
msg.add_attachment(
|
||||
attachment.data,
|
||||
@@ -357,7 +367,9 @@ def send_email_message(
|
||||
)
|
||||
if _smtp_sender_not_authorized(exc) and fallback_from_address:
|
||||
logger.warning(
|
||||
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s; retrying with smtp_username",
|
||||
"SMTP send sender rejected label=%s host=%s "
|
||||
"port=%s transport=%s auth_user=%s "
|
||||
"from_address=%s error=%s; retrying with smtp_username",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
@@ -393,7 +405,9 @@ def send_email_message(
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
"SMTP send fallback failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
||||
"SMTP send fallback failed label=%s host=%s "
|
||||
"port=%s transport=%s auth_user=%s "
|
||||
"from_address=%s error=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
@@ -416,7 +430,8 @@ def send_email_message(
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"SMTP send succeeded via smtp_username label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
||||
"SMTP send succeeded via smtp_username label=%s host=%s "
|
||||
"port=%s transport=%s auth_user=%s from_address=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
|
||||
@@ -141,13 +141,20 @@ class MediaIndex:
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_type ON media_items(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_library ON media_items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_title ON media_items(title COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_series ON media_items(series COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_date_added ON media_items(date_added_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_size ON media_items(size_bytes);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bitrate ON media_items(bitrate_bps);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_type
|
||||
ON media_items(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_library
|
||||
ON media_items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_title
|
||||
ON media_items(title COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_series
|
||||
ON media_items(series COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_date_added
|
||||
ON media_items(date_added_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_size
|
||||
ON media_items(size_bytes);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bitrate
|
||||
ON media_items(bitrate_bps);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -208,10 +215,7 @@ class MediaIndex:
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
|
||||
meta = {
|
||||
row[0]: row[1]
|
||||
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
|
||||
}
|
||||
meta = {row[0]: row[1] for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()}
|
||||
except sqlite3.Error:
|
||||
return MediaIndexStatus(exists=False)
|
||||
updated_at_raw = meta.get("updated_at", "")
|
||||
@@ -310,7 +314,10 @@ class MediaIndex:
|
||||
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
|
||||
direction = "DESC" if sort_order == "Descending" else "ASC"
|
||||
# Always add stable tie-breakers.
|
||||
order_sql = f" ORDER BY {sort_sql} {direction}, series COLLATE NOCASE ASC, season_number ASC, episode ASC, title COLLATE NOCASE ASC"
|
||||
order_sql = (
|
||||
f" ORDER BY {sort_sql} {direction}, series COLLATE NOCASE ASC, "
|
||||
"season_number ASC, episode ASC, title COLLATE NOCASE ASC"
|
||||
)
|
||||
|
||||
with self.connect() as conn:
|
||||
total = int(conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone()[0])
|
||||
@@ -421,10 +428,7 @@ def build_media_index(
|
||||
**row,
|
||||
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
|
||||
}
|
||||
for row in (
|
||||
normalize_media_item(item, library_id, current_library_name)
|
||||
for item in items
|
||||
)
|
||||
for row in (normalize_media_item(item, library_id, current_library_name) for item in items)
|
||||
)
|
||||
processed_total += len(items)
|
||||
current_library_processed += len(items)
|
||||
@@ -432,7 +436,8 @@ def build_media_index(
|
||||
ensure_not_cancelled()
|
||||
emit(
|
||||
"building",
|
||||
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
|
||||
f"{current_library_name or 'Library'}: "
|
||||
f"{current_library_processed} / {current_library_total or '?'} items",
|
||||
)
|
||||
logger.debug(
|
||||
"Media index progress library=%s processed=%s/%s total_processed=%s",
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
@@ -16,17 +17,9 @@ from typing import Any, Callable
|
||||
from fastapi import HTTPException
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.resources import (
|
||||
disk_space,
|
||||
read_resource_metrics,
|
||||
resource_collector_debug_info,
|
||||
resource_collector_status,
|
||||
restart_resource_collector,
|
||||
start_resource_collector,
|
||||
stop_resource_collector,
|
||||
)
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.observability import record_ssh_command
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,6 +56,28 @@ def build_machine_client(machine: dict[str, Any], store: SettingsStore):
|
||||
)
|
||||
|
||||
|
||||
def disk_space(client: Any, path: str = "/") -> dict[str, Any]:
|
||||
"""Return df information for the filesystem containing ``path``.
|
||||
|
||||
Works against any client with a ``run`` method (local shell or SSH).
|
||||
"""
|
||||
command = (
|
||||
"df -P -B1 -- " + shlex.quote(path or "/") + " | awk 'NR==2 {printf "
|
||||
'"{\\"filesystem\\":\\"%s\\",\\"size\\":%s,"'
|
||||
'"\\"used\\":%s,\\"available\\":%s,"'
|
||||
'"\\"used_pct\\":\\"%s\\",\\"mount\\":\\"%s\\"}", "'
|
||||
"$1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
logger.debug("Reading disk space for path=%s", path)
|
||||
result = client.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
data = json.loads(result.stdout)
|
||||
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
|
||||
return data
|
||||
|
||||
|
||||
def summarize_operation_result(action: str, result: Any) -> str:
|
||||
"""Turn an operation result into a compact human-readable summary."""
|
||||
if result is None:
|
||||
@@ -73,10 +88,6 @@ def summarize_operation_result(action: str, result: Any) -> str:
|
||||
if isinstance(result, list):
|
||||
return f"{action}: {len(result)} item(s)"
|
||||
if isinstance(result, dict):
|
||||
if action == "metrics read":
|
||||
samples = result.get("samples")
|
||||
if isinstance(samples, list):
|
||||
return f"{action}: {len(samples)} sample(s)"
|
||||
if action.startswith("disk lookup"):
|
||||
used_pct = result.get("used_pct")
|
||||
mount = result.get("mount") or result.get("filesystem")
|
||||
@@ -104,13 +115,21 @@ def run_machine_operation(
|
||||
summarize: Callable[[Any], str] | None = None,
|
||||
request_id: str = "",
|
||||
raise_http: bool = True,
|
||||
client: Any | None = None,
|
||||
) -> Any:
|
||||
"""Run a machine operation, record history, and optionally raise on failure."""
|
||||
started = time.perf_counter()
|
||||
client = build_machine_client(machine, store)
|
||||
if client is None:
|
||||
client = build_machine_client(machine, store)
|
||||
try:
|
||||
result = callback(client)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
record_ssh_command(
|
||||
machine_id=machine.get("id") or "unknown",
|
||||
action=action,
|
||||
status="ok",
|
||||
duration_seconds=duration_ms / 1000.0,
|
||||
)
|
||||
store.record_machine_action(
|
||||
machine,
|
||||
action,
|
||||
@@ -124,6 +143,12 @@ def run_machine_operation(
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - transport/network fallback
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
record_ssh_command(
|
||||
machine_id=machine.get("id") or "unknown",
|
||||
action=action,
|
||||
status="error",
|
||||
duration_seconds=duration_ms / 1000.0,
|
||||
)
|
||||
logger.exception(
|
||||
"Monitoring %s failed machine_id=%s machine_name=%s",
|
||||
action,
|
||||
@@ -157,34 +182,13 @@ def poll_machine_snapshot(
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a backend-scheduled snapshot for a machine.
|
||||
|
||||
This records the same action-history rows that the UI would otherwise get
|
||||
from manual requests, but it runs entirely inside the backend on a schedule.
|
||||
The legacy POSIX collector has been removed; this now records a lightweight
|
||||
disk-space lookup on the same schedule so action history stays useful.
|
||||
"""
|
||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
||||
results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []}
|
||||
|
||||
status = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"status lookup",
|
||||
resource_collector_status,
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
results["status"] = status
|
||||
results["actions"].append("status lookup")
|
||||
|
||||
metrics = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"metrics read",
|
||||
lambda client: read_resource_metrics(client, max_lines=metrics_limit),
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
results["metrics_samples"] = len(metrics or [])
|
||||
results["actions"].append("metrics read")
|
||||
|
||||
client = build_machine_client(machine, store)
|
||||
settings = get_settings()
|
||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
||||
disk = run_machine_operation(
|
||||
@@ -194,6 +198,7 @@ def poll_machine_snapshot(
|
||||
lambda client: disk_space(client, path),
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
client=client,
|
||||
)
|
||||
results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None
|
||||
results["actions"].append("disk lookup")
|
||||
@@ -201,149 +206,3 @@ def poll_machine_snapshot(
|
||||
return results
|
||||
|
||||
|
||||
def _summarize_metric_samples(samples: list[dict[str, Any]], field: str) -> dict[str, float] | None:
|
||||
values = [float(sample.get(field, 0)) for sample in samples if sample.get(field) is not None]
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"avg": sum(values) / len(values),
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"count": float(len(values)),
|
||||
}
|
||||
|
||||
|
||||
def collect_machine_overview(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
metrics_window_seconds: int = 600,
|
||||
metrics_limit: int = 70_000,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a lightweight machine overview without recording history."""
|
||||
overview: dict[str, Any] = {
|
||||
"machine": {k: machine.get(k) for k in ("id", "name", "mode", "enabled", "host", "port", "username", "media_root", "path_prefix", "notes")},
|
||||
"status": "",
|
||||
"status_error": "",
|
||||
"metrics_error": "",
|
||||
"disk_error": "",
|
||||
"sample_count": 0,
|
||||
"latest_sample": None,
|
||||
"cpu_summary": None,
|
||||
"iowait_summary": None,
|
||||
"mem_summary": None,
|
||||
"net_rx_summary": None,
|
||||
"net_tx_summary": None,
|
||||
"disk_read_summary": None,
|
||||
"disk_write_summary": None,
|
||||
"disk": None,
|
||||
}
|
||||
try:
|
||||
client: Any = build_machine_client(machine, store)
|
||||
except Exception as exc:
|
||||
overview["status_error"] = str(exc)
|
||||
overview["metrics_error"] = str(exc)
|
||||
overview["disk_error"] = str(exc)
|
||||
return overview
|
||||
|
||||
settings = get_settings()
|
||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
||||
|
||||
try:
|
||||
overview["status"] = resource_collector_status(client)
|
||||
except Exception as exc:
|
||||
overview["status_error"] = str(exc)
|
||||
|
||||
try:
|
||||
samples = read_resource_metrics(client, max_lines=metrics_limit)
|
||||
if samples:
|
||||
latest_ts = max(float(sample.get("ts", 0)) for sample in samples)
|
||||
window_start = latest_ts - metrics_window_seconds
|
||||
metrics = [sample for sample in samples if float(sample.get("ts", 0)) >= window_start]
|
||||
if not metrics:
|
||||
metrics = samples
|
||||
overview["sample_count"] = len(metrics)
|
||||
overview["latest_sample"] = metrics[-1]
|
||||
overview["cpu_summary"] = _summarize_metric_samples(metrics, "cpu_pct")
|
||||
overview["iowait_summary"] = _summarize_metric_samples(metrics, "iowait_pct")
|
||||
overview["mem_summary"] = _summarize_metric_samples(metrics, "mem_pct")
|
||||
overview["net_rx_summary"] = _summarize_metric_samples(metrics, "net_rx_bytes_per_sec")
|
||||
overview["net_tx_summary"] = _summarize_metric_samples(metrics, "net_tx_bytes_per_sec")
|
||||
overview["disk_read_summary"] = _summarize_metric_samples(metrics, "disk_read_bps")
|
||||
overview["disk_write_summary"] = _summarize_metric_samples(metrics, "disk_write_bps")
|
||||
except Exception as exc:
|
||||
overview["metrics_error"] = str(exc)
|
||||
|
||||
try:
|
||||
overview["disk"] = disk_space(client, path)
|
||||
except Exception as exc:
|
||||
overview["disk_error"] = str(exc)
|
||||
|
||||
return overview
|
||||
|
||||
|
||||
def poll_machine_diagnostics(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a backend-scheduled diagnostics snapshot for a machine."""
|
||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
||||
diagnostics = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector diagnostics",
|
||||
resource_collector_debug_info,
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
return {"request_id": request_id, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
def start_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector start",
|
||||
start_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
|
||||
|
||||
def stop_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector stop",
|
||||
stop_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
|
||||
|
||||
def restart_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector restart",
|
||||
restart_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
|
||||
@@ -136,16 +136,17 @@ class MonitoringPoller:
|
||||
request_id=f"poll:{machine['id']}:{int(time.time())}",
|
||||
)
|
||||
logger.info(
|
||||
"Monitoring poll snapshot machine_id=%s request_id=%s status=%s metrics_samples=%s disk_mount=%s",
|
||||
"Monitoring poll snapshot machine_id=%s request_id=%s disk_mount=%s actions=%s",
|
||||
machine["id"],
|
||||
snapshot.get("request_id"),
|
||||
snapshot.get("status"),
|
||||
snapshot.get("metrics_samples"),
|
||||
snapshot.get("disk_mount"),
|
||||
snapshot.get("actions"),
|
||||
)
|
||||
except Exception:
|
||||
cycle_errors += 1
|
||||
logger.exception("Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"])
|
||||
logger.exception(
|
||||
"Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"]
|
||||
)
|
||||
retention_seconds = config.retention_days * 24 * 60 * 60
|
||||
cutoff_ts = int(time.time()) - retention_seconds
|
||||
removed = store.prune_machine_actions(cutoff_ts)
|
||||
|
||||
@@ -48,6 +48,9 @@ def _default_local_machine() -> dict[str, Any]:
|
||||
"jellyfin_api_key": "",
|
||||
"jellyseerr_url": "",
|
||||
"jellyseerr_api_key": "",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9100,
|
||||
"node_exporter_scrape_host": "",
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
@@ -81,9 +84,7 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS monitoring_machine_actions (
|
||||
@@ -118,9 +119,7 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
existing_key_columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(ssh_keys)").fetchall()
|
||||
}
|
||||
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:
|
||||
@@ -140,9 +139,7 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
||||
@@ -182,10 +179,16 @@ class SettingsStore:
|
||||
"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)"
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time
|
||||
ON monitoring_machine_actions(machine_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status ON monitoring_machine_actions(action, status)"
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status
|
||||
ON monitoring_machine_actions(action, status)
|
||||
"""
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
@@ -251,7 +254,8 @@ class SettingsStore:
|
||||
|
||||
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
data = json.loads(row["config_json"])
|
||||
services = self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [])
|
||||
default_services = DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []
|
||||
services = self._normalize_services(data.get("services"), default_services)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -274,6 +278,9 @@ class SettingsStore:
|
||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
@@ -294,12 +301,18 @@ class SettingsStore:
|
||||
"This machine" if mode == "local" else machine_id
|
||||
)
|
||||
services = self._normalize_services(payload.get("services"), (current or {}).get("services", []))
|
||||
host = str(payload.get("host") if payload.get("host") is not None else (current or {}).get("host", "") or "").strip()
|
||||
|
||||
def _current_str(field: str, default: str = "") -> str:
|
||||
return str(
|
||||
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default
|
||||
).strip()
|
||||
|
||||
host = _current_str("host")
|
||||
port = int(payload.get("port") or (current or {}).get("port", 22) or 22)
|
||||
username = str(payload.get("username") if payload.get("username") is not None else (current or {}).get("username", "") or "").strip()
|
||||
key_directory = str(payload.get("key_directory") if payload.get("key_directory") is not None else (current or {}).get("key_directory", "") or "").strip()
|
||||
key_name = str(payload.get("key_name") if payload.get("key_name") is not None else (current or {}).get("key_name", "") or "").strip()
|
||||
ssh_key_id = str(payload.get("ssh_key_id") if payload.get("ssh_key_id") is not None else (current or {}).get("ssh_key_id", "") or "").strip()
|
||||
username = _current_str("username")
|
||||
key_directory = _current_str("key_directory")
|
||||
key_name = _current_str("key_name")
|
||||
ssh_key_id = _current_str("ssh_key_id")
|
||||
ssh_private_key = payload.get("ssh_private_key")
|
||||
if ssh_private_key in (None, ""):
|
||||
ssh_private_key = (current or {}).get("ssh_private_key", "")
|
||||
@@ -312,20 +325,30 @@ class SettingsStore:
|
||||
if password in (None, ""):
|
||||
password = (current or {}).get("password", "")
|
||||
password = str(password or "")
|
||||
media_root = str(payload.get("media_root") if payload.get("media_root") is not None else (current or {}).get("media_root", "") or "").strip()
|
||||
path_prefix = str(payload.get("path_prefix") if payload.get("path_prefix") is not None else (current or {}).get("path_prefix", "") or "").strip()
|
||||
jellyfin_url = str(payload.get("jellyfin_url") if payload.get("jellyfin_url") is not None else (current or {}).get("jellyfin_url", "") or "").strip()
|
||||
jellyfin_user_id = str(payload.get("jellyfin_user_id") if payload.get("jellyfin_user_id") is not None else (current or {}).get("jellyfin_user_id", "") or "").strip()
|
||||
media_root = _current_str("media_root")
|
||||
path_prefix = _current_str("path_prefix")
|
||||
jellyfin_url = _current_str("jellyfin_url")
|
||||
jellyfin_user_id = _current_str("jellyfin_user_id")
|
||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
||||
if jellyfin_api_key in (None, ""):
|
||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
||||
jellyseerr_url = str(payload.get("jellyseerr_url") if payload.get("jellyseerr_url") is not None else (current or {}).get("jellyseerr_url", "") or "").strip()
|
||||
jellyseerr_url = _current_str("jellyseerr_url")
|
||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
||||
if jellyseerr_api_key in (None, ""):
|
||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
||||
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip()
|
||||
node_exporter_enabled = bool(
|
||||
payload.get("node_exporter_enabled")
|
||||
if payload.get("node_exporter_enabled") is not None
|
||||
else (current or {}).get("node_exporter_enabled", False)
|
||||
)
|
||||
node_exporter_port_raw = payload.get("node_exporter_port")
|
||||
if node_exporter_port_raw is None:
|
||||
node_exporter_port_raw = (current or {}).get("node_exporter_port", 9100)
|
||||
node_exporter_port = int(node_exporter_port_raw or 9100)
|
||||
node_exporter_scrape_host = _current_str("node_exporter_scrape_host")
|
||||
notes = _current_str("notes")
|
||||
if mode == "local":
|
||||
host = host or "localhost"
|
||||
username = username or ""
|
||||
@@ -351,6 +374,9 @@ class SettingsStore:
|
||||
"jellyfin_api_key": jellyfin_api_key,
|
||||
"jellyseerr_url": jellyseerr_url,
|
||||
"jellyseerr_api_key": jellyseerr_api_key,
|
||||
"node_exporter_enabled": node_exporter_enabled,
|
||||
"node_exporter_port": node_exporter_port,
|
||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
@@ -380,6 +406,9 @@ class SettingsStore:
|
||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
with self.connect() as conn:
|
||||
@@ -431,7 +460,10 @@ class SettingsStore:
|
||||
"name": row["name"],
|
||||
"mode": row["mode"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"services": self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []),
|
||||
"services": self._normalize_services(
|
||||
data.get("services"),
|
||||
DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [],
|
||||
),
|
||||
"host": data.get("host", ""),
|
||||
"port": int(data.get("port", 22) or 22),
|
||||
"username": data.get("username", ""),
|
||||
@@ -448,11 +480,18 @@ class SettingsStore:
|
||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
}
|
||||
|
||||
def list_machines_for_service(self, service: str) -> list[dict[str, Any]]:
|
||||
return [machine for machine in self.list_machines() if service in machine.get("services", []) and machine.get("enabled")]
|
||||
return [
|
||||
machine
|
||||
for machine in self.list_machines()
|
||||
if service in machine.get("services", []) and machine.get("enabled")
|
||||
]
|
||||
|
||||
def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
@@ -485,10 +524,16 @@ class SettingsStore:
|
||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM monitoring_machines WHERE id = ?", (machine["id"],)).fetchone()
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM monitoring_machines WHERE id = ?",
|
||||
(machine["id"],),
|
||||
).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -538,10 +583,13 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machine_actions
|
||||
(id, machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail)
|
||||
(
|
||||
id, machine_id, machine_name, mode, action, status,
|
||||
created_at, duration_ms, request_id, message, error,
|
||||
stdout_tail, stderr_tail
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
,
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(machine.get("id") or ""),
|
||||
@@ -577,8 +625,10 @@ class SettingsStore:
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
sql = (
|
||||
"SELECT machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
|
||||
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} ORDER BY created_at DESC LIMIT ?"
|
||||
"SELECT machine_id, machine_name, mode, action, status, "
|
||||
"created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
|
||||
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} "
|
||||
"ORDER BY created_at DESC LIMIT ?"
|
||||
)
|
||||
params.append(max(1, min(int(limit), 200)))
|
||||
with self.connect() as conn:
|
||||
@@ -595,7 +645,6 @@ class SettingsStore:
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||
if not private_key:
|
||||
@@ -639,11 +688,29 @@ class SettingsStore:
|
||||
if passphrase in (None, ""):
|
||||
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()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
).strip()
|
||||
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}
|
||||
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()
|
||||
@@ -666,7 +733,15 @@ 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": 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": 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()
|
||||
@@ -677,7 +752,10 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ssh_keys (id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at)
|
||||
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,
|
||||
@@ -688,7 +766,17 @@ class SettingsStore:
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key["id"], key["name"], key["private_key"], key["passphrase"], key["public_key"], key["fingerprint"], 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
|
||||
|
||||
@@ -697,8 +785,6 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,))
|
||||
|
||||
|
||||
|
||||
def _row_to_task(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
@@ -719,11 +805,27 @@ class SettingsStore:
|
||||
task_type = str(payload.get("task_type") or (current or {}).get("task_type") or "shell").strip().lower()
|
||||
if task_type not in {"shell", "python"}:
|
||||
task_type = "shell"
|
||||
content = str(payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or "")
|
||||
content = str(
|
||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
||||
)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
default_machine_id = str(payload.get("default_machine_id") if payload.get("default_machine_id") is not None else (current or {}).get("default_machine_id", "") or "").strip()
|
||||
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip()
|
||||
return {"id": task_id, "name": name, "task_type": task_type, "content": content, "enabled": enabled, "default_machine_id": default_machine_id, "notes": notes}
|
||||
default_machine_id = str(
|
||||
payload.get("default_machine_id")
|
||||
if payload.get("default_machine_id") is not None
|
||||
else (current or {}).get("default_machine_id", "") or ""
|
||||
).strip()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"task_type": task_type,
|
||||
"content": content,
|
||||
"enabled": enabled,
|
||||
"default_machine_id": default_machine_id,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
def list_tasks(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
@@ -748,7 +850,10 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (id, name, task_type, content, enabled, default_machine_id, notes, created_at, updated_at)
|
||||
INSERT INTO saved_tasks (
|
||||
id, name, task_type, content, enabled, default_machine_id,
|
||||
notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
@@ -759,7 +864,17 @@ class SettingsStore:
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(task["id"], task["name"], task["task_type"], task["content"], 1 if task["enabled"] else 0, task["default_machine_id"], task["notes"], created_at, now),
|
||||
(
|
||||
task["id"],
|
||||
task["name"],
|
||||
task["task_type"],
|
||||
task["content"],
|
||||
1 if task["enabled"] else 0,
|
||||
task["default_machine_id"],
|
||||
task["notes"],
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_task(task["id"]) or task
|
||||
|
||||
@@ -796,8 +911,11 @@ class SettingsStore:
|
||||
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)
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
@@ -837,17 +955,25 @@ class SettingsStore:
|
||||
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()
|
||||
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()
|
||||
|
||||
def _field(field: str, default: str = "") -> str:
|
||||
return str(
|
||||
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default
|
||||
).strip()
|
||||
|
||||
icon = _field("icon")
|
||||
url = _field("url")
|
||||
task_id = _field("task_id")
|
||||
machine_id = _field("machine_id")
|
||||
user_id = _field("user_id")
|
||||
notes = _field("notes")
|
||||
return {
|
||||
"id": shortcut_id,
|
||||
"label": label,
|
||||
@@ -882,7 +1008,10 @@ class SettingsStore:
|
||||
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()
|
||||
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(
|
||||
"""
|
||||
@@ -926,14 +1055,16 @@ class SettingsStore:
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
def _normalize_backup_job_payload(
|
||||
self, payload: dict[str, Any], job_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
def _normalize_backup_job_payload(self, payload: dict[str, Any], job_id: str | None = None) -> dict[str, Any]:
|
||||
current = self.get_backup_job(job_id) if job_id else None
|
||||
job_id = str(payload.get("id") or job_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
name = str(payload.get("name") or (current or {}).get("name") or job_id).strip() or job_id
|
||||
source = str(payload.get("source") if payload.get("source") is not None else (current or {}).get("source", "") or "").strip()
|
||||
target = str(payload.get("target") if payload.get("target") is not None else (current or {}).get("target", "") or "").strip()
|
||||
source = str(
|
||||
payload.get("source") if payload.get("source") is not None else (current or {}).get("source", "") or ""
|
||||
).strip()
|
||||
target = str(
|
||||
payload.get("target") if payload.get("target") is not None else (current or {}).get("target", "") or ""
|
||||
).strip()
|
||||
schedule_interval_seconds = payload.get("schedule_interval_seconds")
|
||||
if schedule_interval_seconds is None:
|
||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||
@@ -1040,10 +1171,24 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_runs (id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json, created_at)
|
||||
INSERT INTO backup_runs (
|
||||
id, job_id, started_at, ended_at, status, bytes_transferred,
|
||||
duration_ms, error_message, details_json, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(run_id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json, now),
|
||||
(
|
||||
run_id,
|
||||
job_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
bytes_transferred,
|
||||
duration_ms,
|
||||
error_message,
|
||||
details_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_backup_run(run_id) or {
|
||||
"id": run_id,
|
||||
@@ -1128,7 +1273,10 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_alerts (id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at, created_at)
|
||||
INSERT INTO backup_alerts (
|
||||
id, job_id, run_id, alert_type, severity, message,
|
||||
acknowledged, resolved_at, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)
|
||||
""",
|
||||
(alert_id, job_id, run_id, alert_type, severity, message, now),
|
||||
@@ -1238,9 +1386,7 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
def update_setting(self, key: str, value: str) -> None:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Prometheus file-based service discovery target management.
|
||||
|
||||
The backend owns the list of remote Node Exporter targets so that operators can
|
||||
enable scraping per machine from the Manage UI. Prometheus reads the generated
|
||||
JSON file via `file_sd_configs`; this keeps Prometheus config static and pushes
|
||||
machine-specific changes into a file it can reload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NODE_EXPORTER_PORT = 9100
|
||||
|
||||
|
||||
def _scrape_address(machine: dict[str, Any]) -> str | None:
|
||||
"""Return host:port for the Node Exporter on a machine, or None if disabled."""
|
||||
if not machine.get("node_exporter_enabled"):
|
||||
return None
|
||||
scrape_host = str(machine.get("node_exporter_scrape_host") or "").strip()
|
||||
host = scrape_host or str(machine.get("host") or "").strip()
|
||||
if not host or host == "localhost":
|
||||
return None
|
||||
port = int(machine.get("node_exporter_port") or DEFAULT_NODE_EXPORTER_PORT)
|
||||
return f"{host}:{port}"
|
||||
|
||||
|
||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||
"""Build a file-SD target list for all enabled SSH machines.
|
||||
|
||||
Local machines are excluded because the Compose-managed node-exporter
|
||||
service already covers the Docker host.
|
||||
"""
|
||||
targets: list[dict[str, Any]] = []
|
||||
for machine in store.list_machines():
|
||||
if not machine.get("enabled"):
|
||||
continue
|
||||
if str(machine.get("mode") or "local").strip().lower() != "ssh":
|
||||
continue
|
||||
address = _scrape_address(machine)
|
||||
if not address:
|
||||
continue
|
||||
targets.append(
|
||||
{
|
||||
"targets": [address],
|
||||
"labels": {
|
||||
"job": "node-exporter-remote",
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": str(machine.get("name") or ""),
|
||||
"instance": address,
|
||||
},
|
||||
}
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def write_prometheus_targets(store: SettingsStore, file_sd_dir: Path | None = None) -> Path:
|
||||
"""Render and persist Prometheus file-SD targets.
|
||||
|
||||
Returns the path written so callers can log or expose it.
|
||||
"""
|
||||
settings = get_settings()
|
||||
file_sd_dir = file_sd_dir or Path(settings.prometheus_file_sd_dir)
|
||||
file_sd_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = file_sd_dir / "node_exporter_targets.json"
|
||||
targets = build_node_exporter_targets(store)
|
||||
file_path.write_text(json.dumps(targets, indent=2), encoding="utf-8")
|
||||
logger.info("Wrote %s node_exporter targets to %s", len(targets), file_path)
|
||||
return file_path
|
||||
@@ -11,7 +11,6 @@ from datetime import datetime
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
VIDEO_FILE_EXTENSIONS = {
|
||||
".3g2",
|
||||
".3gp",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as package_version
|
||||
|
||||
PACKAGE_NAME = "media-library-viewer-backend"
|
||||
DEFAULT_VERSION = "0.1.0"
|
||||
|
||||
@@ -15,12 +15,12 @@ from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||
from media_library_viewer_api.services.media_index import (
|
||||
MediaIndex,
|
||||
MediaIndexBuildCancelled,
|
||||
build_media_index,
|
||||
)
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user