feat(observability): add Prometheus/Grafana/Loki/Alertmanager/Alloy stack and remove legacy Monitoring UI
This commit is contained in:
+56
-36
@@ -1,46 +1,66 @@
|
||||
# Optional backend logging level.
|
||||
LOG_LEVEL=INFO
|
||||
# App
|
||||
APP_VERSION=0.1.0
|
||||
APP_BUILD_INFO=dev
|
||||
|
||||
# Optional SMTP settings for the Users -> message popup.
|
||||
# Hosts
|
||||
BACKEND_APP_HOST=api.manage.example.com
|
||||
FRONTEND_APP_HOST=manage.example.com
|
||||
GRAFANA_APP_HOST=grafana.example.com
|
||||
BACKEND_APP_PORT=8000
|
||||
FRONTEND_APP_PORT=80
|
||||
GRAFANA_APP_PORT=3000
|
||||
BACKEND_APP_NAME=manage-backend
|
||||
FRONTEND_APP_NAME=manage-frontend
|
||||
GRAFANA_APP_NAME=grafana
|
||||
|
||||
# Traefik / certificates
|
||||
CERT_RESOLVER=letsencrypt
|
||||
|
||||
# Backend
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
PROMETHEUS_ENABLED=true
|
||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
ALERTMANAGER_WEBHOOK_URL=
|
||||
BACKEND_CACHE_DIR=./backend-cache
|
||||
|
||||
# Auth
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||
OIDC_AUDIENCE=manage
|
||||
OIDC_JWKS_URL=https://auth.example.com/application/o/manage/jwks/
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
# Frontend OIDC
|
||||
VITE_API_URL=/api
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
VITE_OIDC_CLIENT_ID=manage
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=your-smtp-username
|
||||
SMTP_PASSWORD=your-smtp-password
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=no-reply@example.com
|
||||
SMTP_FROM_NAME=Manage
|
||||
SMTP_USE_TLS=true
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_TIMEOUT=30
|
||||
|
||||
# Machine/service configuration now lives in the app's Settings tab.
|
||||
# The built-in local machine is seeded automatically.
|
||||
#
|
||||
# Remote SSH machines can store their private key and optional passphrase directly in Settings,
|
||||
# so no SSH key mount is required for normal use.
|
||||
# Grafana admin / OAuth
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change-me
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID=manage
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=
|
||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.example.com/application/o/manage/authorize/
|
||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.example.com/application/o/manage/token/
|
||||
GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.example.com/application/o/manage/userinfo/
|
||||
|
||||
# For deployment with traefik
|
||||
FRONTEND_APP_NAME=manage
|
||||
FRONTEND_APP_HOST=manage.example.com
|
||||
FRONTEND_APP_PORT=5173
|
||||
|
||||
BACKEND_APP_NAME=management-api
|
||||
BACKEND_APP_HOST=management-api.example.com
|
||||
BACKEND_APP_PORT=8000
|
||||
|
||||
CERT_RESOLVER=lets-encrypt
|
||||
|
||||
# Authentik / OIDC
|
||||
# Backend validates every API request with a Bearer JWT.
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
||||
OIDC_AUDIENCE=media-library-viewer
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
# Frontend OIDC settings (Vite build/runtime env)
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
||||
# Alerting
|
||||
ALERT_EMAIL_TO=admin@example.com
|
||||
|
||||
@@ -15,6 +15,7 @@ dist/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
.venv-review/
|
||||
venv/
|
||||
env/
|
||||
|
||||
@@ -52,3 +53,7 @@ frontend/dist/
|
||||
.fusion/
|
||||
.stoneforge/
|
||||
.superpowers/
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.pi-map.md
|
||||
.pi-map.index.md
|
||||
|
||||
@@ -13,6 +13,8 @@ dependencies = [
|
||||
"pandas>=2.0",
|
||||
"PyJWT[crypto]>=2.8",
|
||||
"python-multipart>=0.0.9",
|
||||
"prometheus-client>=0.21",
|
||||
"python-json-logger>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
+190
-125
@@ -11,23 +11,23 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_ssh_client,
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
get_settings_store,
|
||||
get_ssh_client,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.routers.media import get_media_index
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyfin():
|
||||
"""Mock Jellyfin client."""
|
||||
@@ -145,15 +145,29 @@ def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path):
|
||||
|
||||
# --- Health ---
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health(self, test_client):
|
||||
response = test_client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
def test_metrics_endpoint(self, test_client):
|
||||
response = test_client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "manage_api_requests_total" in response.text
|
||||
assert response.headers["content-type"].startswith("text/plain")
|
||||
|
||||
def test_request_id_header(self, test_client):
|
||||
response = test_client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert "x-request-id" in response.headers
|
||||
assert len(response.headers["x-request-id"]) > 0
|
||||
|
||||
|
||||
# --- Dashboard ---
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
def test_counts(self, test_client):
|
||||
response = test_client.get("/api/dashboard/counts")
|
||||
@@ -163,78 +177,6 @@ class TestDashboard:
|
||||
assert data["series"] == 20
|
||||
assert data["episodes"] == 500
|
||||
|
||||
def test_monitoring_overview(self, test_client):
|
||||
store = MagicMock()
|
||||
store.list_machines.return_value = [
|
||||
{
|
||||
"id": "local",
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"enabled": True,
|
||||
"host": "localhost",
|
||||
"port": 22,
|
||||
"username": "",
|
||||
"media_root": "/srv/media",
|
||||
"path_prefix": "",
|
||||
"notes": "",
|
||||
},
|
||||
{
|
||||
"id": "remote1",
|
||||
"name": "Remote",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"host": "server.example.com",
|
||||
"port": 22,
|
||||
"username": "alex",
|
||||
"media_root": "/srv/media",
|
||||
"path_prefix": "",
|
||||
"notes": "",
|
||||
},
|
||||
]
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
try:
|
||||
with (
|
||||
patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=object()),
|
||||
patch("media_library_viewer_api.services.monitoring_actions.resource_collector_status", return_value="running pid=123"),
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.read_resource_metrics",
|
||||
return_value=[
|
||||
{
|
||||
"ts": 123.0,
|
||||
"cpu_pct": 10.0,
|
||||
"iowait_pct": 1.0,
|
||||
"mem_pct": 20.0,
|
||||
"net_rx_bytes_per_sec": 100.0,
|
||||
"net_tx_bytes_per_sec": 50.0,
|
||||
"disk_read_bps": 1.0,
|
||||
"disk_write_bps": 2.0,
|
||||
}
|
||||
],
|
||||
),
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.disk_space",
|
||||
return_value={
|
||||
"filesystem": "/dev/sda1",
|
||||
"size": 1000,
|
||||
"used": 200,
|
||||
"available": 800,
|
||||
"used_pct": "20.0%",
|
||||
"mount": "/srv/media",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = test_client.get("/api/dashboard/monitoring")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_settings_store, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert data["enabled"] == 2
|
||||
assert len(data["machines"]) == 2
|
||||
assert data["machines"][0]["latest_sample"]["cpu_pct"] == 10.0
|
||||
assert data["machines"][0]["disk"]["used_pct"] == "20.0%"
|
||||
|
||||
def test_libraries(self, test_client):
|
||||
response = test_client.get("/api/dashboard/libraries")
|
||||
assert response.status_code == 200
|
||||
@@ -268,6 +210,7 @@ class TestDashboard:
|
||||
|
||||
# --- Settings reset ---
|
||||
|
||||
|
||||
class TestSettingsReset:
|
||||
def test_reset_local_database_requires_full_confirmation(self, test_client, tmp_path):
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
@@ -329,8 +272,10 @@ class TestSettingsReset:
|
||||
assert store.get_machine("local") is None
|
||||
assert len(store.list_machines()) == 0
|
||||
|
||||
|
||||
# --- Users ---
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_users_list_enriched(self, test_client):
|
||||
response = test_client.get("/api/users")
|
||||
@@ -418,7 +363,7 @@ class TestUsers:
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings):
|
||||
with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings):
|
||||
response = test_client.post(
|
||||
"/api/users/message",
|
||||
data={
|
||||
@@ -443,8 +388,10 @@ class TestUsers:
|
||||
assert kwargs["subject"] == "Hello team"
|
||||
assert kwargs["settings"] is settings
|
||||
|
||||
|
||||
# --- Files ---
|
||||
|
||||
|
||||
class TestFiles:
|
||||
def test_list_directory(self, test_client):
|
||||
response = test_client.get("/api/files/list", params={"path": "/media"})
|
||||
@@ -486,6 +433,7 @@ class TestFiles:
|
||||
|
||||
# --- Media index build ---
|
||||
|
||||
|
||||
class TestMediaIndexApi:
|
||||
def test_status_includes_build_progress(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
@@ -586,9 +534,11 @@ class TestMediaIndexApi:
|
||||
alive_calls["count"] += 1
|
||||
return alive_calls["count"] <= 2
|
||||
|
||||
with patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive), patch(
|
||||
"media_library_viewer_api.routers.media.os.killpg"
|
||||
) as killpg, patch("media_library_viewer_api.routers.media.time.sleep", return_value=None):
|
||||
with (
|
||||
patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive),
|
||||
patch("media_library_viewer_api.routers.media.os.killpg") as killpg,
|
||||
patch("media_library_viewer_api.routers.media.time.sleep", return_value=None),
|
||||
):
|
||||
try:
|
||||
response = test_client.post("/api/media/force-stop")
|
||||
assert response.status_code == 202
|
||||
@@ -615,6 +565,7 @@ class TestMediaIndexApi:
|
||||
|
||||
# --- Jobs ---
|
||||
|
||||
|
||||
class TestJobs:
|
||||
def test_list_templates(self, test_client):
|
||||
response = test_client.get("/api/jobs/templates")
|
||||
@@ -645,37 +596,21 @@ class TestJobs:
|
||||
|
||||
# --- Monitoring ---
|
||||
|
||||
|
||||
class TestMonitoring:
|
||||
def _ensure_machine(self):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
if not store.list_machines():
|
||||
store.upsert_machine({
|
||||
"name": "Test Machine",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring", "files", "jellyfin"],
|
||||
"host": "test-host",
|
||||
"username": "test-user",
|
||||
})
|
||||
|
||||
def test_status(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="running pid=1234\n", stderr=""
|
||||
)
|
||||
response = test_client.get("/api/monitoring/status")
|
||||
assert response.status_code == 200
|
||||
assert "running" in response.json()["status"]
|
||||
|
||||
def test_metrics_empty(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="", stderr=""
|
||||
)
|
||||
response = test_client.get("/api/monitoring/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["samples"] == []
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "Test Machine",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring", "files", "jellyfin"],
|
||||
"host": "test-host",
|
||||
"username": "test-user",
|
||||
}
|
||||
)
|
||||
|
||||
def test_disk(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
@@ -685,32 +620,162 @@ class TestMonitoring:
|
||||
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
|
||||
stderr="",
|
||||
)
|
||||
response = test_client.get("/api/monitoring/disk")
|
||||
with patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=mock_ssh):
|
||||
response = test_client.get("/api/monitoring/disk")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["used_pct"] == "50%"
|
||||
|
||||
def test_start(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="started pid=5678\n", stderr=""
|
||||
)
|
||||
response = test_client.post("/api/monitoring/start")
|
||||
def test_prometheus_targets_empty(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
assert "started" in response.json()["message"]
|
||||
assert response.json() == []
|
||||
|
||||
def test_stop(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="stopped pid=5678\n", stderr=""
|
||||
def test_prometheus_targets_returns_enabled_ssh_node_exporter(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
response = test_client.post("/api/monitoring/stop")
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["targets"] == ["1.2.3.4:9200"]
|
||||
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
def test_restart(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="stopped pid=5678\nstarted pid=9999\n", stderr=""
|
||||
|
||||
class TestSettingsMachines:
|
||||
def test_post_machine_rewrites_prometheus_targets(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
||||
with patch("media_library_viewer_api.routers.settings._validate_saved_machine_ssh"):
|
||||
response = test_client.post(
|
||||
"/api/settings/machines",
|
||||
json={
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
write_targets.assert_called_once()
|
||||
|
||||
def test_delete_machine_rewrites_prometheus_targets(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
machine = store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
}
|
||||
)
|
||||
response = test_client.post("/api/monitoring/restart")
|
||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
||||
assert response.status_code == 200
|
||||
write_targets.assert_called_once()
|
||||
|
||||
|
||||
class TestAlertmanager:
|
||||
def test_alerts_endpoint_when_alertmanager_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["error"] == "alertmanager_unreachable"
|
||||
|
||||
def test_alerts_endpoint_returns_summary(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
||||
"annotations": {"summary": "Backup failed", "description": "details"},
|
||||
"startsAt": "2026-05-11T02:00:00Z",
|
||||
"status": "firing",
|
||||
}
|
||||
],
|
||||
}
|
||||
session.get.return_value.raise_for_status = MagicMock()
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["by_severity"]["critical"] == 1
|
||||
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
||||
|
||||
def test_alertmanager_status_endpoint_when_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
|
||||
|
||||
class TestAlertmanagerWebhook:
|
||||
"""Tests for POST /api/monitoring/alertmanager-webhook."""
|
||||
|
||||
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
||||
payload = {
|
||||
"status": "firing",
|
||||
"alerts": [
|
||||
{
|
||||
"status": "firing",
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical"},
|
||||
"annotations": {"summary": "Backup failed"},
|
||||
"startsAt": "2026-06-13T10:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "received"
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
def test_webhook_forwards_when_configured(self, test_client, caplog, monkeypatch):
|
||||
payload = {"status": "resolved", "alerts": []}
|
||||
forwarded = {"captured": False}
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
forwarded["captured"] = True
|
||||
forwarded["url"] = url
|
||||
forwarded["payload"] = json
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("requests.Session.post", lambda _self, url, json, timeout: fake_post(url, json, timeout))
|
||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert forwarded["captured"] is False
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from media_library_viewer_api.config import Settings
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"""Unit tests for domain/media.py normalization helpers."""
|
||||
|
||||
import pytest
|
||||
from media_library_viewer_api.domain.media import (
|
||||
first_media_source,
|
||||
media_streams,
|
||||
stream_value,
|
||||
is_hdr_item,
|
||||
format_date_added,
|
||||
timestamp_date_added,
|
||||
format_rate_bits_decimal,
|
||||
normalize_media_item,
|
||||
display_media_row,
|
||||
first_media_source,
|
||||
format_date_added,
|
||||
format_rate_bits_decimal,
|
||||
is_hdr_item,
|
||||
media_streams,
|
||||
normalize_media_item,
|
||||
stream_value,
|
||||
timestamp_date_added,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Unit tests for jobs.py template rendering and safety."""
|
||||
|
||||
import pytest
|
||||
from media_library_viewer_api.jobs import JOB_TEMPLATES, JobTemplate, run_job
|
||||
from media_library_viewer_api.jobs import JOB_TEMPLATES, JobTemplate
|
||||
|
||||
|
||||
class TestJobTemplate:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Unit tests for the SQLite media index service."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.services.media_index import (
|
||||
MediaIndex,
|
||||
MediaIndexBuildCancelled,
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
||||
|
||||
|
||||
def test_poll_machine_snapshot_records_history_entries():
|
||||
def test_poll_machine_snapshot_records_disk_lookup():
|
||||
store = MagicMock()
|
||||
machine = {
|
||||
"id": "local",
|
||||
@@ -17,14 +17,6 @@ def test_poll_machine_snapshot_records_history_entries():
|
||||
"media_library_viewer_api.services.monitoring_actions.build_machine_client",
|
||||
return_value=object(),
|
||||
) as build_client,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.resource_collector_status",
|
||||
return_value="running pid=123",
|
||||
) as status_fn,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.read_resource_metrics",
|
||||
return_value=[{"ts": 1.0}, {"ts": 2.0}],
|
||||
) as metrics_fn,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.disk_space",
|
||||
return_value={"mount": "/srv/media", "used_pct": "12.5%"},
|
||||
@@ -33,14 +25,12 @@ def test_poll_machine_snapshot_records_history_entries():
|
||||
result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test")
|
||||
|
||||
assert result["request_id"] == "poll:test"
|
||||
assert result["metrics_samples"] == 2
|
||||
assert result["disk_mount"] == "/srv/media"
|
||||
build_client.assert_called_once_with(machine)
|
||||
status_fn.assert_called_once()
|
||||
metrics_fn.assert_called_once_with(build_client.return_value, max_lines=123)
|
||||
assert result["actions"] == ["disk lookup"]
|
||||
build_client.assert_called_once_with(machine, store)
|
||||
disk_fn.assert_called_once_with(build_client.return_value, "/srv/media")
|
||||
assert store.record_machine_action.call_count == 3
|
||||
recorded_actions = [call.args[1] for call in store.record_machine_action.call_args_list]
|
||||
assert recorded_actions == ["status lookup", "metrics read", "disk lookup for /srv/media"]
|
||||
assert all(call.kwargs["request_id"] == "poll:test" for call in store.record_machine_action.call_args_list)
|
||||
assert all(call.args[2] == "ok" for call in store.record_machine_action.call_args_list)
|
||||
assert store.record_machine_action.call_count == 1
|
||||
recorded_action = store.record_machine_action.call_args
|
||||
assert recorded_action.args[1] == "disk lookup for /srv/media"
|
||||
assert recorded_action.kwargs["request_id"] == "poll:test"
|
||||
assert recorded_action.args[2] == "ok"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for observability metrics helpers."""
|
||||
|
||||
import time
|
||||
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from media_library_viewer_api.observability import record_backup_run
|
||||
|
||||
|
||||
def _metric_samples(metric_name):
|
||||
return {
|
||||
tuple(s.labels.values()): s.value
|
||||
for family in REGISTRY.collect()
|
||||
for s in family.samples
|
||||
if s.name == metric_name
|
||||
}
|
||||
|
||||
|
||||
def test_record_backup_run_increments_counter():
|
||||
record_backup_run("job-a", "success", success=True)
|
||||
record_backup_run("job-a", "failure")
|
||||
record_backup_run("job-b", "success", success=True)
|
||||
|
||||
samples = _metric_samples("manage_backup_runs_total")
|
||||
assert samples[("job-a", "success")] >= 1.0
|
||||
assert samples[("job-a", "failure")] >= 1.0
|
||||
assert samples[("job-b", "success")] >= 1.0
|
||||
|
||||
|
||||
def test_record_backup_run_sets_last_success_timestamp():
|
||||
before = time.time()
|
||||
record_backup_run("job-ts", "success", success=True)
|
||||
after = time.time()
|
||||
|
||||
samples = _metric_samples("manage_backup_runs_last_success_timestamp")
|
||||
value = samples[("job-ts",)]
|
||||
assert before <= value <= after
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for path_utils.py — path resolution logic."""
|
||||
|
||||
import pytest
|
||||
from media_library_viewer_api.path_utils import (
|
||||
apply_remote_path_prefix,
|
||||
map_path_to_media_root,
|
||||
@@ -32,7 +31,9 @@ class TestApplyRemotePathPrefix:
|
||||
assert apply_remote_path_prefix("/media/file.mkv", "/srv/") == "/srv/media/file.mkv"
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
assert apply_remote_path_prefix("/media/My Movie (2024)/file.mkv", "/srv") == "/srv/media/My Movie (2024)/file.mkv"
|
||||
assert (
|
||||
apply_remote_path_prefix("/media/My Movie (2024)/file.mkv", "/srv") == "/srv/media/My Movie (2024)/file.mkv"
|
||||
)
|
||||
|
||||
|
||||
class TestMapPathToMediaRoot:
|
||||
|
||||
@@ -7,7 +7,9 @@ from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
|
||||
def test_connect_uses_existing_known_hosts_without_reprobing(tmp_path):
|
||||
known_hosts_path = tmp_path / "known_hosts"
|
||||
known_hosts_path.write_text("example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAAFAKE\n")
|
||||
known_hosts_path.write_text(
|
||||
"example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID8tdNkXNS1EWbkjb2XxFHQXRrpYzl0MXaVLRmVrbmDZ\n"
|
||||
)
|
||||
|
||||
ssh_client = MagicMock()
|
||||
ssh_client.connect.return_value = None
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for Prometheus file-based service discovery target generation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import (
|
||||
build_node_exporter_targets,
|
||||
write_prometheus_targets,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> SettingsStore:
|
||||
db = SettingsStore(tmp_path / "settings.sqlite")
|
||||
db.init_schema()
|
||||
return db
|
||||
|
||||
|
||||
class TestBuildNodeExporterTargets:
|
||||
def test_disabled_machine_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9200,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
|
||||
def test_ssh_enabled_machine_included(self, store: SettingsStore):
|
||||
machine = store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
targets = build_node_exporter_targets(store)
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
||||
assert targets[0]["labels"]["machine_id"] == machine["id"]
|
||||
assert targets[0]["labels"]["machine_name"] == "remote1"
|
||||
assert targets[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
def test_scrape_host_defaults_to_machine_host(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote2",
|
||||
"mode": "ssh",
|
||||
"host": "remote2.example.com",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9100,
|
||||
}
|
||||
)
|
||||
targets = build_node_exporter_targets(store)
|
||||
assert targets[0]["targets"] == ["remote2.example.com:9100"]
|
||||
|
||||
def test_local_machine_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"host": "localhost",
|
||||
"username": "",
|
||||
"node_exporter_enabled": True,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
|
||||
def test_missing_host_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote3",
|
||||
"mode": "ssh",
|
||||
"host": "",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
|
||||
|
||||
class TestWritePrometheusTargets:
|
||||
def test_writes_valid_json(self, store: SettingsStore, tmp_path: Path):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
}
|
||||
)
|
||||
file_path = write_prometheus_targets(store, tmp_path)
|
||||
assert file_path.exists()
|
||||
assert file_path.name == "node_exporter_targets.json"
|
||||
targets = build_node_exporter_targets(store)
|
||||
assert len(targets) == 1
|
||||
@@ -1,17 +1,16 @@
|
||||
"""Unit tests for utils.py formatting helpers."""
|
||||
|
||||
import pytest
|
||||
from media_library_viewer_api.utils import (
|
||||
ticks_to_minutes,
|
||||
ffprobe_format_summary,
|
||||
format_bitrate,
|
||||
format_duration,
|
||||
human_size,
|
||||
is_known_video_file,
|
||||
format_duration,
|
||||
format_bitrate,
|
||||
ffprobe_format_summary,
|
||||
summarize_video_streams,
|
||||
summarize_audio_streams,
|
||||
summarize_subtitle_streams,
|
||||
summarize_streams,
|
||||
summarize_subtitle_streams,
|
||||
summarize_video_streams,
|
||||
ticks_to_minutes,
|
||||
timestamp_to_local,
|
||||
)
|
||||
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# Code Context
|
||||
|
||||
## Files Retrieved
|
||||
1. `docker-compose.yml` (lines 1–262) – production Compose stack; defines observability services and Traefik routing.
|
||||
2. `docker-compose.dev.yml` (lines 1–234) – development Compose stack; same observability services but with host ports exposed and auth disabled.
|
||||
3. `.env.example` (lines 1–55) – template with all required environment variables for the stack, including Prometheus/Grafana/Alertmanager/Alloy/Loki and Node Exporter settings.
|
||||
4. `monitoring/prometheus/prometheus.yml` (all lines) – Prometheus scrape configuration including the `manage-backend`, `node-exporter`, and file-SD remote targets.
|
||||
5. `monitoring/prometheus/rules/backup_alerts.yml` (all lines) – Prometheus alerting rules for backup jobs and observability-stack health.
|
||||
6. `monitoring/alertmanager/alertmanager.yml` (all lines) – Alertmanager routing, email/webhook receivers, and inhibition rules.
|
||||
7. `monitoring/grafana/grafana.ini` (all lines) – Grafana server, embedding, and generic OAuth (Authentik) configuration.
|
||||
8. `monitoring/alloy/config.alloy` (all lines) – Alloy pipeline to discover Docker containers and push logs to Loki.
|
||||
9. `monitoring/loki/loki.yml` (all lines) – Single-node Loki configuration with filesystem storage and 30-day retention.
|
||||
10. `backend/src/media_library_viewer_api/observability.py` (all lines) – Prometheus metrics definitions and helper functions.
|
||||
11. `backend/src/media_library_viewer_api/main.py` (lines 1–128) – FastAPI entrypoint exposing `/metrics` and wiring request/observability middleware.
|
||||
12. `backend/src/media_library_viewer_api/services/targets.py` (all lines) – Backend writes file-SD target list for remote Node Exporters.
|
||||
13. `backend/src/media_library_viewer_api/config.py` (lines 1–88) – Settings including `prometheus_enabled`, `prometheus_file_sd_dir`, and `alertmanager_url`.
|
||||
14. `docs/monitoring-logging-design.md` (all lines) – Architecture/design document describing the observability stack.
|
||||
15. `docs/observability-runbooks.md` (all lines) – Operational runbooks for the observability services.
|
||||
|
||||
## Key Code
|
||||
|
||||
### Backend `/metrics` endpoint
|
||||
`backend/src/media_library_viewer_api/main.py`:
|
||||
```python
|
||||
@app.middleware("http")
|
||||
async def enforce_jwt_auth(request: Request, call_next):
|
||||
if request.url.path in {"/api/health", "/api/version", "/metrics"}:
|
||||
return await call_next(request)
|
||||
return await require_jwt_auth(request, call_next)
|
||||
|
||||
@app.get("/metrics")
|
||||
def metrics() -> Response:
|
||||
"""Expose Prometheus metrics."""
|
||||
data, content_type = metrics_payload()
|
||||
return FastAPIResponse(content=data, media_type=content_type)
|
||||
```
|
||||
|
||||
### Metrics emitted by the backend
|
||||
`backend/src/media_library_viewer_api/observability.py`:
|
||||
```python
|
||||
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"], ...)
|
||||
SSH_COMMANDS_TOTAL = Counter("manage_ssh_commands_total", "Total SSH/local commands executed", ["machine_id", "action", "status"])
|
||||
MEDIA_INDEX_BUILDS_TOTAL = Counter("manage_media_index_builds_total", "Total media index build attempts", ["status"])
|
||||
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"])
|
||||
```
|
||||
|
||||
### Prometheus scrape configuration
|
||||
`monitoring/prometheus/prometheus.yml`:
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: manage-backend
|
||||
static_configs:
|
||||
- targets:
|
||||
- backend:8000
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 15s
|
||||
|
||||
- job_name: node-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
- node-exporter:9100
|
||||
|
||||
- job_name: node-exporter-remote
|
||||
file_sd_configs:
|
||||
- files:
|
||||
- /etc/prometheus/file-sd/node_exporter_targets.json
|
||||
refresh_interval: 30s
|
||||
```
|
||||
|
||||
### Backend-managed remote Node Exporter targets
|
||||
`backend/src/media_library_viewer_api/services/targets.py`:
|
||||
```python
|
||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||
...
|
||||
|
||||
def write_prometheus_targets(store: SettingsStore, file_sd_dir: Path | None = None) -> Path:
|
||||
...
|
||||
file_path = file_sd_dir / "node_exporter_targets.json"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The observability stack is a standard self-hosted Prometheus/Grafana/Loki/Alertmanager deployment running inside the same Docker Compose project as the Manage application:
|
||||
|
||||
- **Prometheus** pulls metrics from:
|
||||
- The Manage FastAPI backend via `/metrics` (`job="manage-backend"`).
|
||||
- The local Docker host via `node-exporter` (`job="node-exporter"`).
|
||||
- Remote machines that have Node Exporter enabled through Manage settings (`job="node-exporter-remote"`), discovered through a file-SD JSON file generated by the backend.
|
||||
- The observability services themselves: Loki, Alertmanager, Grafana, and Prometheus self-scrape.
|
||||
- **Grafana** visualizes metrics and logs; it is configured for OAuth login through Authentik and is embedded in the Manage React UI via iframes.
|
||||
- **Loki** stores logs; retention is 30 days.
|
||||
- **Alloy** (Grafana Alloy) collects Docker container logs by mounting the Docker socket and the container log directory, then pushes them to Loki.
|
||||
- **Alertmanager** routes alerts by severity (critical vs warning) and delivers email notifications (and optionally a webhook back to the backend).
|
||||
|
||||
The backend bridges the stack with the application:
|
||||
- It exposes `/metrics` (unauthenticated, along with `/api/health` and `/api/version`).
|
||||
- On startup it writes `${PROMETHEUS_FILE_SD_DIR}/node_exporter_targets.json` based on enabled SSH machines in the settings store.
|
||||
- It provides proxy endpoints (`/api/monitoring/alerts`, `/api/monitoring/alertmanager-status`, `/api/monitoring/prometheus-targets`) consumed by the frontend.
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `monitoring/prometheus/prometheus.yml` first to understand what is scraped and how the backend is wired, then read `backend/src/media_library_viewer_api/observability.py` to see the metric names and labels. For environment requirements, read `.env.example`.
|
||||
|
||||
## Supervisor coordination
|
||||
Not needed — this is a read-only scouting summary.
|
||||
|
||||
---
|
||||
|
||||
# Monitoring/Observability Setup Summary
|
||||
|
||||
## 1. Observability services defined in Compose
|
||||
|
||||
Both `docker-compose.yml` and `docker-compose.dev.yml` define the following services:
|
||||
|
||||
| Service | Image | Internal endpoint | Purpose |
|
||||
|---------|-------|-------------------|---------|
|
||||
| `prometheus` | `prom/prometheus:v2.55.1` | `http://prometheus:9090` | Metrics TSDB and alert evaluator |
|
||||
| `loki` | `grafana/loki:3.1.1` | `http://loki:3100` | Log aggregation |
|
||||
| `alloy` | `grafana/alloy:v1.5.0` | `http://alloy:12345` | Docker log collection agent |
|
||||
| `grafana` | `grafana/grafana:11.3.1` | `http://grafana:3000` | Dashboards and visualization |
|
||||
| `alertmanager` | `prom/alertmanager:v0.27.0` | `http://alertmanager:9093` | Alert routing/delivery |
|
||||
| `node-exporter` | `prom/node-exporter:v1.8.2` | `http://node-exporter:9100` | Host metrics for the Docker host |
|
||||
| `backend` | Build from `backend/Dockerfile` | `http://backend:8000` | FastAPI app exposing `/metrics` |
|
||||
|
||||
Differences:
|
||||
- Production (`docker-compose.yml`): services attach to an external `web` network for Traefik, use `expose` instead of host ports for most services, and require OIDC/auth variables.
|
||||
- Development (`docker-compose.dev.yml`): Prometheus/Grafana/Loki/Alertmanager/Node Exporter are published on host ports `9090`, `3000`, `3100`, `9093`, `9100`; auth is disabled (`AUTH_ENABLED=false`).
|
||||
|
||||
## 2. Required environment variables
|
||||
|
||||
From `.env.example` and the Compose files, the variables relevant to the observability stack are:
|
||||
|
||||
### Backend / metrics
|
||||
- `PROMETHEUS_ENABLED` – enable metrics endpoint (set to `"true"` in both compose files).
|
||||
- `PROMETHEUS_FILE_SD_DIR` – directory where the backend writes `node_exporter_targets.json` (default `/app/backend/.cache/prometheus-file-sd`).
|
||||
- `ALERTMANAGER_URL` – backend proxy target (default `http://alertmanager:9093`).
|
||||
- `ALERTMANAGER_WEBHOOK_URL` – optional webhook receiver for Alertmanager.
|
||||
- `BACKEND_CACHE_DIR` – host directory mounted into backend and Prometheus for file-SD.
|
||||
|
||||
### Grafana
|
||||
- `GRAFANA_APP_HOST` – public hostname for Grafana (production; required).
|
||||
- `GRAFANA_APP_PORT` – defaults to `3000`.
|
||||
- `GRAFANA_APP_NAME` – defaults to `grafana`.
|
||||
- `GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD` – local admin credentials.
|
||||
- `GF_AUTH_GENERIC_OAUTH_CLIENT_ID`
|
||||
- `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET`
|
||||
- `GF_AUTH_GENERIC_OAUTH_AUTH_URL`
|
||||
- `GF_AUTH_GENERIC_OAUTH_TOKEN_URL`
|
||||
- `GF_AUTH_GENERIC_OAUTH_API_URL`
|
||||
|
||||
### Alertmanager
|
||||
- `SMTP_HOST` / `SMTP_PORT`
|
||||
- `SMTP_USERNAME` / `SMTP_PASSWORD`
|
||||
- `SMTP_FROM_ADDRESS`
|
||||
- `ALERT_EMAIL_TO`
|
||||
|
||||
### Traefik / network (production)
|
||||
- `BACKEND_APP_HOST` / `FRONTEND_APP_HOST` / `GRAFANA_APP_HOST`
|
||||
- `CERT_RESOLVER` – e.g. `letsencrypt`
|
||||
|
||||
### General
|
||||
- `LOG_LEVEL` / `LOG_FORMAT` – also passed to Grafana and backend.
|
||||
|
||||
## 3. Monitoring config files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `monitoring/prometheus/prometheus.yml` | Scrape jobs: backend `/metrics`, local node-exporter, remote node-exporter via file-SD, Loki, Alertmanager, Grafana, and self-scrape. |
|
||||
| `monitoring/prometheus/rules/backup_alerts.yml` | Alert rules: `BackupJobFailed`, `BackupJobStuck`, `PrometheusTargetMissing`, `AlertmanagerDown`, `GrafanaDown`. |
|
||||
| `monitoring/alertmanager/alertmanager.yml` | Routes alerts by severity, sends email to `ALERT_EMAIL_TO`, optional webhook to backend, and inhibits warnings when a critical alert fires. |
|
||||
| `monitoring/grafana/grafana.ini` | Enables iframe embedding, OAuth via Authentik, role mapping from groups, and defaults users to `Viewer`. |
|
||||
| `monitoring/alloy/config.alloy` | Discovers Docker containers, relabels container/stream labels, and writes logs to `http://loki:3100/loki/api/v1/push`. |
|
||||
| `monitoring/loki/loki.yml` | Single-node Loki with filesystem storage, tsdb index, 30-day retention (`720h`). |
|
||||
|
||||
## 4. Backend metrics and Prometheus scraping
|
||||
|
||||
- The backend exposes Prometheus metrics at `/metrics` on port `8000`.
|
||||
- The endpoint is unauthenticated (bypassed in `enforce_jwt_auth`).
|
||||
- Prometheus scrapes it as `job="manage-backend"` with `scrape_interval: 15s`.
|
||||
- Key application metrics include:
|
||||
- `manage_api_requests_total{method, path, status_code}`
|
||||
- `manage_api_request_duration_seconds{method, path}`
|
||||
- `manage_ssh_commands_total{machine_id, action, status}`
|
||||
- `manage_media_index_builds_total{status}`
|
||||
- `manage_backup_runs_total{job_name, status}`
|
||||
- `manage_backup_runs_last_success_timestamp{job_name}`
|
||||
- `manage_mail_queue_messages_total{status}`
|
||||
|
||||
Remote Node Exporter targets are not static: the backend reads machine settings from SQLite and writes `${PROMETHEUS_FILE_SD_DIR}/node_exporter_targets.json`. Prometheus reloads this file every 30 seconds via `file_sd_configs`.
|
||||
|
||||
## 5. Setup steps and gotchas
|
||||
|
||||
- The observability stack is brought up with the app itself:
|
||||
- Production: `docker compose -f docker-compose.yml up --build`
|
||||
- Development: `docker compose -f docker-compose.dev.yml up --build`
|
||||
- Production requires the external `web` network and Traefik already configured; `docker-compose.dev.yml` does not use Traefik and binds ports directly.
|
||||
- Export/copy `.env.example` to `.env` and fill required values (`OIDC_ISSUER_URL`, `OIDC_AUDIENCE`, `BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `GRAFANA_APP_HOST`, `CERT_RESOLVER`, SMTP credentials, Grafana OAuth secrets).
|
||||
- `BACKEND_CACHE_DIR` is shared between the backend and Prometheus so file-SD target updates are visible to Prometheus.
|
||||
- Alloy must run as `user: root` and mount `/var/run/docker.sock` and `/var/lib/docker/containers`; without these mounts it cannot collect Docker logs.
|
||||
- Node Exporter mounts the host root filesystem read-only (`/:/host:ro,rslave`) to report host-level metrics; on production, this exposes the Docker host.
|
||||
- Grafana iframe embedding requires `allow_embedding = true` in `grafana.ini` and matching cookie settings; also ensure the reverse proxy/CSP permits embedding.
|
||||
- After changing Prometheus rules/config, trigger a reload with `curl -X POST http://localhost:9090/-/reload` (production needs Traefik/network access).
|
||||
- Observability data is stored in named volumes: `prometheus_data`, `loki_data`, `grafana_data`, `alertmanager_data`. Back them up as documented in `docs/observability-runbooks.md`.
|
||||
- Default retention is 30 days for both Prometheus TSDB and Loki logs.
|
||||
@@ -11,12 +11,19 @@ services:
|
||||
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
|
||||
OIDC_JWKS_URL: ${OIDC_JWKS_URL:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||
PROMETHEUS_ENABLED: "true"
|
||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||
networks:
|
||||
- web
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
@@ -38,5 +45,209 @@ services:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.55.1
|
||||
container_name: prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.console.libraries=/usr/share/prometheus/console_libraries"
|
||||
- "--web.console.templates=/usr/share/prometheus/consoles"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/rules:/etc/prometheus/rules:ro
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}/prometheus-file-sd:/etc/prometheus/file-sd:ro
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 256M
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
container_name: loki
|
||||
command: -config.file=/etc/loki/loki.yml
|
||||
volumes:
|
||||
- ./monitoring/loki/loki.yml:/etc/loki/loki.yml:ro
|
||||
- loki_data:/loki
|
||||
ports:
|
||||
- "3100:3100"
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3100/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 256M
|
||||
|
||||
alloy:
|
||||
image: grafana/alloy:v1.5.0
|
||||
container_name: alloy
|
||||
command:
|
||||
- run
|
||||
- /etc/alloy/config.alloy
|
||||
- --storage.path=/var/lib/alloy
|
||||
volumes:
|
||||
- ./monitoring/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
networks:
|
||||
- monitoring
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:12345/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: "0.10"
|
||||
memory: 128M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.3.1
|
||||
container_name: grafana
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GF_AUTH_GENERIC_OAUTH_CLIENT_ID:-}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:-}
|
||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${GF_AUTH_GENERIC_OAUTH_AUTH_URL:-}
|
||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: ${GF_AUTH_GENERIC_OAUTH_TOKEN_URL:-}
|
||||
GF_AUTH_GENERIC_OAUTH_API_URL: ${GF_AUTH_GENERIC_OAUTH_API_URL:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
volumes:
|
||||
- ./monitoring/grafana/grafana.ini:/etc/grafana/grafana.ini:ro
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- monitoring
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
loki:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 128M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
container_name: alertmanager
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
environment:
|
||||
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS:-no-reply@example.com}
|
||||
ALERT_EMAIL_TO: ${ALERT_EMAIL_TO:-admin@example.com}
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
ports:
|
||||
- "9093:9093"
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9093/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: "0.10"
|
||||
memory: 64M
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
container_name: node-exporter
|
||||
command:
|
||||
- "--path.rootfs=/host"
|
||||
volumes:
|
||||
- /:/host:ro,rslave
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9100/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.25"
|
||||
memory: 128M
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 32M
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
prometheus_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
alertmanager_data:
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
OIDC_JWKS_URL: ${OIDC_JWKS_URL:-}
|
||||
OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-30}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||
PROMETHEUS_ENABLED: "true"
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
@@ -23,11 +25,14 @@ services:
|
||||
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
|
||||
SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30}
|
||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
volumes:
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- web
|
||||
- monitoring
|
||||
expose:
|
||||
- "8000"
|
||||
labels:
|
||||
@@ -80,6 +85,218 @@ services:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.55.1
|
||||
container_name: prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.console.libraries=/usr/share/prometheus/console_libraries"
|
||||
- "--web.console.templates=/usr/share/prometheus/consoles"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/rules:/etc/prometheus/rules:ro
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}/prometheus-file-sd:/etc/prometheus/file-sd:ro
|
||||
- prometheus_data:/prometheus
|
||||
expose:
|
||||
- "9090"
|
||||
networks:
|
||||
- web
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 256M
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
container_name: loki
|
||||
command: -config.file=/etc/loki/loki.yml
|
||||
volumes:
|
||||
- ./monitoring/loki/loki.yml:/etc/loki/loki.yml:ro
|
||||
- loki_data:/loki
|
||||
expose:
|
||||
- "3100"
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3100/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 256M
|
||||
|
||||
alloy:
|
||||
image: grafana/alloy:v1.5.0
|
||||
container_name: alloy
|
||||
command:
|
||||
- run
|
||||
- /etc/alloy/config.alloy
|
||||
- --storage.path=/var/lib/alloy
|
||||
volumes:
|
||||
- ./monitoring/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
networks:
|
||||
- monitoring
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:12345/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: "0.10"
|
||||
memory: 128M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.3.1
|
||||
container_name: grafana
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GF_AUTH_GENERIC_OAUTH_CLIENT_ID:-}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:-}
|
||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${GF_AUTH_GENERIC_OAUTH_AUTH_URL:-}
|
||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: ${GF_AUTH_GENERIC_OAUTH_TOKEN_URL:-}
|
||||
GF_AUTH_GENERIC_OAUTH_API_URL: ${GF_AUTH_GENERIC_OAUTH_API_URL:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
volumes:
|
||||
- ./monitoring/grafana/grafana.ini:/etc/grafana/grafana.ini:ro
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
- web
|
||||
- monitoring
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
loki:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${GRAFANA_APP_NAME:-grafana}.rule=Host(`${GRAFANA_APP_HOST:?set GRAFANA_APP_HOST}`)"
|
||||
- "traefik.http.routers.${GRAFANA_APP_NAME:-grafana}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${GRAFANA_APP_NAME:-grafana}.tls.certresolver=${CERT_RESOLVER:?set CERT_RESOLVER}"
|
||||
- "traefik.http.services.${GRAFANA_APP_NAME:-grafana}.loadbalancer.server.port=${GRAFANA_APP_PORT:-3000}"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.00"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 128M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
container_name: alertmanager
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
environment:
|
||||
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS:-no-reply@example.com}
|
||||
ALERT_EMAIL_TO: ${ALERT_EMAIL_TO:-admin@example.com}
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
expose:
|
||||
- "9093"
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9093/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: "0.10"
|
||||
memory: 64M
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
container_name: node-exporter
|
||||
command:
|
||||
- "--path.rootfs=/host"
|
||||
volumes:
|
||||
- /:/host:ro,rslave
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9100/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.25"
|
||||
memory: 128M
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 32M
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
monitoring:
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
alertmanager_data:
|
||||
|
||||
@@ -197,6 +197,12 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
||||
|
||||
## Decision Log
|
||||
|
||||
- 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`.
|
||||
- 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding.
|
||||
- 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint.
|
||||
- 2026-06-13 (Phase 3): Added `ALERTMANAGER_URL` setting and `GET /api/monitoring/alerts` plus `GET /api/monitoring/alertmanager-status` endpoints that proxy/summarize Alertmanager for the UI. Added `manage_backup_runs_last_success_timestamp` Prometheus gauge and wired `record_backup_run` to update it on success so the existing `BackupJobStuck` Alertmanager rule works. Updated `docker-compose.yml`, `docker-compose.dev.yml`, and `.env.example` with `ALERTMANAGER_URL`. Added tests for the new endpoints and metric helpers.
|
||||
- 2026-06-13 (Phase 4): Added `/observability` React page (`frontend/src/components/ObservabilityPage.tsx`) with health cards for Alertmanager, active alerts, Prometheus targets, and machines; a recent-alerts list; machine selector; and Grafana iframe panels for Node Exporter metrics and Loki logs. Added `useObservability` hook and API client wrappers for `/api/monitoring/alerts`, `/api/monitoring/alertmanager-status`, and `/api/monitoring/prometheus-targets`. Added TypeScript types for Alertmanager summary/status and Prometheus targets. Wired the new route into `App.tsx` and the sidebar. Added shadcn/ui `card`, `badge`, `alert`, `skeleton`, and `select` components. Frontend build (`npm run build`) passes; lint has only pre-existing warnings.
|
||||
- 2026-06-13 (Phase 5): Hardened observability containers with health checks and resource limits in both compose files; added service-healthy `depends_on` conditions. Added Prometheus scrape jobs for Loki, Alertmanager, and Grafana, plus new `observability_health` alert rules. Added `ALERTMANAGER_WEBHOOK_URL` backend setting, `POST /api/monitoring/alertmanager-webhook` receiver, and Alertmanager `webhook` receiver config. Added `docs/observability-runbooks.md` with operational playbooks. Updated `.env.example` to include all observability variables.
|
||||
- 2026-05-03: Reaffirmed that the Monitoring tab charts should be rendered directly with D3 and expose brush-based time-range selection plus moving averages.
|
||||
- 2026-05-03: Added hover tooltips, summary chips, a moving vertical cursor, snapped point markers, and a selected-range label to the D3 Monitoring charts for faster visual inspection.
|
||||
- 2026-05-03: Combined network download/upload into one traffic chart and disk read/write into one I/O chart for clearer Monitoring layout.
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# Monitoring and Logging Design — Manage
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Manage currently uses ad-hoc observability: plain-text Python logs, a custom POSIX shell metrics collector on remote machines, and a background poller that stores snapshots in SQLite. This works for a single-instance homelab but becomes painful as the fleet grows and as users need faster incident response.
|
||||
|
||||
This document proposes a dedicated, self-hosted observability subsystem built on the standard Prometheus/Grafana stack:
|
||||
|
||||
- **Metrics**: Prometheus pulling from Node Exporter on machines and from application exporters in containers.
|
||||
- **Logs**: Structured JSON logs shipped to **Grafana Loki** by **Promtail/Grafana Alloy**.
|
||||
- **Dashboards**: Grafana for deep-dive dashboards, embedded in the Manage React UI via iframes.
|
||||
- **Alerting**: Prometheus Alertmanager for routing and notifications (email first, webhooks later).
|
||||
- **Auth**: Grafana authenticates through the existing OIDC/Authentik provider.
|
||||
|
||||
The existing POSIX remote collector will be removed, and the Python backup alert engine will be migrated to Alertmanager rules.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Fast query and alerting**: move from SQLite scan-based history to a real time-series database and indexed log store.
|
||||
2. **Unified view**: monitor both local containers/apps and remote Linux machines from one place.
|
||||
3. **Standard tooling**: use de-facto open-source tools so dashboards, exporters, and runbooks are reusable.
|
||||
4. **Room to grow**: design supports adding traces, more notification channels, and longer retention later without re-architecture.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. **Traces**: deferred to a later phase; the data flow and collector choice (Promtail/Alloy) will be trace-ready.
|
||||
2. **Multi-tenant RBAC**: Manage is single-instance/homelab; Grafana teams are sufficient for now.
|
||||
3. **SLA/SLO framework**: out of scope; we focus on metrics, logs, and alerts, not SLO budgeting.
|
||||
4. **Cloud-hosted observability vendors**: all components run self-hosted in Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Area | Decision | Rationale |
|
||||
|------|----------|-----------|
|
||||
| Coupling | Dedicated observability subsystem consumed by Manage | Keeps Manage fast and lets the observability stack evolve independently. |
|
||||
| Metrics backend | Prometheus | Pull model, huge ecosystem, standard exporters, easy Grafana integration. |
|
||||
| Machine metrics | Node Exporter | Rich OS metrics, reusable dashboards, no custom shell to maintain. |
|
||||
| Log backend | Grafana Loki | Prometheus-style labels, low resource use, tight Grafana integration. |
|
||||
| Log collection | Promtail / Grafana Alloy | Tails Docker logs and journald; no per-app network calls. |
|
||||
| App logs | Structured JSON to stdout | Standard 12-factor pattern; collector handles routing. |
|
||||
| Dashboards | Grafana + iframe embeds | Fast to implement, rich dashboards, Manage UI stays focused on summary. |
|
||||
| Alerting | Prometheus Alertmanager | Mature routing, silencing, inhibition; single source of truth for infra alerts. |
|
||||
| Auth | Grafana OAuth via Authentik | Reuses existing identity provider; consistent UX. |
|
||||
| Retention | 30 days metrics, 30 days logs | Matches current retention policy; disk usage stays predictable. |
|
||||
| Migration | Remove POSIX collector, migrate backup alerts | Eliminates duplicate alerting paths and custom remote code. |
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Logging
|
||||
|
||||
- `backend/src/media_library_viewer_api/logging_utils.py` configures stdlib `logging` with a plain-text format.
|
||||
- `main.py` has a `log_requests` middleware that emits method, path, client IP, status code, and elapsed time.
|
||||
- Frontend uses standard `console.log` / browser dev tools; no server-side log aggregation.
|
||||
|
||||
### Metrics
|
||||
|
||||
- `backend/src/media_library_viewer_api/clients/resources.py` deploys a POSIX shell collector to `/tmp` on each remote machine.
|
||||
- The collector samples `/proc/stat`, `/proc/meminfo`, `/proc/net/dev`, and `/sys/block/*/stat` every 10s and writes JSONL to `/tmp/media_library_viewer_metrics.jsonl`.
|
||||
- `MonitoringPoller` (`monitoring_poller.py`) runs every 5 minutes, reads the remote JSONL, and stores snapshots in SQLite (`monitoring_machine_actions`).
|
||||
- Retention defaults to 30 days with periodic pruning.
|
||||
|
||||
### Alerting
|
||||
|
||||
- `backup_alert_engine.py` / `backup_poller.py` generate backup-related alerts (failure, anomaly, missed schedule) and store them in SQLite.
|
||||
- No general infrastructure alerting (disk full, machine down, high CPU, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Docker Compose Network │
|
||||
│ │
|
||||
│ ┌─────────────┐ scrape ┌──────────────┐ │
|
||||
│ │ Prometheus │◄────────────────│ Node Exporter│◄── host / remote hosts │
|
||||
│ │ (TSDB) │ └──────────────┘ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ query │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ alert ┌─────────────┐ email ┌──────────┐ │
|
||||
│ │ Grafana │──────────────►│ Alertmanager│──────────────►│ SMTP │ │
|
||||
│ │ (OAuth) │ └─────────────┘ └──────────┘ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ embed (iframe) │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Manage React │ │
|
||||
│ │ (summary) │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ Logs: │
|
||||
│ Manage / containers ──stdout──► Promtail/Alloy ──push──► Loki ◄──────┐ │
|
||||
│ host / remote journald ───────► Promtail/Alloy ──push──► Loki │ │
|
||||
│ │ │
|
||||
│ Grafana queries Loki for logs ◄──────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| **Prometheus** | Scrape and store metrics; evaluate alert rules. |
|
||||
| **Node Exporter** | Expose host-level metrics (CPU, memory, disk, network, filesystem). |
|
||||
| **Loki** | Store and index log streams by labels. |
|
||||
| **Promtail / Alloy** | Discover log sources, parse labels, and push to Loki. |
|
||||
| **Grafana** | Visualize metrics and logs; serve as the alert UI. |
|
||||
| **Alertmanager** | Deduplicate, group, route, and deliver alerts. |
|
||||
| **Manage backend** | Emit structured logs; expose `/metrics` for Prometheus; forward health/status to summary endpoints. |
|
||||
| **Manage frontend** | Embed Grafana panels; show high-level status cards. |
|
||||
|
||||
---
|
||||
|
||||
## Instrumentation Changes
|
||||
|
||||
### Backend Logging
|
||||
|
||||
1. Switch to structured JSON logging via `python-json-logger` or `structlog`.
|
||||
2. Include fields:
|
||||
- `timestamp`, `level`, `logger`, `message`
|
||||
- `request_id` (correlation ID propagated from frontend or generated)
|
||||
- `method`, `path`, `status_code`, `elapsed_ms`
|
||||
- `user_id`, `machine_id` where relevant
|
||||
- `error` / `error_type` / `traceback` for exceptions
|
||||
3. Keep emitting to stdout; Promtail/Alloy will parse JSON.
|
||||
|
||||
**Status**: implemented in `backend/src/media_library_viewer_api/logging_utils.py` with `LOG_FORMAT=json|text`, secret-safe `sanitize_log_extra`, and request logging in `main.py`.
|
||||
|
||||
### Request Middleware
|
||||
|
||||
- Extend `log_requests` to attach `request_id` to `request.state`.
|
||||
- Include `request_id` in response headers (`X-Request-Id`) so the frontend can correlate.
|
||||
- Log all outbound SSH commands with `machine_id`, `action`, `duration_ms`, and `request_id`.
|
||||
|
||||
**Status**: implemented in `main.py` and `observability.py`; `record_ssh_command` is called from `monitoring_actions.py` for every machine operation.
|
||||
|
||||
### Application Metrics Endpoint
|
||||
|
||||
- Add a `/metrics` endpoint using `prometheus-client`.
|
||||
- Initial counters/gauges:
|
||||
- `manage_api_requests_total` (method, path, status)
|
||||
- `manage_api_request_duration_seconds` histogram
|
||||
- `manage_ssh_commands_total` (machine_id, action, status)
|
||||
- `manage_ssh_command_duration_seconds` histogram
|
||||
- `manage_media_index_build_duration_seconds`
|
||||
- `manage_backup_runs_total` (job_name, status)
|
||||
- `manage_mail_queue_size`, `manage_mail_queue_failures_total`
|
||||
|
||||
**Status**: implemented in `backend/src/media_library_viewer_api/observability.py` and wired into `main.py`, `monitoring_actions.py`, `backups.py`, `media.py`, and `mail_queue.py`.
|
||||
|
||||
### Frontend Observability
|
||||
|
||||
- Keep first phase minimal: capture JS errors and send them to the backend as structured log events.
|
||||
- Optional later: expose RUM-style metrics (page loads, API call latencies) via Prometheus client library or manual instrumentation.
|
||||
|
||||
---
|
||||
|
||||
## Node Exporter Deployment
|
||||
|
||||
### Local / Docker Host
|
||||
|
||||
- Add a `node-exporter` service to `docker-compose.yml` with host PID/network mounts.
|
||||
- Prometheus scrapes it as `job="node"`.
|
||||
|
||||
### Remote Machines
|
||||
|
||||
- Add a managed task/template in `jobs.py` to install/upgrade Node Exporter via the package manager or a static binary.
|
||||
- Manage exposes a settings flag per machine: `node_exporter_enabled`.
|
||||
- For machines behind NAT, use one of:
|
||||
1. Reverse SSH tunnel from machine to Manage host.
|
||||
2. VPN/Wireguard already in place.
|
||||
3. Prometheus federation or pushgateway for unreachable targets (later phase).
|
||||
- If Node Exporter cannot be installed, temporarily keep the POSIX collector as a fallback until migration is complete.
|
||||
|
||||
---
|
||||
|
||||
## Log Shipping
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
- Add `logging` driver config or Promtail sidecar to each service.
|
||||
- Preferred: run **Grafana Alloy** as a single daemon container with `docker_sd_config` to discover all Compose services automatically.
|
||||
|
||||
### Host Logs
|
||||
|
||||
- Alloy mounts `/var/log` and `/var/lib/docker/containers` (read-only).
|
||||
- Alloy also tails journald where available.
|
||||
|
||||
### Remote Machines
|
||||
|
||||
- Option A: install Alloy on remote hosts and have it push logs to Loki.
|
||||
- Option B: keep logs on remote hosts and use Node Exporter logs only; defer centralized remote logs.
|
||||
- Recommendation: **Option A** for important machines, **Option B** for constrained ones.
|
||||
|
||||
---
|
||||
|
||||
## Dashboards
|
||||
|
||||
### Grafana
|
||||
|
||||
- Provision dashboards from YAML/JSON in version control:
|
||||
- Node Exporter Full dashboard (import from Grafana.com).
|
||||
- Manage API overview (request rate, latency, errors).
|
||||
- Manage operations (SSH commands, media index builds, mail queue).
|
||||
- Backup runs and alert history.
|
||||
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and ` kiosk` mode.
|
||||
|
||||
### Manage React UI
|
||||
|
||||
- Add an "Observability" page with:
|
||||
- System health cards (Prometheus up, Loki up, Alertmanager up).
|
||||
- Recent alerts summary from Alertmanager API.
|
||||
- Iframe panels for key metrics (CPU/memory of selected machine, recent logs).
|
||||
- Drill-down links open the full Grafana dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Alerting
|
||||
|
||||
### Alertmanager Configuration
|
||||
|
||||
- Reuse existing SMTP settings for email notifications.
|
||||
- Initial routing:
|
||||
- `severity=critical` → email immediately.
|
||||
- `severity=warning` → email with 5-minute group wait.
|
||||
- `job=backup` → grouped by job name.
|
||||
|
||||
### Initial Alert Rules
|
||||
|
||||
- Infrastructure:
|
||||
- Node down for > 5 minutes.
|
||||
- Disk usage > 85% (warning), > 95% (critical).
|
||||
- Memory usage > 90% for > 10 minutes.
|
||||
- CPU iowait > 30% for > 10 minutes.
|
||||
- Application:
|
||||
- Manage API 5xx rate > 1% over 5 minutes.
|
||||
- SSH command failure rate > 10% over 5 minutes.
|
||||
- Mail queue growing or failures increasing.
|
||||
- Backup:
|
||||
- Backup job failed (`manage_backup_runs_total{status="failure"}`).
|
||||
- Backup job missing for > 1.5× schedule interval.
|
||||
- Backup run duration or size anomaly compared to rolling median.
|
||||
|
||||
### Backup Alert Migration
|
||||
|
||||
- Re-implement rules as Prometheus recording/alerting rules where possible.
|
||||
- Keep historical comparison logic (median duration/size) as a small scheduled task that writes anomaly metrics to a Pushgateway or custom exporter, then Alertmanager consumes them.
|
||||
- Preserve acknowledge/resolve workflow by storing Alertmanager webhook events in SQLite if needed, or by using Grafana alert annotations.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
- Grafana configured with generic OAuth pointing at Authentik (same issuer as Manage).
|
||||
- Grafana role mapping: default `Viewer`; admin group mapped to `Admin`.
|
||||
- Traefik routes `grafana.${BACKEND_APP_HOST}` or a sub-path.
|
||||
- Iframe embedding requires Grafana `allow_embedding = true` and matching cookie domain/samesite settings.
|
||||
|
||||
---
|
||||
|
||||
## Retention and Storage
|
||||
|
||||
| Store | Retention | Notes |
|
||||
|-------|-----------|-------|
|
||||
| Prometheus | 30 days | Default TSDB block compaction. |
|
||||
| Loki | 30 days | Single-store boltdb-shipper or filesystem target. |
|
||||
| Grafana | persistent SQLite/Postgres later | Dashboards and users are config, not runtime data. |
|
||||
|
||||
- Volumes: `prometheus-data`, `loki-data`, `grafana-data`.
|
||||
- Backups: snapshot these volumes alongside existing `backend_cache`.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
1. Network: all observability services on an internal Docker network; exposed only through Traefik where needed.
|
||||
2. Node Exporter: bind to localhost on remote hosts and use a reverse tunnel, or firewall to Manage IP only.
|
||||
3. Secrets: SMTP password, OIDC client secret, and any remote scrape credentials in environment variables or Docker secrets; never commit them.
|
||||
4. Logs: sanitize tokens, passwords, and private keys before JSON serialization.
|
||||
5. Alertmanager: disable unauthenticated UI if exposed publicly; rely on OIDC/Traefik.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0 — Foundation and Cleanup
|
||||
|
||||
- [x] Add `prometheus-client` and `python-json-logger` to `backend/pyproject.toml`.
|
||||
- [x] Refactor `logging_utils.py` to emit JSON when `LOG_FORMAT=json`.
|
||||
- [x] Add `request_id` propagation in `log_requests` middleware.
|
||||
- [x] Add `/metrics` endpoint with initial counters/gauges.
|
||||
- [x] Remove the POSIX remote collector code in `resources.py`; keep `disk_space` as a lightweight SSH/local helper in `monitoring_actions.py`.
|
||||
- [x] Add `X-Request-Id` response header.
|
||||
|
||||
### Phase 1 — Local Observability Stack
|
||||
|
||||
- [x] Add services to `docker-compose.yml`: Prometheus, Loki, Grafana, Alertmanager, Grafana Alloy.
|
||||
- [x] Add `node-exporter` service for the Docker host.
|
||||
- [x] Configure Alloy to scrape all Docker container logs and ship to Loki.
|
||||
- [x] Configure Prometheus to scrape `node-exporter` and Manage `/metrics`.
|
||||
- [x] Provision Grafana datasources and a basic Manage API dashboard.
|
||||
- [x] Wire Grafana OAuth to Authentik.
|
||||
|
||||
**Phase 1 files**:
|
||||
- `monitoring/prometheus/prometheus.yml`
|
||||
- `monitoring/prometheus/rules/backup_alerts.yml`
|
||||
- `monitoring/loki/loki.yml`
|
||||
- `monitoring/alloy/config.alloy`
|
||||
- `monitoring/alertmanager/alertmanager.yml`
|
||||
- `monitoring/grafana/grafana.ini`
|
||||
- `monitoring/grafana/provisioning/datasources/datasources.yml`
|
||||
- `monitoring/grafana/provisioning/dashboards-json/dashboards.yml`
|
||||
- `monitoring/grafana/provisioning/dashboards-json/dashboards/manage-overview.json`
|
||||
- `docker-compose.yml` and `docker-compose.dev.yml` updated with observability services.
|
||||
- `frontend/vite.config.ts` updated with `/grafana` dev proxy.
|
||||
- `.env.example` updated with Grafana OAuth and alerting variables.
|
||||
|
||||
### Phase 2 — Remote Machine Metrics
|
||||
|
||||
- [x] Add Node Exporter install/restart/status job templates in `jobs.py` (`install_node_exporter`, `restart_node_exporter`, `node_exporter_status`).
|
||||
- [x] Add `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host` fields to `MonitoringMachineInput` and `SettingsStore`.
|
||||
- [x] Implement `media_library_viewer_api.services.targets` to build Prometheus file-SD target lists for enabled SSH machines and write them to `PROMETHEUS_FILE_SD_DIR/node_exporter_targets.json`.
|
||||
- [x] Regenerate file-SD targets on machine create/update/delete in `routers/settings.py`.
|
||||
- [x] Add `/api/monitoring/prometheus-targets` endpoint returning live targets from the store.
|
||||
- [x] Configure Prometheus `node-exporter-remote` job with `file_sd_configs` reading `/etc/prometheus/file-sd/node_exporter_targets.json`.
|
||||
- [x] Mount the backend cache `prometheus-file-sd` directory into the Prometheus container as a read-only file-SD source.
|
||||
- [x] Add `PROMETHEUS_FILE_SD_DIR` setting and `.env.example` entry.
|
||||
- [x] Provision a minimal `Node Exporter Overview` Grafana dashboard (`monitoring/grafana/provisioning/dashboards-json/dashboards/node-exporter-overview.json`) covering CPU, memory, root disk, and network traffic.
|
||||
- [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter.
|
||||
|
||||
**Phase 2 files**:
|
||||
- `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates).
|
||||
- `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration).
|
||||
- `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields).
|
||||
- `backend/src/media_library_viewer_api/services/targets.py` (file-SD target builder/writer).
|
||||
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/prometheus-targets` endpoint).
|
||||
- `backend/src/media_library_viewer_api/config.py` (`prometheus_file_sd_dir` setting).
|
||||
- `backend/tests/test_targets.py` and `backend/tests/test_api.py` (target + endpoint tests).
|
||||
- `monitoring/prometheus/prometheus.yml` (`node-exporter-remote` file SD job).
|
||||
- `monitoring/grafana/provisioning/dashboards-json/dashboards/node-exporter-overview.json`.
|
||||
- `docker-compose.yml` and `docker-compose.dev.yml` (file-SD volume mount + backend env var).
|
||||
- `.env.example` (`PROMETHEUS_FILE_SD_DIR`).
|
||||
|
||||
### Phase 3 — Alerting
|
||||
|
||||
- [x] Define initial Prometheus alert rules for backup failures (infrastructure rules deferred to Phase 2/3).
|
||||
- [x] Configure Alertmanager with email routing using existing SMTP settings; `monitoring/alertmanager/alertmanager.yml` uses env vars for SMTP and routing.
|
||||
- [x] Migrate backup alert rules to Alertmanager:
|
||||
- `BackupJobFailed` triggers on `increase(manage_backup_runs_total{status="failed"}[1h]) > 0`.
|
||||
- `BackupJobStuck` triggers on `time() - manage_backup_runs_last_success_timestamp > 86400`.
|
||||
- Added `manage_backup_runs_last_success_timestamp` gauge in `observability.py` and updated `routers/backups.py` to set it on successful runs.
|
||||
- SQLite backup alerts (`backup_alert_engine.py` and `backup_poller.py`) are preserved for now alongside Alertmanager rules; the UI can consume either source during transition.
|
||||
- [x] Add Alertmanager status summary endpoints in Manage backend:
|
||||
- `GET /api/monitoring/alerts` proxies `/api/v1/alerts` and returns a UI-friendly summary (total, by_severity, alerts list).
|
||||
- `GET /api/monitoring/alertmanager-status` proxies `/api/v2/status` and returns `up`, `version`, `uptime`, `peers`.
|
||||
- [x] Added `alertmanager_url` setting to `config.py` (default `http://alertmanager:9093`) and `ALERTMANAGER_URL` env var in both compose files and `.env.example`.
|
||||
- [x] Added tests for the Alertmanager endpoints and the backup success gauge.
|
||||
|
||||
**Phase 3 files**:
|
||||
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints).
|
||||
- `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`).
|
||||
- `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports).
|
||||
- `backend/src/media_library_viewer_api/config.py` (`alertmanager_url` setting).
|
||||
- `monitoring/alertmanager/alertmanager.yml` (SMTP + routing config).
|
||||
- `monitoring/prometheus/rules/backup_alerts.yml` (backup alert rules).
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` (`ALERTMANAGER_URL` env var).
|
||||
- `.env.example` (`ALERTMANAGER_URL`).
|
||||
- `backend/tests/test_api.py` (`TestAlertmanager` tests).
|
||||
- `backend/tests/test_observability.py` (backup metric tests).
|
||||
|
||||
### Phase 4 — Manage UI Integration
|
||||
|
||||
- [x] Add "Observability" page in React with summary cards (Alertmanager health, active alerts, Prometheus targets, machines) and Grafana iframe panels.
|
||||
- [x] Add recent alerts list from Alertmanager API via `GET /api/monitoring/alerts`.
|
||||
- [x] Add drill-down links to full Grafana dashboards for Node Exporter metrics and Loki logs.
|
||||
- [x] Handle iframe sandbox attributes (`allow-scripts allow-same-origin allow-popups allow-forms`); CSP is delegated to the reverse proxy / Grafana `allow_embedding` configuration.
|
||||
- [x] Add `useObservability` hook and API client wrappers for alerts, Alertmanager status, and Prometheus targets.
|
||||
- [x] Add TypeScript types for Alertmanager summary/status and Prometheus targets.
|
||||
- [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation.
|
||||
|
||||
**Phase 4 files**:
|
||||
- `frontend/src/components/ObservabilityPage.tsx` (page component).
|
||||
- `frontend/src/hooks/useObservability.ts` (React Query hooks).
|
||||
- `frontend/src/api/client.ts` (API client functions).
|
||||
- `frontend/src/types/index.ts` (new interfaces).
|
||||
- `frontend/src/App.tsx` (route + nav item).
|
||||
- `frontend/src/components/ui/{card,badge,alert,skeleton,select}.tsx` (shadcn/ui components).
|
||||
- `frontend/vite.config.ts` already has `/grafana` dev proxy for iframe source.
|
||||
|
||||
### Phase 5 — Hardening and Future-Proofing
|
||||
|
||||
- [x] Add health checks and `deploy.resources` limits for Prometheus, Loki, Alloy, Grafana, Alertmanager, and Node Exporter in both compose files.
|
||||
- [x] Use `depends_on` with `condition: service_healthy` for Alloy → Loki and Grafana → Prometheus/Loki.
|
||||
- [x] Add Prometheus scrape jobs for Loki, Alertmanager, and Grafana so their `up` metrics are available for health alerts.
|
||||
- [x] Add observability health alerting rules (`PrometheusTargetMissing`, `AlertmanagerDown`, `GrafanaDown`).
|
||||
- [x] Add `ALERTMANAGER_WEBHOOK_URL` backend setting, `POST /api/monitoring/alertmanager-webhook` receiver, and Alertmanager `webhook` receiver config.
|
||||
- [x] Document runbooks for common alerts.
|
||||
- [x] Add volume backups for Prometheus/Loki/Grafana data. Backup/restore procedures for `prometheus_data`, `loki_data`, `grafana_data`, and `alertmanager_data` are documented in `docs/observability-runbooks.md`.
|
||||
- [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later.
|
||||
|
||||
**Phase 5 files**:
|
||||
- `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions).
|
||||
- `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services).
|
||||
- `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
|
||||
- `backend/src/media_library_viewer_api/config.py` (`alertmanager_webhook_url` setting).
|
||||
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`POST /api/monitoring/alertmanager-webhook`).
|
||||
- `monitoring/alertmanager/alertmanager.yml` (`webhook` receiver).
|
||||
- `backend/tests/test_api.py` (`TestAlertmanagerWebhook`).
|
||||
- `docs/observability-runbooks.md` (new runbook documentation).
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Node Exporter hard to install on NAS/minimal hosts | Keep POSIX collector as opt-in fallback; document manual install steps. |
|
||||
| Grafana iframe embedding blocked by CSP or cookies | Test early in Phase 4; use matching domains and `allow_embedding`. |
|
||||
| Prometheus storage grows faster than expected | Start with 30-day retention; add compaction alerts. |
|
||||
| Backup alert semantics lost in migration | Write tests comparing old Python alerts vs new Alertmanager rules. |
|
||||
| OIDC configuration drift between Manage and Grafana | Use same env vars/Authentik application for both. |
|
||||
| Remote scrape requires network path | Use reverse SSH tunnels or defer remote scraping until VPN is ready. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~What sub-domain or sub-path should Grafana use? (`grafana.${BACKEND_APP_HOST}` vs `${BACKEND_APP_HOST}/grafana`)~~ Decided: dedicated `GRAFANA_APP_HOST` subdomain in production; dev uses port 3000 and a `/grafana` proxy in Vite.
|
||||
2. Should remote machines run Node Exporter as a systemd service or a container?
|
||||
3. Do we need remote log aggregation immediately, or can it wait until after metrics alerting is stable?
|
||||
4. Should the existing backup alert acknowledgement/resolve UI be rebuilt on top of Alertmanager, or replaced by Grafana alert annotations?
|
||||
@@ -0,0 +1,190 @@
|
||||
# Observability Runbooks
|
||||
|
||||
Operational playbooks for the Manage self-hosted observability stack (Prometheus, Grafana, Loki, Alertmanager).
|
||||
|
||||
## Service Overview
|
||||
|
||||
| Service | Compose name | Internal URL | Health check |
|
||||
|---------|--------------|--------------|--------------|
|
||||
| Prometheus | `prometheus` | `http://prometheus:9090` | `/-/healthy` |
|
||||
| Grafana | `grafana` | `http://grafana:3000` | `/api/health` |
|
||||
| Loki | `loki` | `http://loki:3100` | `/ready` |
|
||||
| Alloy | `alloy` | `http://alloy:12345` | `/-/healthy` |
|
||||
| Alertmanager | `alertmanager` | `http://alertmanager:9093` | `/-/healthy` |
|
||||
| Node Exporter | `node-exporter` | `http://node-exporter:9100` | `/` |
|
||||
| Manage backend | `backend` | `http://backend:8000` | `/api/health` |
|
||||
|
||||
---
|
||||
|
||||
## Alert: `BackupJobFailed`
|
||||
|
||||
**Severity**: critical
|
||||
**Meaning**: A backup job reported `status=failed` within the last hour.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Open **Manage → Backups** and identify the failed job/run.
|
||||
2. Check the run output / logs for the failure reason.
|
||||
3. Search Loki for `{container="backend"} | json | message=~"(?i)backup"` around the failure time.
|
||||
4. If transient (network, lock file), retry the job.
|
||||
5. If persistent, open a task to fix the backup script or credentials.
|
||||
|
||||
---
|
||||
|
||||
## Alert: `BackupJobStuck`
|
||||
|
||||
**Severity**: warning
|
||||
**Meaning**: No successful backup run has been recorded for a job in the last 24 hours.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Confirm the job is still scheduled and expected to run.
|
||||
2. Check whether the backup scheduler/host is running.
|
||||
3. Verify the job can still report success to `POST /api/backups/reports`.
|
||||
4. Inspect Prometheus graph for `manage_backup_runs_last_success_timestamp` by `job_name`.
|
||||
5. If the job was intentionally retired, remove or disable its reporting.
|
||||
|
||||
---
|
||||
|
||||
## Alert: `PrometheusTargetMissing`
|
||||
|
||||
**Severity**: warning
|
||||
**Meaning**: A Prometheus scrape target is down (`up == 0`) for more than 2 minutes.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Identify `job` and `instance` from the alert labels.
|
||||
2. Check the container/process status:
|
||||
- `docker compose ps <service>`
|
||||
- `docker compose logs --tail 100 <service>`
|
||||
3. Verify network reachability from the Prometheus container:
|
||||
- `docker compose exec prometheus wget -qO- http://<instance>/`
|
||||
4. If the target is a remote Node Exporter:
|
||||
- Check the machine is reachable over SSH.
|
||||
- Verify Node Exporter is installed and running (`systemctl status node_exporter`).
|
||||
- Confirm the scrape host/port in Manage → Settings for that machine.
|
||||
5. Restart if needed: `docker compose restart <service>`.
|
||||
|
||||
---
|
||||
|
||||
## Alert: `AlertmanagerDown`
|
||||
|
||||
**Severity**: critical
|
||||
**Meaning**: Prometheus cannot scrape Alertmanager; new alerts may not be delivered.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Check container status: `docker compose ps alertmanager`
|
||||
2. Review logs: `docker compose logs --tail 200 alertmanager`
|
||||
3. Validate config syntax:
|
||||
- `docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml`
|
||||
4. Verify SMTP environment variables are present if using email receivers.
|
||||
5. Restart: `docker compose restart alertmanager`
|
||||
|
||||
---
|
||||
|
||||
## Alert: `GrafanaDown`
|
||||
|
||||
**Severity**: warning
|
||||
**Meaning**: Grafana is unreachable; dashboards and iframe panels in Manage are unavailable.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Check container status and logs.
|
||||
2. Verify the OAuth client configuration is correct (`GF_AUTH_GENERIC_OAUTH_*`).
|
||||
3. If embedded panels are blank, confirm Grafana `allow_embedding = true` and cookie settings.
|
||||
4. Restart: `docker compose restart grafana`
|
||||
|
||||
---
|
||||
|
||||
## Routine Maintenance
|
||||
|
||||
### Check overall health
|
||||
|
||||
```bash
|
||||
cd /path/to/manage
|
||||
docker compose ps
|
||||
docker compose exec prometheus wget -qO- http://127.0.0.1:9090/-/healthy
|
||||
docker compose exec grafana wget -qO- http://127.0.0.1:3000/api/health
|
||||
docker compose exec loki wget -qO- http://127.0.0.1:3100/ready
|
||||
docker compose exec alertmanager wget -qO- http://127.0.0.1:9093/-/healthy
|
||||
```
|
||||
|
||||
### Reload Prometheus after rule/config changes
|
||||
|
||||
Prometheus is started with `--web.enable-lifecycle`, so a SIGHUP or HTTP call reloads config:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/-/reload
|
||||
```
|
||||
|
||||
### Inspect logs
|
||||
|
||||
```bash
|
||||
# All backend logs in Loki via Grafana Explore, or locally:
|
||||
docker compose logs --tail 500 backend
|
||||
|
||||
# Specific service:
|
||||
docker compose logs -f prometheus
|
||||
```
|
||||
|
||||
### Storage usage
|
||||
|
||||
```bash
|
||||
docker system df -v
|
||||
docker compose exec prometheus du -sh /prometheus
|
||||
docker compose exec loki du -sh /loki
|
||||
docker compose exec grafana du -sh /var/lib/grafana
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup and Disaster Recovery
|
||||
|
||||
The observability data lives in named volumes:
|
||||
|
||||
- `prometheus_data`
|
||||
- `loki_data`
|
||||
- `grafana_data`
|
||||
- `alertmanager_data`
|
||||
|
||||
### Backup volumes
|
||||
|
||||
```bash
|
||||
# Stop the stack to ensure consistency
|
||||
docker compose down
|
||||
|
||||
# Back up each volume to a tarball
|
||||
docker run --rm -v manage_prometheus_data:/data -v $(pwd)/backups:/backups alpine \
|
||||
tar czf /backups/prometheus-$(date +%F).tar.gz -C /data .
|
||||
docker run --rm -v manage_loki_data:/data -v $(pwd)/backups:/backups alpine \
|
||||
tar czf /backups/loki-$(date +%F).tar.gz -C /data .
|
||||
docker run --rm -v manage_grafana_data:/data -v $(pwd)/backups:/backups alpine \
|
||||
tar czf /backups/grafana-$(date +%F).tar.gz -C /data .
|
||||
docker run --rm -v manage_alertmanager_data:/data -v $(pwd)/backups:/backups alpine \
|
||||
tar czf /backups/alertmanager-$(date +%F).tar.gz -C /data .
|
||||
|
||||
# Start the stack again
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> Replace `manage_` with your actual Docker Compose project name if different.
|
||||
|
||||
### Restore a volume
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm manage_prometheus_data
|
||||
docker volume create manage_prometheus_data
|
||||
docker run --rm -v manage_prometheus_data:/data -v $(pwd)/backups:/backups alpine \
|
||||
tar xzf /backups/prometheus-YYYY-MM-DD.tar.gz -C /data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaling Notes
|
||||
|
||||
- The current `deploy.resources` blocks are tuned for a small homelab. Raise memory limits if you monitor many machines or retain logs longer than 30 days.
|
||||
- Loki is configured for single-node filesystem storage. For larger deployments, migrate to object storage (S3/GCS/MinIO) and a shared index.
|
||||
- Prometheus remote-write or Thanos/Cortex can be added later for long-term metrics without changing application instrumentation.
|
||||
Generated
+8
-50
@@ -35,7 +35,7 @@
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@@ -3375,9 +3375,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3395,9 +3392,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3415,9 +3409,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3435,9 +3426,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3455,9 +3443,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3475,9 +3460,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3737,9 +3719,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3757,9 +3736,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3777,9 +3753,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3797,9 +3770,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4254,12 +4224,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
@@ -7532,9 +7502,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7556,9 +7523,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7580,9 +7544,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7604,9 +7565,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9774,9 +9732,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
|
||||
+11
-12
@@ -5,6 +5,7 @@ import {
|
||||
NavLink,
|
||||
useLocation,
|
||||
Outlet,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
QueryClient,
|
||||
@@ -14,13 +15,13 @@ import {
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Monitoring } from "./pages/Monitoring";
|
||||
import { Applications } from "./pages/Applications";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -80,7 +81,7 @@ function useDarkMode() {
|
||||
// Navigation items for sidebar
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/monitoring", label: "Monitoring", icon: Activity },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/applications", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
@@ -114,9 +115,7 @@ function Sidebar({
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
|
||||
M
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="font-semibold">Manage</span>
|
||||
)}
|
||||
{!collapsed && <span className="font-semibold">Manage</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -155,9 +154,7 @@ function Sidebar({
|
||||
<Icon className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<NavLink
|
||||
@@ -313,8 +310,8 @@ function ShellLayout({
|
||||
onToggleDarkMode: () => void;
|
||||
}) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
window.matchMedia("(max-width: 768px)").matches,
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => window.matchMedia("(max-width: 768px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -435,13 +432,14 @@ function AppInner() {
|
||||
<Routes>
|
||||
<Route element={<AuthenticatedApp />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Monitoring />} />
|
||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
||||
<Route path="/applications" element={<Applications />} />
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -459,13 +457,14 @@ function AppInner() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Monitoring />} />
|
||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
||||
<Route path="/applications" element={<Applications />} />
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+13
-54
@@ -10,12 +10,7 @@ import type {
|
||||
UserMessageResponse,
|
||||
UserMessageQueueStatus,
|
||||
NowPlayingSession,
|
||||
MonitoringPollerStatus,
|
||||
MonitoringOverviewResponse,
|
||||
AppVersionInfo,
|
||||
MonitoringStatus,
|
||||
MonitoringMetrics,
|
||||
DiskSpace,
|
||||
SSHKey,
|
||||
SSHKeyInput,
|
||||
SSHKeyGenerated,
|
||||
@@ -24,7 +19,6 @@ import type {
|
||||
SavedTaskRun,
|
||||
MonitoringMachine,
|
||||
MonitoringMachineInput,
|
||||
MonitoringMachineAction,
|
||||
MediaIndexStatus,
|
||||
MediaIndexActionResponse,
|
||||
MediaQueryResponse,
|
||||
@@ -37,6 +31,9 @@ import type {
|
||||
SSHValidationResult,
|
||||
DashboardShortcut,
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
@@ -165,11 +162,7 @@ export const fetchNowPlaying = fetchActivity;
|
||||
// Monitoring
|
||||
export const fetchMonitoringMachines = () =>
|
||||
get<MonitoringMachine[]>("/api/monitoring/machines");
|
||||
export const fetchMonitoringPoller = () =>
|
||||
get<MonitoringPollerStatus>("/api/monitoring/poller");
|
||||
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
|
||||
export const fetchMonitoringOverview = () =>
|
||||
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
|
||||
export const fetchDashboardShortcuts = () =>
|
||||
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
||||
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
|
||||
@@ -194,45 +187,6 @@ export const deleteDashboardShortcut = (shortcutId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
||||
);
|
||||
export const fetchMonitoringStatus = (machineId?: string) =>
|
||||
get<MonitoringStatus>(
|
||||
"/api/monitoring/status",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
export const fetchMonitoringMetrics = (
|
||||
lastSeconds?: number | null,
|
||||
maxLines = 70_000,
|
||||
machineId?: string,
|
||||
) =>
|
||||
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
||||
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
|
||||
max_lines: String(maxLines),
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchDiskSpace = (machineId?: string) =>
|
||||
get<DiskSpace>(
|
||||
"/api/monitoring/disk",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
export const startCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/start?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/start",
|
||||
);
|
||||
export const stopCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/stop?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/stop",
|
||||
);
|
||||
export const restartCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/restart?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/restart",
|
||||
);
|
||||
|
||||
export const fetchMonitoringSettings = () =>
|
||||
get<MonitoringMachine[]>("/api/settings/machines");
|
||||
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
|
||||
@@ -242,11 +196,6 @@ export const generateSSHKey = (payload: {
|
||||
notes: string;
|
||||
bits?: number;
|
||||
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
|
||||
export const fetchMonitoringMachineActions = (machineId: string, limit = 10) =>
|
||||
get<{ items: MonitoringMachineAction[]; total: number }>(
|
||||
`/api/monitoring/machines/${encodeURIComponent(machineId)}/actions`,
|
||||
{ limit: String(limit) },
|
||||
);
|
||||
export const saveSSHKey = (key: SSHKeyInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
@@ -428,3 +377,13 @@ export const fetchUserMessageQueueStatus = () =>
|
||||
|
||||
export const sendUserMessage = (formData: FormData) =>
|
||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||
|
||||
// Observability summary endpoints
|
||||
export const fetchAlertmanagerAlerts = () =>
|
||||
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
|
||||
|
||||
export const fetchAlertmanagerStatus = () =>
|
||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
||||
|
||||
export const fetchPrometheusTargets = () =>
|
||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { MetricCard } from "./MetricCard";
|
||||
import { MonitoringCharts } from "./MonitoringCharts";
|
||||
import type { MonitoringMachine } from "../types";
|
||||
import {
|
||||
useCollectorControls,
|
||||
useDiskSpace,
|
||||
useMachineActions,
|
||||
useMonitoringMetrics,
|
||||
useMonitoringStatus,
|
||||
} from "../hooks/useMonitoring";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
function avg(arr: number[]) {
|
||||
return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
}
|
||||
function max(arr: number[]) {
|
||||
return arr.length ? Math.max(...arr) : 0;
|
||||
}
|
||||
|
||||
function formatActionTime(epochSeconds: number): string {
|
||||
return new Date(epochSeconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function ErrorBanner({ label, error }: { label: string; error: unknown }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{label}: {String(error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function MachineMonitoringSection({
|
||||
machine,
|
||||
}: {
|
||||
machine: MonitoringMachine;
|
||||
}) {
|
||||
const statusQuery = useMonitoringStatus(machine.id, machine.enabled);
|
||||
const metricsQuery = useMonitoringMetrics(machine.id, machine.enabled);
|
||||
const diskQuery = useDiskSpace(machine.id, machine.enabled);
|
||||
const { start, stop, restart } = useCollectorControls(machine.id);
|
||||
const actionsQuery = useMachineActions(machine.id, machine.enabled);
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [resultFilter, setResultFilter] = useState("all");
|
||||
|
||||
const status = statusQuery.data;
|
||||
const metrics = metricsQuery.data;
|
||||
const disk = diskQuery.data;
|
||||
const samples = metrics?.samples ?? [];
|
||||
const latest = samples.at(-1);
|
||||
const cpuArr = samples.map((s) => s.cpu_pct);
|
||||
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
|
||||
const memArr = samples.map((s) => s.mem_pct);
|
||||
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
|
||||
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
|
||||
const diskReadArr = samples.map((s) => s.disk_read_bps);
|
||||
const diskWriteArr = samples.map((s) => s.disk_write_bps);
|
||||
const actions = actionsQuery.data?.items ?? [];
|
||||
const visibleActions = useMemo(
|
||||
() =>
|
||||
actions.filter((action) => {
|
||||
const actionMatches =
|
||||
actionFilter === "all" || action.action === actionFilter;
|
||||
const resultMatches =
|
||||
resultFilter === "all" || action.status === resultFilter;
|
||||
return actionMatches && resultMatches;
|
||||
}),
|
||||
[actions, actionFilter, resultFilter],
|
||||
);
|
||||
const hasQueryError =
|
||||
statusQuery.error ||
|
||||
metricsQuery.error ||
|
||||
diskQuery.error ||
|
||||
actionsQuery.error;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{machine.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{machine.mode === "local"
|
||||
? "Local API host"
|
||||
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
|
||||
{machine.media_root ? ` · media root ${machine.media_root}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label={machine.mode} variant="outlined" />
|
||||
<Chip
|
||||
label={machine.enabled ? "Enabled" : "Disabled"}
|
||||
color={machine.enabled ? "success" : "default"}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={status?.status ?? "unknown"}
|
||||
color={status?.status?.includes("running") ? "success" : "primary"}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending || !machine.enabled}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => restart.mutate()}
|
||||
disabled={restart.isPending || !machine.enabled}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending || !machine.enabled}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{hasQueryError && (
|
||||
<Stack spacing={1}>
|
||||
<ErrorBanner label="Status" error={statusQuery.error} />
|
||||
<ErrorBanner label="Metrics" error={metricsQuery.error} />
|
||||
<ErrorBanner label="Disk" error={diskQuery.error} />
|
||||
<ErrorBanner label="Recent activity" error={actionsQuery.error} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="CPU now"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="RAM now"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{disk && (
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<MonitoringCharts samples={samples} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Recent activity
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Collected automatically by the backend poller.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${visibleActions.length}/${actions.length || 0}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Action</InputLabel>
|
||||
<Select
|
||||
label="Action"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value as string)}
|
||||
>
|
||||
<MenuItem value="all">All actions</MenuItem>
|
||||
<MenuItem value="status lookup">Status</MenuItem>
|
||||
<MenuItem value="metrics read">Metrics</MenuItem>
|
||||
<MenuItem value="disk lookup">Disk</MenuItem>
|
||||
<MenuItem value="collector start">Start</MenuItem>
|
||||
<MenuItem value="collector stop">Stop</MenuItem>
|
||||
<MenuItem value="collector restart">Restart</MenuItem>
|
||||
<MenuItem value="collector diagnostics">Diagnostics</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Result</InputLabel>
|
||||
<Select
|
||||
label="Result"
|
||||
value={resultFilter}
|
||||
onChange={(e) => setResultFilter(e.target.value as string)}
|
||||
>
|
||||
<MenuItem value="all">All results</MenuItem>
|
||||
<MenuItem value="ok">OK</MenuItem>
|
||||
<MenuItem value="error">Error</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
{actionsQuery.error && (
|
||||
<Alert severity="error">
|
||||
Recent activity: {String(actionsQuery.error)}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Time</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Duration</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visibleActions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No activity matches the current filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
visibleActions.map((action) => (
|
||||
<TableRow
|
||||
key={`${action.machine_id}-${action.created_at}-${action.action}-${action.status}`}
|
||||
>
|
||||
<TableCell>{formatActionTime(action.created_at)}</TableCell>
|
||||
<TableCell>{action.action}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={action.status}
|
||||
color={action.status === "ok" ? "success" : "error"}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{action.duration_ms} ms</TableCell>
|
||||
<TableCell>
|
||||
{action.message || action.error || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
export { MonitoringCharts } from "./MonitoringCharts.impl";
|
||||
@@ -1,716 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type {
|
||||
MonitoringMachineOverview,
|
||||
MonitoringOverviewResponse,
|
||||
} from "../types";
|
||||
|
||||
type SortKey =
|
||||
| "machine"
|
||||
| "mode"
|
||||
| "status"
|
||||
| "cpu"
|
||||
| "iowait"
|
||||
| "mem"
|
||||
| "net_rx"
|
||||
| "net_tx"
|
||||
| "disk_read"
|
||||
| "disk_write"
|
||||
| "disk_used"
|
||||
| "updated";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
function formatAge(epochSeconds: number | null): string {
|
||||
if (!epochSeconds) return "-";
|
||||
const diff = Date.now() / 1000 - epochSeconds;
|
||||
if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
|
||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
|
||||
return `${Math.round(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
function formatReadableTime(epochSeconds: number | null): string {
|
||||
if (!epochSeconds) return "-";
|
||||
const date = new Date(epochSeconds * 1000);
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatClockTime(epochSeconds: number | null): string {
|
||||
if (!epochSeconds) return "-";
|
||||
const date = new Date(epochSeconds * 1000);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdatedDetails(epochSeconds: number | null): [string, string] {
|
||||
if (!epochSeconds) return ["-", "-"];
|
||||
return [
|
||||
formatReadableTime(epochSeconds),
|
||||
`${formatClockTime(epochSeconds)} · ${formatAge(epochSeconds)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function formatSummary(
|
||||
summary: { avg: number; min: number; max: number } | null,
|
||||
formatter: (value: number) => string,
|
||||
) {
|
||||
if (!summary) return { value: "-", min: "", max: "" };
|
||||
return {
|
||||
value: formatter(summary.avg),
|
||||
min: formatter(summary.min),
|
||||
max: formatter(summary.max),
|
||||
};
|
||||
}
|
||||
|
||||
function sortableText(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function getStatusChipProps(row: MonitoringMachineOverview) {
|
||||
if (row.status_error) {
|
||||
return { color: "error" as const, label: row.status_error };
|
||||
}
|
||||
if (row.status === "running") {
|
||||
return { color: "success" as const, label: "running" };
|
||||
}
|
||||
if (row.status === "not running") {
|
||||
return { color: "warning" as const, label: "not running" };
|
||||
}
|
||||
return { color: "default" as const, label: row.status || "-" };
|
||||
}
|
||||
|
||||
function metricSortValue(
|
||||
row: MonitoringMachineOverview,
|
||||
key: SortKey,
|
||||
): number | string {
|
||||
switch (key) {
|
||||
case "machine":
|
||||
return sortableText(row.machine.name);
|
||||
case "mode":
|
||||
return row.machine.mode;
|
||||
case "status":
|
||||
return sortableText(row.status || row.status_error || "");
|
||||
case "cpu":
|
||||
return row.cpu_summary?.avg ?? -1;
|
||||
case "iowait":
|
||||
return row.iowait_summary?.avg ?? -1;
|
||||
case "mem":
|
||||
return row.mem_summary?.avg ?? -1;
|
||||
case "net_rx":
|
||||
return row.net_rx_summary?.avg ?? -1;
|
||||
case "net_tx":
|
||||
return row.net_tx_summary?.avg ?? -1;
|
||||
case "disk_read":
|
||||
return row.disk_read_summary?.avg ?? -1;
|
||||
case "disk_write":
|
||||
return row.disk_write_summary?.avg ?? -1;
|
||||
case "disk_used":
|
||||
return parseFloat((row.disk?.used_pct || "0").replace("%", "")) || -1;
|
||||
case "updated":
|
||||
return row.latest_sample?.ts ?? -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function NoDataCell() {
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
height: "100%",
|
||||
minHeight: 118,
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.7rem",
|
||||
fontStyle: "italic",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
No data
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCell({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
height: "100%",
|
||||
minHeight: 118,
|
||||
textAlign: "center",
|
||||
justifyContent: "space-between",
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
minHeight: 68,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.25} sx={{ alignItems: "center" }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.6rem",
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
10m avg
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 900,
|
||||
fontSize: "1.12rem",
|
||||
lineHeight: 1,
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack spacing={0.25} sx={{ width: "100%" }}>
|
||||
{min ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 999,
|
||||
px: 0.75,
|
||||
py: 0.15,
|
||||
fontSize: "0.6rem",
|
||||
lineHeight: 1.2,
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
Min {min}
|
||||
</Box>
|
||||
) : null}
|
||||
{max ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 999,
|
||||
px: 0.75,
|
||||
py: 0.15,
|
||||
fontSize: "0.6rem",
|
||||
lineHeight: 1.2,
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
Max {max}
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringOverviewTable({
|
||||
overview,
|
||||
embedded = false,
|
||||
}: {
|
||||
overview?: MonitoringOverviewResponse;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const poller = overview?.poller;
|
||||
const rows = overview?.machines ?? [];
|
||||
const [sortKey, setSortKey] = useState<SortKey>("machine");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const factor = sortDirection === "asc" ? 1 : -1;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = metricSortValue(a, sortKey);
|
||||
const bv = metricSortValue(b, sortKey);
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
return String(av).localeCompare(String(bv)) * factor;
|
||||
});
|
||||
}, [rows, sortDirection, sortKey]);
|
||||
|
||||
const setSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
{!embedded ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Machine monitoring
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={poller?.worker_running ? "success" : "default"}
|
||||
label={poller?.worker_running ? "running" : "stopped"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last success:{" "}
|
||||
{poller?.last_success_at
|
||||
? new Date(poller.last_success_at * 1000).toLocaleString()
|
||||
: "-"}{" "}
|
||||
· last run: {formatAge(poller?.last_run_at ?? null)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
{!embedded && poller?.last_error ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="error.main">
|
||||
Poller error: {poller.last_error}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<TableContainer
|
||||
component={embedded ? Box : Paper}
|
||||
variant={embedded ? undefined : "outlined"}
|
||||
sx={
|
||||
embedded
|
||||
? {
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
}
|
||||
: {
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 1500,
|
||||
tableLayout: "fixed",
|
||||
"& .MuiTableCell-root": {
|
||||
px: 1.1,
|
||||
py: 0.9,
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
"& .MuiTableHead .MuiTableCell-root": {
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{ width: 260 }}
|
||||
sortDirection={sortKey === "machine" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "machine"}
|
||||
direction={sortKey === "machine" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("machine")}
|
||||
>
|
||||
Machine
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ width: 90 }}
|
||||
sortDirection={sortKey === "mode" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "mode"}
|
||||
direction={sortKey === "mode" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("mode")}
|
||||
>
|
||||
Mode
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ width: 120 }}
|
||||
sortDirection={sortKey === "status" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "status"}
|
||||
direction={sortKey === "status" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("status")}
|
||||
>
|
||||
Status
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
{[
|
||||
["cpu", "CPU"],
|
||||
["iowait", "IO wait"],
|
||||
["mem", "RAM"],
|
||||
["net_rx", "Net down"],
|
||||
["net_tx", "Net up"],
|
||||
["disk_read", "Disk read"],
|
||||
["disk_write", "Disk write"],
|
||||
["disk_used", "Disk used"],
|
||||
].map(([key, label]) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
align="center"
|
||||
sx={{ width: 150 }}
|
||||
sortDirection={sortKey === key ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === key}
|
||||
direction={sortKey === key ? sortDirection : "asc"}
|
||||
onClick={() => setSort(key as SortKey)}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell
|
||||
sx={{ width: 180 }}
|
||||
sortDirection={sortKey === "updated" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "updated"}
|
||||
direction={sortKey === "updated" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("updated")}
|
||||
>
|
||||
Updated
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No monitoring machines are configured.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedRows.map((row) => {
|
||||
const machine = row.machine;
|
||||
const hasMetrics = row.sample_count > 0;
|
||||
const cpu = formatSummary(
|
||||
row.cpu_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const iowait = formatSummary(
|
||||
row.iowait_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const mem = formatSummary(
|
||||
row.mem_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const netDown = formatSummary(row.net_rx_summary, formatRate);
|
||||
const netUp = formatSummary(row.net_tx_summary, formatRate);
|
||||
const diskRead = formatSummary(
|
||||
row.disk_read_summary,
|
||||
formatRate,
|
||||
);
|
||||
const diskWrite = formatSummary(
|
||||
row.disk_write_summary,
|
||||
formatRate,
|
||||
);
|
||||
const disk = row.disk
|
||||
? {
|
||||
value: row.disk.used_pct,
|
||||
min: `Used ${formatBytes(row.disk.used)}`,
|
||||
max: `Avail ${formatBytes(row.disk.available)}`,
|
||||
}
|
||||
: null;
|
||||
const updated = formatUpdatedDetails(
|
||||
row.latest_sample?.ts ?? null,
|
||||
);
|
||||
return (
|
||||
<TableRow key={machine.id}>
|
||||
<TableCell sx={{ width: 260 }}>
|
||||
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.75}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{machine.name}
|
||||
</Typography>
|
||||
{!machine.enabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="disabled"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{machine.mode === "local"
|
||||
? "Local API host"
|
||||
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: 90 }}>{machine.mode}</TableCell>
|
||||
<TableCell sx={{ width: 120 }}>
|
||||
{(() => {
|
||||
const chip = getStatusChipProps(row);
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
row.metrics_error
|
||||
? `Metrics: ${row.metrics_error}`
|
||||
: row.sample_count === 0
|
||||
? "No metrics collected yet. Start the collector to begin monitoring."
|
||||
: ""
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={chip.color}
|
||||
label={chip.label}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={cpu.value}
|
||||
min={cpu.min}
|
||||
max={cpu.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={iowait.value}
|
||||
min={iowait.min}
|
||||
max={iowait.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={mem.value}
|
||||
min={mem.min}
|
||||
max={mem.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={netDown.value}
|
||||
min={netDown.min}
|
||||
max={netDown.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={netUp.value}
|
||||
min={netUp.min}
|
||||
max={netUp.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={diskRead.value}
|
||||
min={diskRead.min}
|
||||
max={diskRead.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{hasMetrics ? (
|
||||
<MetricCell
|
||||
value={diskWrite.value}
|
||||
min={diskWrite.min}
|
||||
max={diskWrite.max}
|
||||
/>
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{disk ? (
|
||||
<MetricCell
|
||||
value={disk.value}
|
||||
min={disk.min}
|
||||
max={disk.max}
|
||||
/>
|
||||
) : hasMetrics ? (
|
||||
"-"
|
||||
) : (
|
||||
<NoDataCell />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: 180 }}>
|
||||
<Stack spacing={0.1} sx={{ alignItems: "center" }}>
|
||||
{hasMetrics ? (
|
||||
updated.map((line) => (
|
||||
<Typography
|
||||
key={line}
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.1 }}
|
||||
>
|
||||
{line}
|
||||
</Typography>
|
||||
))
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.1, fontStyle: "italic" }}
|
||||
>
|
||||
No data yet
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import {
|
||||
Component,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ElementType,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Inbox,
|
||||
PanelTop,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ServerOff,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
usePrometheusTargets,
|
||||
useMonitoringMachines,
|
||||
} from "../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type {
|
||||
AlertmanagerAlert,
|
||||
MonitoringMachine,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
status: "ok" | "warning" | "error" | "unknown";
|
||||
detail: string;
|
||||
icon: ElementType;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const statusIcon =
|
||||
status === "ok" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : status === "warning" ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
) : status === "error" ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
|
||||
<span className="text-2xl font-bold capitalize">{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryError({
|
||||
label,
|
||||
error,
|
||||
refetch,
|
||||
}: {
|
||||
label: string;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{label} failed</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error.message}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class GrafanaErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; fallback: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Grafana panel error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function GrafanaPanel({ src, title }: { src: string; title: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
timerRef.current = setTimeout(() => setFailed(true), 10_000);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [iframeKey]);
|
||||
|
||||
const handleLoad = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setLoaded(true);
|
||||
setFailed(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setFailed(true);
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
setLoaded(false);
|
||||
setFailed(false);
|
||||
setIframeKey((k) => k + 1);
|
||||
};
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={PanelTop}
|
||||
title="No Grafana URL"
|
||||
description="Select a machine to load a Grafana panel."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fallback = (
|
||||
<EmptyState
|
||||
icon={PanelTop}
|
||||
title="Grafana panel unavailable"
|
||||
description="The panel did not load in time or Grafana is unreachable."
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={reload}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reload
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
Open in Grafana
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<GrafanaErrorBoundary key={iframeKey} fallback={fallback}>
|
||||
<div className="relative h-full min-h-[320px] w-full overflow-hidden rounded-md border">
|
||||
{!loaded && !failed && (
|
||||
<div className="absolute inset-0 z-10 p-4">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
{failed ? (
|
||||
<div className="absolute inset-0 z-10 bg-background p-2">
|
||||
{fallback}
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
title={title}
|
||||
src={src}
|
||||
className="h-full min-h-[320px] w-full"
|
||||
allow="fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GrafanaErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
refetch: refetchAlerts,
|
||||
} = useAlertmanagerAlerts();
|
||||
const {
|
||||
data: alertmanagerStatus,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useAlertmanagerStatus();
|
||||
const {
|
||||
data: prometheusTargets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
refetch: refetchTargets,
|
||||
} = usePrometheusTargets();
|
||||
const {
|
||||
data: machines = [],
|
||||
isLoading: machinesLoading,
|
||||
error: machinesError,
|
||||
refetch: refetchMachines,
|
||||
} = useMonitoringMachines();
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
||||
|
||||
const selectedMachine = useMemo<MonitoringMachine | null>(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const grafanaBase = "/grafana";
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine) return "";
|
||||
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${grafanaBase}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
||||
}, [selectedMachine]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${grafanaBase}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine]);
|
||||
|
||||
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
||||
? alertmanagerStatus.version
|
||||
? `version ${alertmanagerStatus.version}`
|
||||
: "reachable"
|
||||
: "unreachable";
|
||||
|
||||
const targetsCount = prometheusTargets?.length ?? 0;
|
||||
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
|
||||
? "unknown"
|
||||
: targetsError
|
||||
? "error"
|
||||
: targetsCount > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
|
||||
? "unknown"
|
||||
: alertsError
|
||||
? "error"
|
||||
: (alertsSummary?.total ?? 0) > 0
|
||||
? alertsSummary?.alerts.some((a) => a.severity === "critical")
|
||||
? "error"
|
||||
: "warning"
|
||||
: "ok";
|
||||
|
||||
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
|
||||
? "unknown"
|
||||
: machinesError
|
||||
? "error"
|
||||
: machines.length > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unified view of metrics, logs, and alerts from Prometheus, Grafana,
|
||||
Loki, and Alertmanager.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HealthCard
|
||||
title="Alertmanager"
|
||||
status={
|
||||
statusError
|
||||
? "error"
|
||||
: alertmanagerStatus?.up
|
||||
? "ok"
|
||||
: statusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={alertmanagerStatusDetail}
|
||||
icon={Bell}
|
||||
isLoading={statusLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Active Alerts"
|
||||
status={alertStatus}
|
||||
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
|
||||
icon={AlertTriangle}
|
||||
isLoading={alertsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus Targets"
|
||||
status={targetsStatus}
|
||||
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
|
||||
icon={Radio}
|
||||
isLoading={targetsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Machines"
|
||||
status={machinesStatus}
|
||||
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
|
||||
icon={Server}
|
||||
isLoading={machinesLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{statusError && (
|
||||
<QueryError
|
||||
label="Alertmanager status"
|
||||
error={statusError}
|
||||
refetch={refetchStatus}
|
||||
/>
|
||||
)}
|
||||
{alertsError && (
|
||||
<QueryError
|
||||
label="Active alerts"
|
||||
error={alertsError}
|
||||
refetch={refetchAlerts}
|
||||
/>
|
||||
)}
|
||||
{targetsError && (
|
||||
<QueryError
|
||||
label="Prometheus targets"
|
||||
error={targetsError}
|
||||
refetch={refetchTargets}
|
||||
/>
|
||||
)}
|
||||
{machinesError && (
|
||||
<QueryError
|
||||
label="Monitoring machines"
|
||||
error={machinesError}
|
||||
refetch={refetchMachines}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alertsSummary?.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Alertmanager unreachable</AlertTitle>
|
||||
<AlertDescription>
|
||||
The UI cannot reach Alertmanager right now. Alerts shown here may be
|
||||
stale.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No active alerts"
|
||||
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more
|
||||
alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus Targets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !prometheusTargets || prometheusTargets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Radio}
|
||||
title="No Node Exporter targets"
|
||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TargetsTable targets={prometheusTargets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machines.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedMachine ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedMachine.name} metrics
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={nodeExporterDashboardUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<GrafanaPanel
|
||||
key={nodeExporterDashboardUrl}
|
||||
src={nodeExporterDashboardUrl}
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm font-medium">Recent logs</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Explore in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<GrafanaPanel
|
||||
key={logsUrl}
|
||||
src={logsUrl}
|
||||
title={`${selectedMachine.name} logs`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title="No machine selected"
|
||||
description="Add monitoring machines in Settings to embed Grafana dashboards."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Badge.displayName = "Badge";
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
fetchCounts,
|
||||
fetchDashboardShortcuts,
|
||||
fetchLibraries,
|
||||
fetchMonitoringOverview,
|
||||
saveDashboardShortcut,
|
||||
} from "../api/client";
|
||||
import type { DashboardShortcutInput } from "../types";
|
||||
@@ -34,14 +33,6 @@ export function useActivity(machineId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringOverview() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "monitoring"],
|
||||
queryFn: fetchMonitoringOverview,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Backward-compatible alias used by older code.
|
||||
export const useNowPlaying = useActivity;
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchMonitoringStatus,
|
||||
fetchMonitoringMetrics,
|
||||
fetchDiskSpace,
|
||||
fetchMonitoringMachines,
|
||||
fetchMonitoringMachineActions,
|
||||
fetchMonitoringPoller,
|
||||
startCollector,
|
||||
stopCollector,
|
||||
restartCollector,
|
||||
} from "../api/client";
|
||||
|
||||
export function useMonitoringMachines() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "machines"],
|
||||
queryFn: fetchMonitoringMachines,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringPoller() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "poller"],
|
||||
queryFn: fetchMonitoringPoller,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringStatus(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "status", machineId ?? "default"],
|
||||
queryFn: () => fetchMonitoringStatus(machineId),
|
||||
refetchInterval: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMetrics(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "metrics", machineId ?? "default"],
|
||||
queryFn: () => fetchMonitoringMetrics(undefined, 70_000, machineId),
|
||||
refetchInterval: 15_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiskSpace(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "disk", machineId ?? "default"],
|
||||
queryFn: () => fetchDiskSpace(machineId),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMachineActions(machineId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "actions", machineId],
|
||||
queryFn: () => fetchMonitoringMachineActions(machineId),
|
||||
refetchInterval: 15_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCollectorControls(machineId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: () => startCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const stop = useMutation({
|
||||
mutationFn: () => stopCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const restart = useMutation({
|
||||
mutationFn: () => restartCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { start, stop, restart };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchPrometheusTargets,
|
||||
fetchMonitoringMachines,
|
||||
} from "../api/client";
|
||||
|
||||
export function useAlertmanagerAlerts() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alerts"],
|
||||
queryFn: fetchAlertmanagerAlerts,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertmanagerStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alertmanager-status"],
|
||||
queryFn: fetchAlertmanagerStatus,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusTargets() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-targets"],
|
||||
queryFn: fetchPrometheusTargets,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMachines() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "machines"],
|
||||
queryFn: fetchMonitoringMachines,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -26,13 +26,11 @@ import {
|
||||
useActivity,
|
||||
useDashboardShortcuts,
|
||||
useDeleteDashboardShortcut,
|
||||
useMonitoringOverview,
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||
@@ -325,7 +323,6 @@ export function Dashboard() {
|
||||
const selectedJellyfinId =
|
||||
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
|
||||
const { data: activity } = useActivity(selectedJellyfinId || undefined);
|
||||
const { data: monitoringOverview } = useMonitoringOverview();
|
||||
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||
const saveShortcut = useSaveDashboardShortcut();
|
||||
const deleteShortcut = useDeleteDashboardShortcut();
|
||||
@@ -438,13 +435,6 @@ export function Dashboard() {
|
||||
) : null}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Monitoring overview"
|
||||
description="10-minute averages and fleet status across all configured machines."
|
||||
>
|
||||
<MonitoringOverviewTable overview={monitoringOverview} embedded />
|
||||
</SectionCard>
|
||||
|
||||
<BackupDashboardWidget />
|
||||
|
||||
<ShortcutDialog
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Button, Stack, Tab, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useMonitoringMachines,
|
||||
useMonitoringPoller,
|
||||
} from "../hooks/useMonitoring";
|
||||
import { useMonitoringOverview } from "../hooks/useDashboard";
|
||||
import { MachineMonitoringSection } from "../components/MachineMonitoringSection";
|
||||
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
type MonitoringTab = "overview" | string;
|
||||
|
||||
export function Monitoring() {
|
||||
const { data: machines = [], isLoading, error } = useMonitoringMachines();
|
||||
const { data: poller } = useMonitoringPoller();
|
||||
const { data: overview } = useMonitoringOverview();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<MonitoringTab>("overview");
|
||||
|
||||
const selectedMachine = useMemo(
|
||||
() => machines.find((machine) => machine.id === tab) ?? null,
|
||||
[machines, tab],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
||||
Monitoring
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Review fleet health first, then switch to a machine-specific tab.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{error ? <Alert severity="error">{String(error)}</Alert> : null}
|
||||
{isLoading ? (
|
||||
<Alert severity="info">Loading monitoring machines...</Alert>
|
||||
) : null}
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<Tab key="overview" value="overview" label="Overview" />,
|
||||
...machines.map((machine) => (
|
||||
<Tab key={machine.id} value={machine.id} label={machine.name} />
|
||||
)),
|
||||
]}
|
||||
contentSx={{ p: 1.5 }}
|
||||
>
|
||||
{tab === "overview" ? (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Fleet overview
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{poller?.worker_running ? "running" : "stopped"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && machines.length === 0 ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No monitoring machines are configured yet.
|
||||
</Alert>
|
||||
) : overview ? (
|
||||
<MonitoringOverviewTable overview={overview} embedded />
|
||||
) : (
|
||||
<Alert severity="info">Loading fleet overview...</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
) : selectedMachine ? (
|
||||
<SectionCard
|
||||
title={selectedMachine.name}
|
||||
description={
|
||||
selectedMachine.mode === "local"
|
||||
? "Local API host"
|
||||
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
|
||||
}
|
||||
>
|
||||
<MachineMonitoringSection machine={selectedMachine} />
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</TabbedCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+32
-83
@@ -246,28 +246,6 @@ export interface SavedTaskRun {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineAction {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
mode: "local" | "ssh";
|
||||
action: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
message: string;
|
||||
error: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseInput {
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
@@ -291,20 +269,6 @@ export interface SSHValidationResult {
|
||||
known_hosts_updated: boolean;
|
||||
}
|
||||
|
||||
export interface MonitoringPollerStatus {
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
last_run_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string;
|
||||
last_cycle_ms: number | null;
|
||||
poll_count: number;
|
||||
error_count: number;
|
||||
interval_seconds: number;
|
||||
initial_delay_seconds: number;
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
app: string;
|
||||
backend_version: string;
|
||||
@@ -312,53 +276,6 @@ export interface AppVersionInfo {
|
||||
backend_label: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineOverview {
|
||||
machine: MonitoringMachine;
|
||||
status: string;
|
||||
status_error: string;
|
||||
metrics_error: string;
|
||||
disk_error: string;
|
||||
latest_sample: MonitoringSample | null;
|
||||
sample_count: number;
|
||||
cpu_summary: MetricSummary | null;
|
||||
iowait_summary: MetricSummary | null;
|
||||
mem_summary: MetricSummary | null;
|
||||
net_rx_summary: MetricSummary | null;
|
||||
net_tx_summary: MetricSummary | null;
|
||||
disk_read_summary: MetricSummary | null;
|
||||
disk_write_summary: MetricSummary | null;
|
||||
disk: DiskSpace | null;
|
||||
}
|
||||
|
||||
export interface MonitoringOverviewResponse {
|
||||
poller: MonitoringPollerStatus;
|
||||
machines: MonitoringMachineOverview[];
|
||||
total: number;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export interface MonitoringStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MonitoringSample {
|
||||
ts: number;
|
||||
cpu_pct: number;
|
||||
iowait_pct?: number;
|
||||
mem_pct: number;
|
||||
net_rx_bytes_per_sec: number;
|
||||
net_tx_bytes_per_sec: number;
|
||||
disk_read_bps: number;
|
||||
disk_write_bps: number;
|
||||
}
|
||||
|
||||
export interface MonitoringMetrics {
|
||||
samples: MonitoringSample[];
|
||||
total_samples: number;
|
||||
filtered_samples: number;
|
||||
cutoff_ts: number;
|
||||
}
|
||||
|
||||
export interface DiskSpace {
|
||||
filesystem: string;
|
||||
size: number;
|
||||
@@ -503,3 +420,35 @@ export interface DashboardShortcutInput {
|
||||
user_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlert {
|
||||
name: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
job_name: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
active_since: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlertSummary {
|
||||
total: number;
|
||||
by_severity: Record<string, number>;
|
||||
alerts: AlertmanagerAlert[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
uptime: string;
|
||||
name: string;
|
||||
peers: string[];
|
||||
}
|
||||
|
||||
export interface PrometheusTarget {
|
||||
labels: Record<string, string>;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"/grafana": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/grafana/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
global:
|
||||
smtp_smarthost: '${SMTP_HOST:-smtp.example.com}:${SMTP_PORT:-587}'
|
||||
smtp_from: '${SMTP_FROM_ADDRESS:-alerts@example.com}'
|
||||
smtp_auth_username: '${SMTP_USERNAME:-}'
|
||||
smtp_auth_password: '${SMTP_PASSWORD}'
|
||||
|
||||
route:
|
||||
receiver: 'default'
|
||||
group_by: ['alertname', 'severity', 'category']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
routes:
|
||||
- match:
|
||||
severity: critical
|
||||
receiver: 'critical'
|
||||
continue: true
|
||||
|
||||
receivers:
|
||||
- name: 'default'
|
||||
email_configs:
|
||||
- to: '${ALERT_EMAIL_TO}'
|
||||
send_resolved: true
|
||||
headers:
|
||||
Subject: '[Manage] {{ .GroupLabels.alertname }}'
|
||||
|
||||
- name: 'critical'
|
||||
email_configs:
|
||||
- to: '${ALERT_EMAIL_TO}'
|
||||
send_resolved: true
|
||||
headers:
|
||||
Subject: '[Manage CRITICAL] {{ .GroupLabels.alertname }}'
|
||||
|
||||
- name: 'webhook'
|
||||
webhook_configs:
|
||||
- url: '${ALERTMANAGER_WEBHOOK_URL:-http://backend:8000/api/monitoring/alertmanager-webhook}'
|
||||
send_resolved: true
|
||||
|
||||
inhibit_rules:
|
||||
- source_match:
|
||||
severity: 'critical'
|
||||
target_match:
|
||||
severity: 'warning'
|
||||
equal: ['alertname', 'instance']
|
||||
@@ -0,0 +1,39 @@
|
||||
logging {
|
||||
level = "info"
|
||||
}
|
||||
|
||||
// Promtail-compatible Docker log scraping via the local container engine.
|
||||
// Requires mounting /var/run/docker.sock and /var/lib/docker/containers.
|
||||
discovery.docker "containers" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
}
|
||||
|
||||
discovery.relabel "container_labels" {
|
||||
targets = discovery.docker.containers.targets
|
||||
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
target_label = "container"
|
||||
}
|
||||
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_log_stream"]
|
||||
target_label = "stream"
|
||||
}
|
||||
}
|
||||
|
||||
loki.source.docker "default" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
targets = discovery.relabel.container_labels.output
|
||||
forward_to = [loki.write.local.receiver]
|
||||
labels = {
|
||||
job = "docker",
|
||||
}
|
||||
}
|
||||
|
||||
loki.write "local" {
|
||||
endpoint {
|
||||
name = "loki"
|
||||
url = "http://loki:3100/loki/api/v1/push"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[server]
|
||||
# Allow embedding Manage in iframes
|
||||
root_url = %(protocol)s://%(domain)s:%(http_port)s/
|
||||
serve_from_sub_path = false
|
||||
|
||||
[security]
|
||||
allow_embedding = true
|
||||
cookie_samesite = lax
|
||||
|
||||
[auth]
|
||||
disable_login_form = false
|
||||
disable_signout_menu = false
|
||||
|
||||
[auth.generic_oauth]
|
||||
enabled = true
|
||||
name = Authentik
|
||||
allow_sign_up = true
|
||||
client_id = ${GF_AUTH_GENERIC_OAUTH_CLIENT_ID}
|
||||
client_secret = ${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}
|
||||
scopes = openid profile email
|
||||
auth_url = ${GF_AUTH_GENERIC_OAUTH_AUTH_URL}
|
||||
token_url = ${GF_AUTH_GENERIC_OAUTH_TOKEN_URL}
|
||||
api_url = ${GF_AUTH_GENERIC_OAUTH_API_URL}
|
||||
role_attribute_path = "contains(groups[*], 'grafana-admins') && 'Admin' || contains(groups[*], 'grafana-editors') && 'Editor' || 'Viewer'"
|
||||
|
||||
[users]
|
||||
auto_assign_org = true
|
||||
auto_assign_org_role = Viewer
|
||||
|
||||
[dataproxy]
|
||||
# Keep dashboard queries responsive in the iframe
|
||||
timeout = 30
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: "Manage dashboards"
|
||||
orgId: 1
|
||||
folder: "Manage"
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 60
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards-json/dashboards
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "sum(rate(manage_api_requests_total[5m])) by (method)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "API Request Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "sum(increase(manage_backup_runs_total[1h]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Backup Runs (1h)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "donut",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "sum by (status) (manage_backup_runs_total)",
|
||||
"legendFormat": "{{ status }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Backup Runs by Status",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${loki_datasource}"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false,
|
||||
"prettifyLogMessage": false,
|
||||
"enableLogDetails": true,
|
||||
"dedupStrategy": "none"
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${loki_datasource}"
|
||||
},
|
||||
"expr": "{container=\"/backend\"} |= \"\"",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Backend Logs",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["manage"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Metrics",
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Loki",
|
||||
"value": "Loki"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Logs",
|
||||
"multi": false,
|
||||
"name": "loki_datasource",
|
||||
"options": [],
|
||||
"query": "loki",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Manage Overview",
|
||||
"uid": "manage-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "100 - (avg by (instance) (irate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "{{ instance }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CPU Utilization",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))",
|
||||
"legendFormat": "{{ instance }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Memory Utilization",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
"value": 85
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 95
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "100 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"} * 100)",
|
||||
"legendFormat": "{{ instance }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Root Disk Usage",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "Bps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "irate(node_network_receive_bytes_total[5m])",
|
||||
"legendFormat": "rx {{ instance }} {{ device }}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"expr": "irate(node_network_transmit_bytes_total[5m])",
|
||||
"legendFormat": "tx {{ instance }} {{ device }}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Network Traffic",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["manage", "node-exporter"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Metrics",
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Node Exporter Overview",
|
||||
"uid": "node-exporter-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: 15s
|
||||
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: false
|
||||
jsonData:
|
||||
maxLines: 1000
|
||||
@@ -0,0 +1,51 @@
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
log_level: info
|
||||
|
||||
common:
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
query_range:
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
tsdb_shipper:
|
||||
active_index_directory: /loki/tsdb-index
|
||||
cache_location: /loki/tsdb-cache
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
retention_enabled: true
|
||||
retention_delete_delay: 2h
|
||||
retention_delete_worker_count: 150
|
||||
compaction_interval: 10m
|
||||
|
||||
limits_config:
|
||||
retention_period: 720h
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
@@ -0,0 +1,53 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: manage
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
- alertmanager:9093
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/rules/*.yml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets:
|
||||
- localhost:9090
|
||||
|
||||
- job_name: manage-backend
|
||||
static_configs:
|
||||
- targets:
|
||||
- backend:8000
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 15s
|
||||
|
||||
- job_name: node-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
- node-exporter:9100
|
||||
|
||||
- job_name: loki
|
||||
static_configs:
|
||||
- targets:
|
||||
- loki:3100
|
||||
|
||||
- job_name: alertmanager
|
||||
static_configs:
|
||||
- targets:
|
||||
- alertmanager:9093
|
||||
|
||||
- job_name: grafana
|
||||
static_configs:
|
||||
- targets:
|
||||
- grafana:3000
|
||||
|
||||
- job_name: node-exporter-remote
|
||||
file_sd_configs:
|
||||
- files:
|
||||
- /etc/prometheus/file-sd/node_exporter_targets.json
|
||||
refresh_interval: 30s
|
||||
@@ -0,0 +1,54 @@
|
||||
groups:
|
||||
- name: backup
|
||||
rules:
|
||||
- alert: BackupJobFailed
|
||||
expr: increase(manage_backup_runs_total{status="failed"}[1h]) > 0
|
||||
for: 0m
|
||||
labels:
|
||||
severity: critical
|
||||
category: backup
|
||||
annotations:
|
||||
summary: "Backup job {{ $labels.job_name }} failed"
|
||||
description: "At least one failed backup run was recorded for job {{ $labels.job_name }} within the last hour."
|
||||
|
||||
- alert: BackupJobStuck
|
||||
expr: time() - manage_backup_runs_last_success_timestamp > 86400
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
category: backup
|
||||
annotations:
|
||||
summary: "Backup job {{ $labels.job_name }} has not succeeded in 24h"
|
||||
description: "No successful backup run has been recorded for job {{ $labels.job_name }} in the last 24 hours."
|
||||
|
||||
- name: observability_health
|
||||
rules:
|
||||
- alert: PrometheusTargetMissing
|
||||
expr: up == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
category: observability
|
||||
annotations:
|
||||
summary: "Prometheus target {{ $labels.job }} / {{ $labels.instance }} is down"
|
||||
description: "The Prometheus scrape target for {{ $labels.job }} on {{ $labels.instance }} has been unreachable for more than 2 minutes."
|
||||
|
||||
- alert: AlertmanagerDown
|
||||
expr: up{job="alertmanager"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
category: observability
|
||||
annotations:
|
||||
summary: "Alertmanager is down"
|
||||
description: "Alertmanager has been unreachable for more than 2 minutes. Alerts may not be delivered."
|
||||
|
||||
- alert: GrafanaDown
|
||||
expr: up{job="grafana"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
category: observability
|
||||
annotations:
|
||||
summary: "Grafana is down"
|
||||
description: "Grafana has been unreachable for more than 2 minutes. Dashboards are unavailable."
|
||||
Reference in New Issue
Block a user