Compare commits
63 Commits
8afdd9c2bc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e6177d86 | |||
| 0866dc4136 | |||
| 11f093cd2c | |||
| 1bf8a34a97 | |||
| e0f66a51f7 | |||
| 3871f24724 | |||
| 17976eab80 | |||
| 4562a9dfca | |||
| 37533dd219 | |||
| fe90feb1b7 | |||
| 230b4b8533 | |||
| 03aece02b8 | |||
| a541a4fd16 | |||
| 70511d97f9 | |||
| a9488af0b4 | |||
| eac9b5d33d | |||
| 70f4e5b6e1 | |||
| 45c295457a | |||
| 7665ef4d10 | |||
| 54851779fb | |||
| e757f4ba21 | |||
| 0b039529f6 | |||
| e25240c2f3 | |||
| b8cb29e330 | |||
| ba01ad7c0c | |||
| 7e4222ef00 | |||
| b7019b33ac | |||
| 05a9faca3e | |||
| 39775a82ef | |||
| 7e53edfcc6 | |||
| b9a79b85d1 | |||
| 6d46de26c4 | |||
| 50c0c9b548 | |||
| b011d2421b | |||
| dad2202756 | |||
| 84dcf9e010 | |||
| ecabc65dd4 | |||
| 044d386ac7 | |||
| 9bc8fab971 | |||
| 29650ca512 | |||
| f921524d37 | |||
| 8d3c44d87f | |||
| 3bc7ce5269 | |||
| ad61d92b32 | |||
| dbc332d1b6 | |||
| 87f42b4ec3 | |||
| 5addc9dae9 | |||
| 6bcb60a74d | |||
| 98bf496a98 | |||
| f6c67bd3ff | |||
| 3391fbc85d | |||
| c4f68b4938 | |||
| 1fc3127b58 | |||
| ce5ee4f0a0 | |||
| a5ca1521fe | |||
| 9236fd8ac2 | |||
| cb8dd13514 | |||
| c886fcdf09 | |||
| 7e91e7f931 | |||
| df80c68f89 | |||
| 798196ffc7 | |||
| 872e95f8f7 | |||
| bf8de32815 |
@@ -17,6 +17,7 @@
|
|||||||
- Focused frontend typecheck: `npx tsc --noEmit`
|
- Focused frontend typecheck: `npx tsc --noEmit`
|
||||||
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
||||||
- Production stack: `docker compose up --build`
|
- Production stack: `docker compose up --build`
|
||||||
|
- Solo landing: after review and verification, squash-land a feature branch with `bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"`; do not commit directly on `main`.
|
||||||
|
|
||||||
## Repo-Specific Gotchas
|
## Repo-Specific Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed — HTTP read timeouts
|
||||||
|
|
||||||
|
- Service HTTP clients now use a `(connect, read)` timeout tuple (connect 5s,
|
||||||
|
read 60s default) instead of a single integer, resolving `ReadTimeoutError`
|
||||||
|
on slow Jellyfin index builds and qBittorrent stats. The media index build
|
||||||
|
worker uses a 180s read floor so slow `/Items` pages on large libraries
|
||||||
|
don't time out mid-build.
|
||||||
|
- The shared `http_timeout()` helper (`clients/http_timeout.py`) decouples
|
||||||
|
connect (fail-fast on dead hosts) from read (generous for slow responses).
|
||||||
|
- Integration `timeout_seconds` defaults were raised from 5/10s to 15/60s.
|
||||||
|
- Existing services with a low `timeout_seconds` may benefit from bumping it
|
||||||
|
to 60+ via the service editor.
|
||||||
|
|
||||||
|
### **BREAKING** — Prometheus queries now route through Grafana gateway
|
||||||
|
|
||||||
|
- The `prometheus` service config changed: `base_url` is replaced by
|
||||||
|
`grafana_url` + `datasource_uid`, and the `api_key` secret is replaced by
|
||||||
|
`grafana_api_key` (a Grafana service account token or API key with read
|
||||||
|
access to the Prometheus datasource). All metric widget queries (`chart`,
|
||||||
|
`gauge`, `mean`, `metric`) now issue `POST {grafana_url}/api/ds/query`
|
||||||
|
instead of direct Prometheus HTTP calls.
|
||||||
|
- **Migration:** Reconfigure existing `prometheus` services — replace
|
||||||
|
`base_url` with `grafana_url` (your Grafana instance URL), add the
|
||||||
|
`grafana_api_key` secret, and optionally set `datasource_uid` (defaults
|
||||||
|
to `"prometheus"`).
|
||||||
|
|
||||||
### Added — Direct Prometheus charting
|
### Added — Direct Prometheus charting
|
||||||
|
|
||||||
- **Prometheus is now the direct source for in-app charts.** New widget kinds
|
- **Prometheus is now the direct source for in-app charts.** New widget kinds
|
||||||
|
|||||||
@@ -1,62 +1,67 @@
|
|||||||
# Manage
|
# Manage
|
||||||
|
|
||||||
Manage is a media and server operations tool with Jellyfin integration, SSH file inspection, server monitoring, and safe remote job templates.
|
Manage is a media and server-operations application with Jellyfin integration, SSH file inspection, monitoring integrations, safe remote-job templates, a FastAPI backend, and a React single-page application.
|
||||||
|
|
||||||
See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history.
|
It includes a configurable dashboard, service registry, per-machine settings, a SQLite-indexed media library, a read-only Users view with optional Jellyseerr enrichment, remote file browsing with `ffprobe`, and SSH-based job execution.
|
||||||
See `docs/MIGRATION_PLAN.md` for the FastAPI + React architecture plan.
|
|
||||||
|
|
||||||
Project policy/docs:
|
## Architecture and scope
|
||||||
|
|
||||||
- License: `LICENSE` (MIT)
|
- `backend/` is the FastAPI API.
|
||||||
- Contributing guide: `CONTRIBUTING.md`
|
- `frontend/` is the React and TypeScript SPA.
|
||||||
|
- `archive/` retains the original Streamlit prototype for reference.
|
||||||
|
|
||||||
## Architecture
|
The root Compose files deploy **only** Manage's backend and frontend. Manage can expose `/metrics` and optional Alertmanager proxy endpoints, but it does not deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter as part of its normal stack. Configure service instances in the app's Services page.
|
||||||
|
|
||||||
The project consists of two subprojects:
|
## Prerequisites
|
||||||
|
|
||||||
- **`backend/`** — FastAPI Python API (see `backend/README.md`)
|
- Docker and Docker Compose for the supplied Compose stacks.
|
||||||
- **`frontend/`** — React + TypeScript SPA (see `frontend/README.md`)
|
- Python 3.11 or newer for manual backend development.
|
||||||
- **`archive/`** — Original Streamlit prototype (preserved for reference)
|
- Node.js and npm for manual frontend development.
|
||||||
|
- A valid Fernet key for `MANAGE_ENCRYPTION_KEY`, including in development Compose.
|
||||||
|
- For production: an existing external Docker network named `web`, Traefik, DNS/TLS configuration, and an OIDC provider.
|
||||||
|
|
||||||
## Features
|
## Local development with Compose
|
||||||
|
|
||||||
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts
|
1. Create `.env` from the template and set a valid `MANAGE_ENCRYPTION_KEY`. Docker Compose automatically reads `.env` for interpolation; alternatively, export the same variables in the shell.
|
||||||
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
|
||||||
- Service registry: configure Jellyfin, Jellyseerr, Alertmanager, Grafana, Prometheus, Nextcloud, and SSH task runner instances in the UI
|
|
||||||
- Per-machine settings for SSH, monitoring targets, and file browsing
|
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
|
||||||
- Remote file browser with ffprobe preview and job execution
|
|
||||||
- Jellyfin API integration for library metadata and user identity data
|
|
||||||
- SSH-based file inspection and safe remote job templates
|
|
||||||
|
|
||||||
## Quick Start
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
Generate a Fernet key if needed:
|
||||||
|
|
||||||
Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend. The compose files rely on environment-variable interpolation, so export the required values in your shell before running them (no `env_file` is needed):
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the development stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The development frontend is available at <http://localhost:5173> and the backend at <http://localhost:8000>. Development Compose sets `AUTH_ENABLED=false` and `VITE_OIDC_ENABLED=false`, but still requires `MANAGE_ENCRYPTION_KEY`. The backend cache, settings database, media index, saved SSH keys, tasks, and dashboard widgets persist outside rebuilt containers.
|
||||||
|
|
||||||
|
## Production-style deployment
|
||||||
|
|
||||||
|
The root [`docker-compose.yml`](docker-compose.yml) is designed for deployment behind Traefik; it does not publish localhost ports. Before starting it, configure `.env` (or shell variables) with the required values:
|
||||||
|
|
||||||
|
- `BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, and `CERT_RESOLVER` for Traefik routing and certificates.
|
||||||
|
- `OIDC_ISSUER_URL` and `OIDC_AUDIENCE` for backend authentication.
|
||||||
|
- `VITE_OIDC_ISSUER`, `VITE_OIDC_CLIENT_ID`, `VITE_OIDC_REDIRECT_URI`, and `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` for the frontend build.
|
||||||
|
- `MANAGE_ENCRYPTION_KEY`, a valid Fernet key used to encrypt service secrets at rest.
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at <http://localhost:8080>.
|
The production Compose file requires its external `web` network to exist. It is not a standalone local deployment; access is through the configured Traefik hostnames.
|
||||||
|
|
||||||
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
## Manual development
|
||||||
|
|
||||||
> **Observability is external.** Manage only ships its **backend** and **frontend**. It does **not** deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter. The backend exposes a `/metrics` endpoint and optional Alertmanager proxy endpoints so an *existing* observability deployment can scrape and consume them. For a ready-to-run example stack you can deploy alongside Manage, see [`docker-compose.observability.yml`](docker-compose.observability.yml) and [`docs/observability-runbooks.md`](docs/observability-runbooks.md).
|
### Backend
|
||||||
|
|
||||||
Local development with hot reload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
|
||||||
|
|
||||||
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
|
||||||
|
|
||||||
### Manual backend/frontend development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -66,126 +71,75 @@ pip install -e '.[dev]'
|
|||||||
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Tests and quality checks
|
||||||
|
|
||||||
The Compose files use environment-variable interpolation. Export the required variables in your shell or pass them inline; a `.env` file is optional, not required.
|
|
||||||
|
|
||||||
### Compose examples
|
|
||||||
|
|
||||||
Production-style example with shell exports:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export BACKEND_APP_HOST=api.manage.example.com
|
# Backend
|
||||||
export FRONTEND_APP_HOST=manage.example.com
|
cd backend
|
||||||
export CERT_RESOLVER=letsencrypt
|
ruff check .
|
||||||
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
python -m pytest
|
||||||
export VITE_OIDC_CLIENT_ID=manage
|
|
||||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|
||||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
|
||||||
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
|
||||||
|
|
||||||
docker compose up --build
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
> Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
A focused frontend typecheck can be run with `npx tsc --noEmit` from `frontend/`.
|
||||||
> the app on the **Services** page — no env vars for them.
|
|
||||||
|
|
||||||
Inline one-liner example:
|
## Configuration and operations
|
||||||
|
|
||||||
```bash
|
[`.env.example`](.env.example) is a template; do not commit real credentials or encryption keys. The Compose files interpolate environment values directly. Some template entries are for the optional observability example and are not consumed by the normal Manage Compose stack.
|
||||||
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
Optional SMTP settings (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`, `SMTP_USE_SSL`, and `SMTP_TIMEOUT`) support the Users message popup.
|
||||||
|
|
||||||
```bash
|
### Remote servers
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Example environment variables:
|
A managed remote server needs a POSIX `/bin/sh`, `python3`, `ffprobe`, `find`, `stat`, `df`, and `awk`. Configure its SSH credentials in Manage's Settings. Unknown SSH host keys are rejected; establish trust first, for example:
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optional backend logging level
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
||||||
# Optional SMTP settings for the Users -> message popup
|
|
||||||
SMTP_HOST=smtp.example.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your-smtp-username
|
|
||||||
SMTP_PASSWORD=your-smtp-password
|
|
||||||
SMTP_FROM_ADDRESS=no-reply@example.com
|
|
||||||
SMTP_FROM_NAME=Manage
|
|
||||||
SMTP_USE_TLS=true
|
|
||||||
SMTP_USE_SSL=false
|
|
||||||
SMTP_TIMEOUT=30
|
|
||||||
|
|
||||||
# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab.
|
|
||||||
# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required.
|
|
||||||
#
|
|
||||||
# Remote SSH machines can store their private key and optional passphrase directly in Settings,
|
|
||||||
# so no SSH key mount is required for normal use.
|
|
||||||
|
|
||||||
# Authentik / OIDC
|
|
||||||
AUTH_ENABLED=true
|
|
||||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
|
||||||
OIDC_AUDIENCE=manage
|
|
||||||
OIDC_JWKS_URL=
|
|
||||||
OIDC_CLOCK_SKEW_SECONDS=30
|
|
||||||
|
|
||||||
# Frontend OIDC settings
|
|
||||||
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/
|
|
||||||
|
|
||||||
# Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
|
||||||
# the app on the Services page. The only observability env var is the optional
|
|
||||||
# PROMETHEUS_ENABLED toggle (defaults on) for Manage's own /metrics endpoint.
|
|
||||||
|
|
||||||
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
|
||||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
||||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Remote server requirements
|
|
||||||
|
|
||||||
The remote server needs:
|
|
||||||
|
|
||||||
- `/bin/sh` (POSIX shell)
|
|
||||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
|
||||||
- SSH access with a key configured in the app's Settings tab
|
|
||||||
|
|
||||||
The SSH client rejects unknown host keys. Connect manually once first:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh user@host
|
ssh user@host
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
SSH commands run through `/bin/sh -c` regardless of the remote login shell.
|
||||||
|
|
||||||
|
### Optional observability example
|
||||||
|
|
||||||
|
[`docker-compose.observability.yml`](docker-compose.observability.yml) is a separate, optional stack for Grafana, Prometheus, Loki, Alertmanager, Alloy, and Node Exporter. It is not required by Manage. Its header documents required `*_ROOT` persistence directories, `CERT_RESOLVER`, and Grafana/Prometheus/Alertmanager host variables. With those prepared, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (lint + tests)
|
docker compose -f docker-compose.observability.yml up -d
|
||||||
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
|
|
||||||
|
|
||||||
# Frontend (lint + typecheck/build + tests)
|
|
||||||
cd frontend && npm run lint && npm run build && npm run test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused frontend typecheck: `npx tsc --noEmit`.
|
Set a non-default `GRAFANA_ADMIN_USER` and a strong, secret `GRAFANA_ADMIN_PASSWORD` before deploying this stack. Do not expose the example observability services with their defaults.
|
||||||
|
|
||||||
## Notes
|
See [`docs/observability-runbooks.md`](docs/observability-runbooks.md) for its operational documentation.
|
||||||
|
|
||||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
## Repository layout
|
||||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
|
||||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
```text
|
||||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. They deploy **only** the backend and frontend; Manage never deploys its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
.
|
||||||
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
├── backend/ # FastAPI API and tests
|
||||||
- Grafana, Prometheus, and Alertmanager are configured as **service instances** in the app (Services page); their widget adapters resolve URLs from service records, and no observability URLs/credentials live in env vars. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`. When no alertmanager service is configured, the alert proxy endpoints return graceful "not configured" responses.
|
├── frontend/ # React/TypeScript SPA and tests
|
||||||
|
├── archive/ # Preserved Streamlit prototype
|
||||||
|
├── docs/ # Requirements, migration, and operations docs
|
||||||
|
├── docker-compose.yml # Traefik-backed production-style stack
|
||||||
|
├── docker-compose.dev.yml # Local hot-reload development stack
|
||||||
|
└── docker-compose.observability.yml # Optional standalone observability example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project documents
|
||||||
|
|
||||||
|
- [Requirements and planning history](docs/REQUIREMENTS.md)
|
||||||
|
- [FastAPI + React migration plan](docs/MIGRATION_PLAN.md)
|
||||||
|
- [Contributing guide](CONTRIBUTING.md)
|
||||||
|
- [MIT license](LICENSE)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api
|
dir: backend/src/media_library_viewer_api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
FastAPI backend providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/.pi-map.index.md
|
index: backend/src/.pi-map.index.md
|
||||||
map: backend/src/.pi-map.md
|
map: backend/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api
|
|||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
FastAPI backend providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||||
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
|
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
|
||||||
- dependencies.py | Provides FastAPI dependency injection functions for resolving and instantiating Jellyfin/Jellyseerr API clients and machine-specific SSH/Local command clients. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
- dependencies.py | Provides FastAPI dependency injection functions that resolve and instantiate service clients like Jellyfin and SSH based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str(service.get("config", {}).get("user_id") or "").strip, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_resolved_user_id, raise:HTTPException, func:_resolved_user_id(cache_key: tuple[str, str, str, str]) → str, call:_jellyfin_client_for, call:client.resolve_user_id | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||||
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
||||||
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
||||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_service_data_harness, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.service_data, FastAPI, media_library_viewer_api.services.backup_poller
|
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health, version, and metrics endpoints. | exp: func:_validate_prometheus_gateway_config() → None, call:get_settings_store, call:store.list_services, call:service.get, call:logger.warning, call:logger.exception, func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_service_data_harness, call:_validate_prometheus_gateway_config, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.service_data, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||||
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
||||||
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
|
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
|
||||||
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||||
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||||
## arch
|
## arch
|
||||||
Layered FastAPI architecture using dependency injection for client resolution, middleware-based OIDC/API-key authentication, pydantic-settings configuration, and Prometheus-based observability with structured logging.
|
Layered FastAPI architecture using dependency injection, Pydantic settings, middleware-based auth (OIDC/JWT/API key), and modular utilities for configuration, logging, metrics, and path mapping.
|
||||||
## tags
|
## tags
|
||||||
call:, settings, call:get, request, get, call:str, id, client
|
call:, settings, call:get, request, id, get, call:str, client
|
||||||
## symbols
|
## symbols
|
||||||
- Settings
|
- Settings
|
||||||
- JobTemplate
|
- JobTemplate
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/clients
|
dir: backend/src/media_library_viewer_api/clients
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
|
Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
@@ -11,6 +11,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
|||||||
## files
|
## files
|
||||||
- __init__.py
|
- __init__.py
|
||||||
- authentik.py
|
- authentik.py
|
||||||
|
- http_timeout.py
|
||||||
- jellyfin.py
|
- jellyfin.py
|
||||||
- jellyseerr.py
|
- jellyseerr.py
|
||||||
- local.py
|
- local.py
|
||||||
@@ -21,6 +22,6 @@ index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
|||||||
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||||
## workflows
|
## workflows
|
||||||
- change clients behavior
|
- change clients behavior
|
||||||
read: __init__.py, authentik.py, jellyfin.py
|
read: __init__.py, authentik.py, http_timeout.py
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ dir: backend/src/media_library_viewer_api/clients
|
|||||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
|
Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
- authentik.py | Provides a client wrapper around the Authentik REST API for browsing and searching the user directory with pagination. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests
|
- authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||||
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
|
- http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
|
||||||
- jellyseerr.py | HTTP client wrapper for the Jellyseerr REST API to fetch user data and enrich Jellyfin user information | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests
|
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||||
|
- jellyseerr.py | HTTP API client for Jellyseerr that fetches and enriches Jellyfin user and request metadata. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:_resolve_title(self, media_type: Any, tmdb_id: Any) → str, call:str, call:self.get, call:data.get, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:mapped.append, call:_label, call:(media or {}).get, method:open_requests(self, max_per_filter) → list[dict[str, Any]], call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:results.append, call:_label, call:(media or {}).get, call:len, call:results.sort, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||||
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||||
- qbittorrent.py | Provides a minimal read-only client for the qBittorrent Web API to fetch sync/maindata using authenticated requests. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:logger.info, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests
|
- qbittorrent.py | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||||
## arch
|
## arch
|
||||||
Adapter/gateway pattern where each client encapsulates external API or protocol communication behind a uniform interface, isolating transport-level concerns (REST, SSH, local shell) from business logic.
|
Adapter/wrapper pattern around `requests` HTTP and SSH/paramiko protocols, with each client encapsulating authentication, data fetching, and response normalization into plain Python dictionaries.
|
||||||
## tags
|
## tags
|
||||||
call:logger.info, error, call:logger.debug, client, call:self.get, init, call:shlex.quote, status
|
call:logger.info, call:self.get, call:self., error, call:logger.debug, call:logger.warning, call:isinstance, client
|
||||||
## symbols
|
## symbols
|
||||||
- AuthentikClient
|
- AuthentikClient
|
||||||
- JellyfinClient
|
- JellyfinClient
|
||||||
@@ -28,6 +29,6 @@ call:logger.info, error, call:logger.debug, client, call:self.get, init, call:sh
|
|||||||
- __init__
|
- __init__
|
||||||
## workflows
|
## workflows
|
||||||
- change clients behavior
|
- change clients behavior
|
||||||
read: __init__.py, authentik.py, jellyfin.py
|
read: __init__.py, authentik.py, http_timeout.py
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Authentik directory API client.
|
"""Read-only Authentik directory client.
|
||||||
|
|
||||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
The client normalizes the subset of Authentik core data that Manage displays.
|
||||||
page). This client wraps the Authentik REST API for browsing the user directory
|
It deliberately does not fetch individual users or expose policy/provider data.
|
||||||
with pagination and search. OIDC authentication is unchanged — this client is
|
|
||||||
for the directory, not SSO.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,13 +11,40 @@ from typing import Any
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_COLLECTION_ITEMS = 10_000
|
||||||
|
_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value).strip() if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier(item: dict[str, Any]) -> str:
|
||||||
|
for key in ("pk", "id", "uuid"):
|
||||||
|
value = _text(item.get(key))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _page_total(payload: dict[str, Any], fallback: int) -> int:
|
||||||
|
pagination = payload.get("pagination")
|
||||||
|
if isinstance(pagination, dict):
|
||||||
|
try:
|
||||||
|
return max(0, int(pagination.get("count") or fallback))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class AuthentikClient:
|
class AuthentikClient:
|
||||||
"""Small wrapper around the Authentik core directory API."""
|
"""Small wrapper around Authentik's read-only core API."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
|
def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("Authentik base_url is required")
|
raise ValueError("Authentik base_url is required")
|
||||||
if not api_token:
|
if not api_token:
|
||||||
@@ -29,87 +54,139 @@ class AuthentikClient:
|
|||||||
if self.base_url.endswith("/api/v3"):
|
if self.base_url.endswith("/api/v3"):
|
||||||
self.base_url = self.base_url[:-7]
|
self.base_url = self.base_url[:-7]
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
self.timeout = timeout
|
self.timeout = http_timeout(timeout)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update(
|
self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"})
|
||||||
{
|
|
||||||
"Authorization": f"Bearer {api_token}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, path: str, **params: Any) -> Any:
|
def get(self, path: str, **params: Any) -> Any:
|
||||||
"""GET an Authentik endpoint and include useful response text on errors."""
|
"""GET an Authentik 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 != ""}
|
clean_params = {key: value for key, value in params.items() if value is not None and value != ""}
|
||||||
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
||||||
response = self.session.get(
|
response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout)
|
||||||
f"{self.base_url}/api/v3{path}",
|
|
||||||
params=clean_params,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.HTTPError as exc:
|
except requests.HTTPError as exc:
|
||||||
detail = response.text[:500]
|
detail = response.text[:500]
|
||||||
logger.warning(
|
logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url)
|
||||||
"Authentik GET %s failed status=%s url=%s",
|
raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc
|
||||||
path,
|
|
||||||
response.status_code,
|
|
||||||
response.url,
|
|
||||||
)
|
|
||||||
raise requests.HTTPError(
|
|
||||||
f"{response.status_code} for {response.url}: {detail}",
|
|
||||||
response=response,
|
|
||||||
) from exc
|
|
||||||
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
def users(
|
def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]:
|
||||||
|
"""Return one raw user page for the directory and messaging surfaces."""
|
||||||
|
payload = self.get("/core/users/", search=search, page=page, page_size=page_size)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
results = payload.get("results")
|
||||||
|
items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||||
|
return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||||
|
"""Read a paginated core collection with a hard cap and loop protection."""
|
||||||
|
try:
|
||||||
|
requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
requested = _MAX_COLLECTION_ITEMS
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
total = 0
|
||||||
|
while len(items) < requested:
|
||||||
|
payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items)))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__)
|
||||||
|
break
|
||||||
|
results = payload.get("results")
|
||||||
|
page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||||
|
total = _page_total(payload, len(items) + len(page_items))
|
||||||
|
items.extend(page_items[: requested - len(items)])
|
||||||
|
if not page_items or len(items) >= total:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
if page > 100: # defensive limit for malformed pagination responses
|
||||||
|
logger.warning("Authentik %s pagination stopped after 100 pages", path)
|
||||||
|
break
|
||||||
|
return {"items": items, "total": total or len(items)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None:
|
||||||
|
group_id = _identifier(item)
|
||||||
|
if not group_id:
|
||||||
|
return None
|
||||||
|
name = _text(item.get("name") or item.get("display_name") or item.get("slug"))
|
||||||
|
return {"id": group_id, "name": name or f"Unnamed group ({group_id})"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_application(item: dict[str, Any]) -> dict[str, str]:
|
||||||
|
app_id = _identifier(item)
|
||||||
|
return {
|
||||||
|
"id": app_id,
|
||||||
|
"name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application",
|
||||||
|
"slug": _text(item.get("slug")),
|
||||||
|
"launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||||
|
"""Return normalized groups; only display-safe identifiers and names are retained."""
|
||||||
|
raw = self._collection("/core/groups/", limit)
|
||||||
|
items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None]
|
||||||
|
return {"items": items, "total": raw["total"]}
|
||||||
|
|
||||||
|
def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||||
|
"""Return normalized applications without provider, policy, or secret fields."""
|
||||||
|
raw = self._collection("/core/applications/", limit)
|
||||||
|
return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _group_references(user: dict[str, Any]) -> list[str]:
|
||||||
|
"""Extract group ids from release-dependent user reference shapes."""
|
||||||
|
raw = user.get("groups", user.get("group", []))
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raw = [raw] if raw is not None else []
|
||||||
|
ids: list[str] = []
|
||||||
|
for reference in raw:
|
||||||
|
if isinstance(reference, dict):
|
||||||
|
group_id = _identifier(reference)
|
||||||
|
else:
|
||||||
|
group_id = _text(reference)
|
||||||
|
if group_id and group_id not in ids:
|
||||||
|
ids.append(group_id)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def access_summaries(
|
||||||
self,
|
self,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return a normalized page of Authentik users.
|
"""Summarize user group references and privileged flags without N+1 user reads.
|
||||||
|
|
||||||
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
|
This is directory metadata only: group membership plus the explicit
|
||||||
Authentik response into ``{items, total, page, page_size}``. Each item
|
``is_superuser`` and ``is_staff`` fields. It does not evaluate policies
|
||||||
is the raw Authentik user dict (pk, username, name, email, avatar, …)
|
or claim to calculate effective authorization.
|
||||||
so the frontend can pick the fields it needs.
|
|
||||||
"""
|
"""
|
||||||
payload = self.get(
|
users = self.users(search=search, page=page, page_size=page_size)
|
||||||
"/core/users/",
|
groups = self.groups()
|
||||||
search=search,
|
group_names = {group["id"]: group["name"] for group in groups["items"]}
|
||||||
page=page,
|
summaries: list[dict[str, Any]] = []
|
||||||
page_size=page_size,
|
for user in users["items"]:
|
||||||
)
|
group_ids = self._group_references(user)
|
||||||
if not isinstance(payload, dict):
|
summaries.append(
|
||||||
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
{
|
||||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
"id": _identifier(user),
|
||||||
|
"username": _text(user.get("username")),
|
||||||
results = payload.get("results")
|
"name": _text(user.get("name")),
|
||||||
items: list[dict[str, Any]] = (
|
"email": _text(user.get("email")),
|
||||||
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
"is_active": bool(user.get("is_active", True)),
|
||||||
)
|
"is_superuser": bool(user.get("is_superuser", False)),
|
||||||
|
"is_staff": bool(user.get("is_staff", False)),
|
||||||
pagination = payload.get("pagination") or {}
|
"groups": [
|
||||||
total = 0
|
{
|
||||||
if isinstance(pagination, dict):
|
"id": group_id,
|
||||||
try:
|
"name": group_names.get(group_id, f"Unknown group ({group_id})"),
|
||||||
total = int(pagination.get("count") or 0)
|
"known": group_id in group_names,
|
||||||
except (TypeError, ValueError):
|
}
|
||||||
total = 0
|
for group_id in group_ids
|
||||||
|
],
|
||||||
logger.info(
|
}
|
||||||
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
|
)
|
||||||
page,
|
return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
|
||||||
page_size,
|
|
||||||
len(items),
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Shared HTTP timeout helpers.
|
||||||
|
|
||||||
|
``requests`` accepts a single integer timeout and applies it to BOTH the
|
||||||
|
connect and read phases. For slow upstream services (large Jellyfin
|
||||||
|
libraries, qBittorrent with many torrents), the read phase needs a much
|
||||||
|
larger budget than connect. These helpers produce ``(connect, read)`` tuples
|
||||||
|
so the two phases are decoupled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Short connect timeout — fail fast on unreachable/dead hosts.
|
||||||
|
DEFAULT_CONNECT_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
#: Generous read timeout — let slow responses complete.
|
||||||
|
DEFAULT_READ_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def http_timeout(
|
||||||
|
read_timeout: float | int | None = None,
|
||||||
|
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Build a ``(connect, read)`` timeout tuple for ``requests``.
|
||||||
|
|
||||||
|
``read_timeout`` is the per-response read budget (seconds). When omitted
|
||||||
|
or non-positive, :data:`DEFAULT_READ_TIMEOUT` applies.
|
||||||
|
"""
|
||||||
|
effective_read = DEFAULT_READ_TIMEOUT
|
||||||
|
if read_timeout is not None:
|
||||||
|
try:
|
||||||
|
parsed = float(read_timeout)
|
||||||
|
if parsed > 0:
|
||||||
|
effective_read = parsed
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass # fall back to default on non-numeric input
|
||||||
|
return (connect_timeout, effective_read)
|
||||||
@@ -12,6 +12,8 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ DEFAULT_FIELDS = ",".join(
|
|||||||
class JellyfinClient:
|
class JellyfinClient:
|
||||||
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("Jellyfin URL is required")
|
raise ValueError("Jellyfin URL is required")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -47,7 +49,8 @@ class JellyfinClient:
|
|||||||
if self.base_url.endswith("/web"):
|
if self.base_url.endswith("/web"):
|
||||||
self.base_url = self.base_url[:-4]
|
self.base_url = self.base_url[:-4]
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||||
|
self.timeout = http_timeout(timeout)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update(
|
self.session.headers.update(
|
||||||
{
|
{
|
||||||
@@ -87,6 +90,32 @@ class JellyfinClient:
|
|||||||
logger.info("Jellyfin returned %s visible users", len(users))
|
logger.info("Jellyfin returned %s visible users", len(users))
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
def resolve_user_id(self, identifier: str | None) -> str:
|
||||||
|
"""Resolve a configured user identifier to Jellyfin's internal Id.
|
||||||
|
|
||||||
|
The service ``user_id`` config field accepts either the internal Jellyfin
|
||||||
|
Id (a hash) or a username (e.g. ``'admin'``). Jellyfin's
|
||||||
|
``/Users/{id}/...`` endpoints reject usernames with HTTP 400
|
||||||
|
(``"The value 'admin' is not valid."``), so any caller must resolve
|
||||||
|
usernames to the real Id before hitting user-scoped endpoints.
|
||||||
|
|
||||||
|
Resolution order: exact ``Id`` match → ``Name`` match → first visible
|
||||||
|
user. Raises if the API key cannot see any users.
|
||||||
|
"""
|
||||||
|
users = self.users()
|
||||||
|
if not users:
|
||||||
|
raise RuntimeError("No Jellyfin users visible to this API key")
|
||||||
|
if identifier:
|
||||||
|
if any(str(u.get("Id")) == identifier for u in users):
|
||||||
|
return identifier
|
||||||
|
match = next((u for u in users if str(u.get("Name", "")) == identifier), None)
|
||||||
|
if match:
|
||||||
|
resolved = str(match["Id"])
|
||||||
|
logger.info("Resolved Jellyfin username %r to Id %s", identifier, resolved)
|
||||||
|
return resolved
|
||||||
|
logger.warning("Jellyfin user identifier %r not found; using first user", identifier)
|
||||||
|
return str(users[0]["Id"])
|
||||||
|
|
||||||
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
"""Return top-level library views visible to the selected Jellyfin user."""
|
"""Return top-level library views visible to the selected Jellyfin user."""
|
||||||
items = self.get(f"/Users/{user_id}/Views").get("Items", [])
|
items = self.get(f"/Users/{user_id}/Views").get("Items", [])
|
||||||
|
|||||||
@@ -11,13 +11,33 @@ from typing import Any
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Jellyseerr numeric status enums (see Overseerr/Jellyseerr source).
|
||||||
|
_REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"}
|
||||||
|
_MEDIA_STATUS: dict[int, str] = {
|
||||||
|
1: "unknown",
|
||||||
|
2: "pending",
|
||||||
|
3: "processing",
|
||||||
|
4: "partially_available",
|
||||||
|
5: "available",
|
||||||
|
}
|
||||||
|
_REQUEST_TYPE: dict[int, str] = {1: "movie", 2: "tv"}
|
||||||
|
|
||||||
|
|
||||||
|
def _label(value: Any, table: dict[int, str]) -> str:
|
||||||
|
try:
|
||||||
|
return table.get(int(value), str(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value) if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
class JellyseerrClient:
|
class JellyseerrClient:
|
||||||
"""Small wrapper around the Jellyseerr REST API."""
|
"""Small wrapper around the Jellyseerr REST API."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("Jellyseerr URL is required")
|
raise ValueError("Jellyseerr URL is required")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -27,7 +47,8 @@ class JellyseerrClient:
|
|||||||
if self.base_url.endswith("/api/v1"):
|
if self.base_url.endswith("/api/v1"):
|
||||||
self.base_url = self.base_url[:-7]
|
self.base_url = self.base_url[:-7]
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||||
|
self.timeout = http_timeout(timeout)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update(
|
self.session.headers.update(
|
||||||
{
|
{
|
||||||
@@ -35,6 +56,7 @@ class JellyseerrClient:
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
self._title_cache: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
def get(self, path: str, **params: Any) -> Any:
|
def get(self, path: str, **params: Any) -> Any:
|
||||||
"""GET a Jellyseerr endpoint and include useful response text on errors."""
|
"""GET a Jellyseerr endpoint and include useful response text on errors."""
|
||||||
@@ -63,6 +85,26 @@ class JellyseerrClient:
|
|||||||
path = f"/{path}"
|
path = f"/{path}"
|
||||||
return f"{self.base_url}{path}"
|
return f"{self.base_url}{path}"
|
||||||
|
|
||||||
|
def _resolve_title(self, media_type: Any, tmdb_id: Any) -> str:
|
||||||
|
"""Resolve a media title via /movie/{tmdbId} or /tv/{tmdbId}, cached.
|
||||||
|
|
||||||
|
Jellyseerr's /request list doesn't include titles; they live on the
|
||||||
|
Movie/Series records. Cached per (type, tmdbId) so repeated polls reuse.
|
||||||
|
"""
|
||||||
|
if not tmdb_id:
|
||||||
|
return ""
|
||||||
|
key = (str(media_type or ""), str(tmdb_id))
|
||||||
|
if key in self._title_cache:
|
||||||
|
return self._title_cache[key]
|
||||||
|
try:
|
||||||
|
is_tv = str(media_type) in ("2", "tv")
|
||||||
|
data = self.get(f"/{'tv' if is_tv else 'movie'}/{tmdb_id}")
|
||||||
|
title = str(data.get("name" if is_tv else "title") or "")
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
self._title_cache[key] = title
|
||||||
|
return title
|
||||||
|
|
||||||
def jellyfin_users(self) -> list[dict[str, Any]]:
|
def jellyfin_users(self) -> list[dict[str, Any]]:
|
||||||
"""Return Jellyfin-linked users known to Jellyseerr.
|
"""Return Jellyfin-linked users known to Jellyseerr.
|
||||||
|
|
||||||
@@ -133,3 +175,102 @@ class JellyseerrClient:
|
|||||||
|
|
||||||
logger.info("Jellyseerr returned %s users", len(results))
|
logger.info("Jellyseerr returned %s users", len(results))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def request_count(self) -> dict[str, int]:
|
||||||
|
"""Return normalized request counts from /api/v1/request/count.
|
||||||
|
|
||||||
|
Jellyseerr reports pending/approved/declined/processing/available/total.
|
||||||
|
Missing keys default to 0 so callers can rely on a stable shape.
|
||||||
|
"""
|
||||||
|
payload = self.get("/request/count")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
keys = ("total", "pending", "approved", "declined", "processing", "available")
|
||||||
|
counts = {k: int(payload.get(k) or 0) for k in keys}
|
||||||
|
logger.info(
|
||||||
|
"Jellyseerr request counts total=%s pending=%s processing=%s",
|
||||||
|
counts["total"],
|
||||||
|
counts["pending"],
|
||||||
|
counts["processing"],
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def recent_requests(self, take: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Return the most recently modified requests with resolved titles."""
|
||||||
|
take = max(1, min(int(take), 100))
|
||||||
|
payload = self.get("/request", sort="modified", skip=0, take=take)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
results = payload.get("results") or []
|
||||||
|
items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
|
||||||
|
mapped: list[dict[str, Any]] = []
|
||||||
|
for r in items:
|
||||||
|
media = r.get("media") or {}
|
||||||
|
tmdb_id = media.get("tmdbId")
|
||||||
|
name = r.get("title") or media.get("title") or media.get("name") or ""
|
||||||
|
if not name and tmdb_id:
|
||||||
|
name = self._resolve_title(r.get("type"), tmdb_id)
|
||||||
|
if not name:
|
||||||
|
name = media.get("externalServiceSlug") or ""
|
||||||
|
mapped.append(
|
||||||
|
{
|
||||||
|
"id": r.get("id"),
|
||||||
|
"type": _label(r.get("type"), _REQUEST_TYPE),
|
||||||
|
"name": name or "—",
|
||||||
|
"status": _label(r.get("status"), _REQUEST_STATUS),
|
||||||
|
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
|
||||||
|
"created_at": r.get("createdAt"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
def open_requests(self, max_per_filter: int = 100) -> list[dict[str, Any]]:
|
||||||
|
"""Return open (pending + approved) requests with resolved titles.
|
||||||
|
|
||||||
|
Fetches pending and approved requests via Jellyseerr's filter param
|
||||||
|
(not all 800+ historical requests), then resolves titles from
|
||||||
|
/movie/{tmdbId} or /tv/{tmdbId}. Titles are cached on the client so
|
||||||
|
subsequent polls are instant.
|
||||||
|
"""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
take = 50
|
||||||
|
for filter_val in ("pending", "approved"):
|
||||||
|
skip = 0
|
||||||
|
while skip < max_per_filter:
|
||||||
|
payload = self.get("/request", filter=filter_val, sort="added", skip=skip, take=take)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
break
|
||||||
|
page = payload.get("results") or []
|
||||||
|
items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else []
|
||||||
|
for r in items:
|
||||||
|
media = r.get("media") or {}
|
||||||
|
tmdb_id = media.get("tmdbId") or r.get("tmdbId")
|
||||||
|
# Diagnostic: log the first request's shape once so we can verify tmdbId.
|
||||||
|
if not results and filter_val == "pending":
|
||||||
|
logger.info(
|
||||||
|
"Jellyseerr request sample: keys=%s media_keys=%s tmdbId=%s",
|
||||||
|
sorted(r.keys()),
|
||||||
|
sorted(media.keys()) if isinstance(media, dict) else "N/A",
|
||||||
|
tmdb_id,
|
||||||
|
)
|
||||||
|
name = r.get("title") or media.get("title") or media.get("name") or ""
|
||||||
|
if not name and tmdb_id:
|
||||||
|
name = self._resolve_title(r.get("type"), tmdb_id)
|
||||||
|
if not name:
|
||||||
|
name = media.get("externalServiceSlug") or ""
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": r.get("id"),
|
||||||
|
"type": _label(r.get("type"), _REQUEST_TYPE),
|
||||||
|
"name": name or "—",
|
||||||
|
"status": _label(r.get("status"), _REQUEST_STATUS),
|
||||||
|
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
|
||||||
|
"created_at": r.get("createdAt"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) < take:
|
||||||
|
break
|
||||||
|
skip += len(items)
|
||||||
|
results.sort(key=lambda r: r.get("created_at") or 0, reverse=True)
|
||||||
|
logger.info("Jellyseerr returned %s open requests (with titles)", len(results))
|
||||||
|
return results
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ SID cookie in the requests session. The client re-logins transparently on 403.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# qBittorrent's built-in web server is effectively single-threaded; collapse
|
||||||
|
# concurrent widget polls onto one fetch and back off when it struggles.
|
||||||
|
MAINDATA_CACHE_TTL = 3.0 # seconds a snapshot is served without re-hitting qBittorrent
|
||||||
|
MAINDATA_BACKOFF_MAX = 30.0 # cap exponential backoff after repeated failures
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentClient:
|
class QbittorrentClient:
|
||||||
"""Small wrapper around the qBittorrent Web API.
|
"""Small wrapper around the qBittorrent Web API.
|
||||||
@@ -23,7 +32,7 @@ class QbittorrentClient:
|
|||||||
:class:`requests.Session` that carries the login cookie.
|
:class:`requests.Session` that carries the login cookie.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
|
def __init__(self, base_url: str, username: str, password: str, timeout: float = DEFAULT_READ_TIMEOUT) -> None:
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("qBittorrent base_url is required")
|
raise ValueError("qBittorrent base_url is required")
|
||||||
if not username:
|
if not username:
|
||||||
@@ -34,15 +43,42 @@ class QbittorrentClient:
|
|||||||
self.base_url += "/api/v2"
|
self.base_url += "/api/v2"
|
||||||
self._username = username
|
self._username = username
|
||||||
self._password = password
|
self._password = password
|
||||||
self.timeout = timeout
|
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||||
|
self.timeout = http_timeout(timeout)
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._logged_in = False
|
self._logged_in = False
|
||||||
|
# /sync/maindata is the only hot endpoint. Maintain a rid-merged
|
||||||
|
# snapshot (incremental updates -> small payloads), a short-TTL cache
|
||||||
|
# + lock so concurrent widgets share one fetch, and back off when
|
||||||
|
# qBittorrent is struggling rather than piling on (its web server is
|
||||||
|
# single-threaded and otherwise hangs the Web UI for everyone).
|
||||||
|
self._rid: int | None = None
|
||||||
|
self._snapshot: dict[str, Any] = {
|
||||||
|
"server_state": {},
|
||||||
|
"torrents": {},
|
||||||
|
"categories": {},
|
||||||
|
"tags": [],
|
||||||
|
"trackers": [],
|
||||||
|
}
|
||||||
|
self._maindata_lock = threading.Lock()
|
||||||
|
self._maindata_fetched_at: float = 0.0
|
||||||
|
self._maindata_ttl: float = MAINDATA_CACHE_TTL
|
||||||
|
self._backoff_until: float = 0.0
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
|
||||||
def _login(self) -> None:
|
def _login(self) -> None:
|
||||||
"""POST username/password to ``/auth/login``; store the SID cookie.
|
"""POST username/password to ``/auth/login``; store the SID cookie.
|
||||||
|
|
||||||
qBittorrent returns the plain text ``"Ok."`` on success. The
|
qBittorrent replies with the plain text ``"Ok."`` and a ``SID`` cookie
|
||||||
``Referer`` header is required by some qBittorrent CSRF protections.
|
on success, ``"Fails."`` on bad credentials, and ``403 Forbidden`` when
|
||||||
|
the source IP is banned (too many failed attempts). The ``Referer``
|
||||||
|
header is required by qBittorrent's CSRF protection.
|
||||||
|
|
||||||
|
Any other body — in particular an *empty* 200 — means the request did not
|
||||||
|
reach qBittorrent's login handler, almost always because ``base_url`` is
|
||||||
|
wrong (wrong host/port/path) or a reverse proxy is misrouting
|
||||||
|
``/api/v2/auth/login``. We surface a diagnostic error in that case
|
||||||
|
instead of the useless ``"login failed: "`` message.
|
||||||
"""
|
"""
|
||||||
resp = self._session.post(
|
resp = self._session.post(
|
||||||
f"{self.base_url}/auth/login",
|
f"{self.base_url}/auth/login",
|
||||||
@@ -50,11 +86,49 @@ class QbittorrentClient:
|
|||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
|
# 502/503/504 come from the reverse proxy when qBittorrent is down,
|
||||||
|
# starting up, or can't answer within the proxy's forwarding timeout
|
||||||
|
# (qBittorrent's PBKDF2 password check is intentionally slow, so a
|
||||||
|
# flood of concurrent logins can trip this). Surface it clearly rather
|
||||||
|
# than as a bare HTTPError.
|
||||||
|
if resp.status_code in (502, 503, 504):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"qBittorrent is unreachable: reverse proxy returned HTTP {resp.status_code} "
|
||||||
|
f"for {resp.url}. qBittorrent may be down, starting up, or unable to "
|
||||||
|
"answer within the proxy's forwarding timeout."
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
if resp.text.strip() != "Ok.":
|
body = resp.text.strip()
|
||||||
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
|
|
||||||
self._logged_in = True
|
# qBittorrent signals a successful login with the body "Ok." and/or by
|
||||||
logger.info("qBittorrent login successful for %s", self.base_url)
|
# setting a session cookie. The cookie is named "SID" in older versions
|
||||||
|
# and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
|
||||||
|
# No Content with the cookie and no body, and ``requests`` doesn't always
|
||||||
|
# populate the cookie jar, so check both the jar and the raw Set-Cookie
|
||||||
|
# header. qBittorrent only sets this cookie on a valid login.
|
||||||
|
def _is_session_cookie(name: str) -> bool:
|
||||||
|
upper = name.strip().upper()
|
||||||
|
return upper == "SID" or upper.startswith("QBT_SID")
|
||||||
|
|
||||||
|
set_cookie_hdr = resp.headers.get("Set-Cookie", "") or ""
|
||||||
|
first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip()
|
||||||
|
sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or (
|
||||||
|
bool(first_cookie_name) and _is_session_cookie(first_cookie_name)
|
||||||
|
)
|
||||||
|
if body == "Ok." or sid_ok:
|
||||||
|
self._logged_in = True
|
||||||
|
logger.info("qBittorrent login successful for %s", self.base_url)
|
||||||
|
return
|
||||||
|
if body == "Fails.":
|
||||||
|
raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password")
|
||||||
|
cookie_names = sorted(resp.cookies.keys()) or (["<unparsed>"] if set_cookie_hdr else [])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, "
|
||||||
|
f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session "
|
||||||
|
"cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does "
|
||||||
|
"not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in "
|
||||||
|
"front of qBittorrent)."
|
||||||
|
)
|
||||||
|
|
||||||
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||||
"""GET an endpoint with auto-login on first call and re-login on 403."""
|
"""GET an endpoint with auto-login on first call and re-login on 403."""
|
||||||
@@ -71,10 +145,107 @@ class QbittorrentClient:
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
def maindata(self) -> dict[str, Any]:
|
def maindata(self) -> dict[str, Any]:
|
||||||
"""Fetch ``/sync/maindata``.
|
"""Return the current ``/sync/maindata`` snapshot.
|
||||||
|
|
||||||
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
|
Uses qBittorrent's incremental ``rid`` protocol (first call is a full
|
||||||
``up_info_speed``, etc.) and ``torrents`` (a dict of
|
update, subsequent calls send the last rid and get a small diff that is
|
||||||
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
|
merged into the cached snapshot), so payloads stay small. A short-TTL
|
||||||
|
cache + lock collapses concurrent widget polls onto a single fetch, and
|
||||||
|
on repeated failures the client backs off instead of hammering
|
||||||
|
qBittorrent's single-threaded web server (serving the last good
|
||||||
|
snapshot when available).
|
||||||
|
|
||||||
|
Returns a dict with ``server_state`` and ``torrents``.
|
||||||
"""
|
"""
|
||||||
return self._get("/sync/maindata")
|
now = time.time()
|
||||||
|
with self._maindata_lock:
|
||||||
|
# Serve a fresh-enough cached snapshot without re-hitting qBittorrent.
|
||||||
|
if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl:
|
||||||
|
return self._copy_snapshot()
|
||||||
|
# While backing off, don't pile on; serve stale or raise.
|
||||||
|
if now < self._backoff_until:
|
||||||
|
if self._snapshot.get("torrents"):
|
||||||
|
return self._copy_snapshot()
|
||||||
|
raise RuntimeError(
|
||||||
|
"qBittorrent maindata unavailable (backing off after repeated failures)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
update = self._fetch_maindata_incremental()
|
||||||
|
self._apply_update(update)
|
||||||
|
except Exception as exc:
|
||||||
|
self._consecutive_failures += 1
|
||||||
|
delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX)
|
||||||
|
self._backoff_until = time.time() + delay
|
||||||
|
logger.warning(
|
||||||
|
"qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s",
|
||||||
|
self._consecutive_failures,
|
||||||
|
delay,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if self._snapshot.get("torrents"):
|
||||||
|
return self._copy_snapshot()
|
||||||
|
raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc
|
||||||
|
self._maindata_fetched_at = time.time()
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
self._backoff_until = 0.0
|
||||||
|
return self._copy_snapshot()
|
||||||
|
|
||||||
|
def _fetch_maindata_incremental(self) -> dict[str, Any]:
|
||||||
|
"""GET /sync/maindata, sending the last rid for an incremental update."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if self._rid is not None:
|
||||||
|
params["rid"] = self._rid
|
||||||
|
return self._get("/sync/maindata", **params)
|
||||||
|
|
||||||
|
def _apply_update(self, update: dict[str, Any]) -> None:
|
||||||
|
"""Merge a full or partial maindata update into the cached snapshot."""
|
||||||
|
is_full = bool(update.get("full_update")) or self._rid is None
|
||||||
|
self._rid = update.get("rid", self._rid)
|
||||||
|
snap = self._snapshot
|
||||||
|
if is_full:
|
||||||
|
snap.clear()
|
||||||
|
snap["server_state"] = dict(update.get("server_state") or {})
|
||||||
|
snap["torrents"] = dict(update.get("torrents") or {})
|
||||||
|
snap["categories"] = dict(update.get("categories") or {})
|
||||||
|
snap["tags"] = list(update.get("tags") or [])
|
||||||
|
snap["trackers"] = list(update.get("trackers") or [])
|
||||||
|
return
|
||||||
|
# Partial update — merge the diff.
|
||||||
|
server_state = update.get("server_state")
|
||||||
|
if isinstance(server_state, dict):
|
||||||
|
snap["server_state"].update(server_state)
|
||||||
|
changed = update.get("torrents")
|
||||||
|
if isinstance(changed, dict):
|
||||||
|
for hash_, fields in changed.items():
|
||||||
|
if fields is None:
|
||||||
|
snap["torrents"].pop(hash_, None)
|
||||||
|
else:
|
||||||
|
previous = snap["torrents"].get(hash_)
|
||||||
|
snap["torrents"][hash_] = (
|
||||||
|
{**previous, **fields}
|
||||||
|
if isinstance(previous, dict) and isinstance(fields, dict)
|
||||||
|
else fields
|
||||||
|
)
|
||||||
|
for hash_ in update.get("torrents_removed") or []:
|
||||||
|
snap["torrents"].pop(hash_, None)
|
||||||
|
categories = update.get("categories")
|
||||||
|
if isinstance(categories, dict):
|
||||||
|
snap["categories"].update(categories)
|
||||||
|
for name in update.get("categories_removed") or []:
|
||||||
|
snap["categories"].pop(name, None)
|
||||||
|
if "tags" in update:
|
||||||
|
snap["tags"] = list(update.get("tags") or [])
|
||||||
|
if "trackers" in update:
|
||||||
|
snap["trackers"] = list(update.get("trackers") or [])
|
||||||
|
|
||||||
|
def _copy_snapshot(self) -> dict[str, Any]:
|
||||||
|
"""Return a shallow, race-safe copy of the current snapshot."""
|
||||||
|
snap = self._snapshot
|
||||||
|
return {
|
||||||
|
"rid": self._rid,
|
||||||
|
"server_state": dict(snap.get("server_state") or {}),
|
||||||
|
"torrents": dict(snap.get("torrents") or {}),
|
||||||
|
"categories": dict(snap.get("categories") or {}),
|
||||||
|
"tags": list(snap.get("tags") or []),
|
||||||
|
"trackers": list(snap.get("trackers") or []),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""Dependency injection for FastAPI.
|
"""Dependency injection for FastAPI.
|
||||||
|
|
||||||
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||||
machine-specific SSH clients via FastAPI's request context.
|
remote-machine SSH clients via FastAPI's request context.
|
||||||
|
|
||||||
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
||||||
parameter (resolved against the service registry); the backend falls back to
|
parameter (resolved against the service registry); the backend falls back to
|
||||||
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
||||||
- SSH/Files transport is selected with ``machine_id`` as before.
|
- SSH/Files transport is selected with an enabled ``remote_machine`` ``service_id``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,9 +18,7 @@ from typing import Any
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
|
||||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
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
|
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.mail_queue import get_mail_queue as _get_mail_queue
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
@@ -29,11 +27,11 @@ from media_library_viewer_api.services.settings_store import get_settings_store
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _request_machine_id(request: Request | None) -> str | None:
|
def _request_remote_machine_service_id(request: Request | None) -> str | None:
|
||||||
if request is None:
|
if request is None:
|
||||||
return None
|
return None
|
||||||
machine_id = request.query_params.get("machine_id")
|
service_id = request.query_params.get("service_id")
|
||||||
return machine_id or None
|
return service_id or None
|
||||||
|
|
||||||
|
|
||||||
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
||||||
@@ -80,87 +78,10 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
|||||||
return JellyfinClient(url, api_key)
|
return JellyfinClient(url, api_key)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
def get_jellyfin_client(request: Request) -> JellyfinClient:
|
||||||
def _ssh_client_for(
|
"""Return a Jellyfin client for the selected enabled service instance."""
|
||||||
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>",
|
|
||||||
host or "<unset>",
|
|
||||||
username or "<unset>",
|
|
||||||
port,
|
|
||||||
key_filename or "<unset>",
|
|
||||||
"set" if password else "missing",
|
|
||||||
"set" if private_key else "missing",
|
|
||||||
"set" if private_key_passphrase else "missing",
|
|
||||||
)
|
|
||||||
client = RemoteSSHClient(
|
|
||||||
host=host,
|
|
||||||
username=username,
|
|
||||||
port=port,
|
|
||||||
key_filename=key_filename or None,
|
|
||||||
private_key=private_key or None,
|
|
||||||
private_key_passphrase=private_key_passphrase or None,
|
|
||||||
password=password or None,
|
|
||||||
known_hosts_path=known_hosts_path or None,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
client.connect()
|
|
||||||
except RuntimeError as exc:
|
|
||||||
message = str(exc)
|
|
||||||
lowered = message.lower()
|
|
||||||
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
|
|
||||||
if "banner" in lowered:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=(
|
|
||||||
f"SSH banner not received from {host}:{port}. "
|
|
||||||
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
|
|
||||||
),
|
|
||||||
) from exc
|
|
||||||
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail=(
|
|
||||||
f"SSH authentication failed for {host}:{port}. "
|
|
||||||
"Check the selected key, passphrase, username, or password."
|
|
||||||
),
|
|
||||||
) from exc
|
|
||||||
raise HTTPException(status_code=502, detail=message) from exc
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
|
|
||||||
raise
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
|
||||||
"""Resolve an SSH/Files machine for the given transport service.
|
|
||||||
|
|
||||||
Jellyfin/Jellyseerr are resolved against the service registry, not here.
|
|
||||||
"""
|
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service = _service_record(store, "jellyfin", _request_jellyfin_service_id(request))
|
||||||
if machine_id:
|
|
||||||
machine = store.get_machine(machine_id)
|
|
||||||
if machine and (service in machine.get("services", []) or service == "ssh"):
|
|
||||||
return machine
|
|
||||||
return machine
|
|
||||||
if service == "ssh":
|
|
||||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
|
||||||
else:
|
|
||||||
machines = store.list_machines_for_service(service)
|
|
||||||
return machines[0] if machines else None
|
|
||||||
|
|
||||||
|
|
||||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
|
||||||
"""Return a Jellyfin client for the selected Jellyfin service instance."""
|
|
||||||
store = get_settings_store()
|
|
||||||
service_id = _request_jellyfin_service_id(request)
|
|
||||||
service = _service_record(store, "jellyfin", service_id)
|
|
||||||
if service is None:
|
if service is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -173,83 +94,25 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
||||||
)
|
)
|
||||||
cache_key = (service["id"], base_url, api_key)
|
return _jellyfin_client_for((service["id"], base_url, api_key))
|
||||||
return _jellyfin_client_for(cache_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
def get_ssh_client(request: Request) -> RemoteSSHClient:
|
||||||
"""Build a RemoteSSHClient from a machine config dict."""
|
"""Return SSH transport for the requested enabled remote-machine service."""
|
||||||
store = store or get_settings_store()
|
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||||
known_hosts_path = get_settings().ssh_known_hosts_file
|
from media_library_viewer_api.widgets.sources import build_service_record
|
||||||
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()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_remote_machine_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
if not service_id:
|
||||||
if machine is None:
|
raise HTTPException(status_code=400, detail="service_id is required for remote file and job operations")
|
||||||
machine_ref = _resolve_machine("ssh", request)
|
row = store.get_service(service_id)
|
||||||
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
|
if not row or row.get("service_type") != "remote_machine" or not row.get("enabled", True):
|
||||||
if machine and str(machine.get("mode") or "local").strip().lower() == "local":
|
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
|
||||||
logger.info("Creating LocalCommandClient machine_id=%s", machine["id"])
|
try:
|
||||||
return LocalCommandClient()
|
return build_ssh_client(store, build_service_record(store, row))
|
||||||
if machine and machine.get("host") and machine.get("username"):
|
except ValueError as exc:
|
||||||
return _ssh_client_from_machine_config(machine, store)
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
logger.info(
|
|
||||||
"Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
|
|
||||||
settings.ssh_host or "<unset>",
|
|
||||||
settings.ssh_username or "<unset>",
|
|
||||||
settings.ssh_port,
|
|
||||||
settings.ssh_key_directory or "<unset>",
|
|
||||||
settings.ssh_key_name or "<unset>",
|
|
||||||
"set" if settings.ssh_password else "missing",
|
|
||||||
)
|
|
||||||
if not settings.ssh_key_path:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_mail_queue() -> MailQueue:
|
def get_mail_queue() -> MailQueue:
|
||||||
@@ -262,18 +125,42 @@ def get_settings_store() -> SettingsStore:
|
|||||||
return _get_settings_store()
|
return _get_settings_store()
|
||||||
|
|
||||||
|
|
||||||
def get_user_id(request: Request = None) -> str:
|
def get_user_id(request: Request) -> str:
|
||||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
"""Return the Jellyfin user Id, resolving a configured username if needed.
|
||||||
|
|
||||||
|
The service ``user_id`` config field accepts either the internal Jellyfin Id
|
||||||
|
or a username (e.g. ``'admin'``). Jellyfin's ``/Users/{id}/...`` endpoints
|
||||||
|
reject usernames with HTTP 400 (``"The value 'admin' is not valid."``), so
|
||||||
|
always resolve to the internal Id before use. Resolution is cached per
|
||||||
|
(service, base_url, api_key, configured) so repeated dashboard/media requests
|
||||||
|
don't re-list users on every call.
|
||||||
|
"""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
service_id = _request_jellyfin_service_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
service = _service_record(store, "jellyfin", service_id)
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if service and service.get("config", {}).get("user_id"):
|
if service is None:
|
||||||
return str(service["config"]["user_id"])
|
|
||||||
client = get_jellyfin_client(request)
|
|
||||||
users = client.users()
|
|
||||||
if not users:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="No Jellyfin users found and no user_id configured on the service",
|
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
|
||||||
)
|
)
|
||||||
return users[0]["Id"]
|
configured = str(service.get("config", {}).get("user_id") or "").strip()
|
||||||
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
|
if not base_url or not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
||||||
|
)
|
||||||
|
return _resolved_user_id((service["id"], base_url, api_key, configured))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def _resolved_user_id(cache_key: tuple[str, str, str, str]) -> str:
|
||||||
|
"""Resolve a configured Jellyfin identifier (Id or username) to the internal Id.
|
||||||
|
|
||||||
|
Keyed by (service_id, base_url, api_key, configured) so a credentials change
|
||||||
|
or a different configured user busts the cache automatically.
|
||||||
|
"""
|
||||||
|
service_id, base_url, api_key, configured = cache_key
|
||||||
|
client = _jellyfin_client_for((service_id, base_url, api_key))
|
||||||
|
return client.resolve_user_id(configured or None)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/integrations
|
dir: backend/src/media_library_viewer_api/integrations
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
|
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api/integrations
|
|||||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
|
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Defines a closed registry module for service integrations.
|
- __init__.py | Defines a closed registry module for service integrations.
|
||||||
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
- alertmanager.py | Defines a service integration for Prometheus Alertmanager, providing configuration models, connection testing, alert summarization, and widget definitions for displaying active alerts. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:int, call:secrets.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get("versionInfo", {}).get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||||
- authentik.py | Defines the service configuration model and definition for integrating Authentik as a user directory and identity provider. | exp: class:AuthentikConfig | dep: media_library_viewer_api.integrations.base
|
- authentik.py | Defines the Authentik service integration for user-directory access, including connection config, API token secret management, and a connection test. | exp: class:AuthentikConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:secrets.get, call:float, call:AuthentikClient, call:client.users, call:result.get, call:isinstance, call:TestResult, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.authentik, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.authentik.AuthentikClient
|
||||||
- backups.py | Defines a Backups service type with configuration and summary widget for monitoring backup jobs, run history, and alerting. | exp: class:BackupsConfig, class:BackupsSummaryWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- backups.py | Defines a Backups service type with configuration and summary widget for monitoring backup jobs, run history, and alerting. | exp: class:BackupsConfig, class:BackupsSummaryWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- base.py | Provides foundational base classes and dataclasses for defining external service integrations, including config validation and widget schema generation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
- base.py | Provides base classes and utility functions for defining external service integrations, including config schemas, secrets, widgets, and connection error translation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:TestResult, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump, func:translate_connection_error(exc: Exception, context) → TestResult, call:str, call:message.lower, call:isinstance, call:TestResult | dep: asyncio, dataclasses, typing, requests, pydantic, media_library_viewer_api.services.settings_store
|
||||||
- jellyfin.py | Defines the Jellyfin media server service configuration, secret fields, and widget definitions for activity and now-playing sessions. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- jellyfin.py | Defines the Jellyfin service integration configuration, connection testing, and widget definitions for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, class:JellyfinRequestStatWidgetConfig, class:JellyfinRequestsOverviewWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||||
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
- nextcloud.py | Defines a Nextcloud service integration with connection testing and configuration for a media library viewer API. | exp: class:NextcloudConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||||
- prometheus.py | Defines the Prometheus service configuration and widget types (metric, chart, gauge, mean) for querying and visualizing PromQL data. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- prometheus.py | Defines the Prometheus service integration for a media library viewer API, including connection testing via a Grafana gateway and configuration models for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||||
- qbittorrent.py | Declares the qBittorrent service definition including config models, secret fields, and three widget kinds (totals, active, speed). | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- qbittorrent.py | Defines the qBittorrent service integration, including connection config models, secret fields, widget definitions (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||||
- registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks
|
- registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks
|
||||||
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- ssh_tasks.py | Defines a service plugin that runs reusable saved tasks over SSH by managing connection configuration, secrets, and connection testing. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("host") or "").strip, call:config.get, call:int, call:ServiceRecord, call:build_ssh_client, call:client.connect, call:str(exc).lower, call:TestResult, call:translate_connection_error, call:client.close | dep: typing, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||||
## arch
|
## arch
|
||||||
Plugin-style registry pattern with a shared base class hierarchy; each service module independently defines config dataclasses, widget types, and summarization logic, all registered in a central closed registry for discovery and lookup.
|
Registry-based plugin pattern with a shared base class defining standard interfaces (config models, secrets, widgets, connection tests) that each service integration implements and registers with a central closed registry for dynamic discovery.
|
||||||
## tags
|
## tags
|
||||||
config, widget, service, integrations, base, media_library_viewer_api, prometheus, definition
|
config, connection, widget, media_library_viewer_api, service, error, integrations, test
|
||||||
## symbols
|
## symbols
|
||||||
- AlertmanagerConfig
|
- AlertmanagerConfig
|
||||||
- AlertmanagerAlertsWidgetConfig
|
- AlertmanagerAlertsWidgetConfig
|
||||||
|
|||||||
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
widget_kind,
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
class AlertmanagerConfig(ServiceConfigBase):
|
class AlertmanagerConfig(ServiceConfigBase):
|
||||||
"""Non-secret Alertmanager connection config."""
|
"""Non-secret Alertmanager connection config."""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 5
|
timeout_seconds: int = 15
|
||||||
|
|
||||||
|
|
||||||
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||||
@@ -68,6 +75,28 @@ def summarize_alerts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""GET /api/v2/status with optional bearer auth."""
|
||||||
|
try:
|
||||||
|
base_url = str(config.get("base_url") or "").rstrip("/")
|
||||||
|
timeout = int(config.get("timeout_seconds") or 15)
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
api_key = str(secrets.get("api_key") or "")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
resp = requests.get(f"{base_url}/api/v2/status", headers=headers, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
version = str(payload.get("versionInfo", {}).get("version", "") or "connected")
|
||||||
|
return TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version)
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="Alertmanager")
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="alertmanager",
|
service_type="alertmanager",
|
||||||
name="Alertmanager",
|
name="Alertmanager",
|
||||||
@@ -86,4 +115,5 @@ DEFINITION = ServiceDefinition(
|
|||||||
refresh_interval_ms=30_000,
|
refresh_interval_ms=30_000,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,35 +1,85 @@
|
|||||||
"""Authentik service definition.
|
"""Authentik service definition for read-only directory and access metadata."""
|
||||||
|
|
||||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
|
||||||
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
|
|
||||||
on the Authentik service page (Users + Messaging tabs). OIDC authentication
|
|
||||||
is unchanged -- this service type is for the directory, not SSO.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
|
||||||
|
"""Probe the least-expensive Authentik directory endpoint."""
|
||||||
|
try:
|
||||||
|
client = AuthentikClient(
|
||||||
|
base_url=str(config.get("base_url") or "").rstrip("/"),
|
||||||
|
api_token=str(secrets.get("api_token") or ""),
|
||||||
|
timeout=float(config.get("timeout_seconds") or 60),
|
||||||
|
)
|
||||||
|
result = client.users(page=1, page_size=1)
|
||||||
|
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="Authentik")
|
||||||
|
|
||||||
|
|
||||||
class AuthentikConfig(ServiceConfigBase):
|
class AuthentikConfig(ServiceConfigBase):
|
||||||
"""Non-secret Authentik connection config."""
|
"""Non-secret Authentik connection config."""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = Field(default=60, ge=1, le=300)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikListWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Bounded display count for read-only Authentik list widgets."""
|
||||||
|
|
||||||
|
limit: int = Field(default=10, ge=1, le=50)
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="authentik",
|
service_type="authentik",
|
||||||
name="Authentik",
|
name="Authentik",
|
||||||
description="User directory and identity provider integration.",
|
description="Read-only user directory, groups, and application access metadata.",
|
||||||
config_model=AuthentikConfig,
|
config_model=AuthentikConfig,
|
||||||
secret_fields=[
|
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
|
||||||
SecretField(key="api_token", label="API token", required=True),
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="access_summary",
|
||||||
|
name="User access summary",
|
||||||
|
description="User group memberships and explicit staff/superuser status; not effective authorization.",
|
||||||
|
model_cls=AuthentikListWidgetConfig,
|
||||||
|
default_config={"limit": 10},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
|
widget_kind(
|
||||||
|
kind="groups",
|
||||||
|
name="Groups",
|
||||||
|
description="Read-only Authentik group list.",
|
||||||
|
model_cls=AuthentikListWidgetConfig,
|
||||||
|
default_config={"limit": 10},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
|
widget_kind(
|
||||||
|
kind="applications",
|
||||||
|
name="Applications",
|
||||||
|
description="Read-only Authentik application list.",
|
||||||
|
model_cls=AuthentikListWidgetConfig,
|
||||||
|
default_config={"limit": 10},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
widget_kinds=[],
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ map. There is no runtime plugin loading.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Annotated, Any
|
from typing import TYPE_CHECKING, Annotated, Any, Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
from pydantic import BaseModel, BeforeValidator, Field
|
from pydantic import BaseModel, BeforeValidator, Field
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
def _validate_service_base_url(value: Any) -> str:
|
def _validate_service_base_url(value: Any) -> str:
|
||||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||||
@@ -93,6 +98,20 @@ class WidgetKind:
|
|||||||
config_model: type[WidgetConfigBase] | None = None
|
config_model: type[WidgetConfigBase] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TestResult:
|
||||||
|
"""Outcome of a credential/connectivity test for a service instance."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
evidence: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
#: A test routine receives (config, secrets, store). The store is needed for
|
||||||
|
#: remote_machine (SSH-key resolution). Other types ignore it.
|
||||||
|
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ServiceDefinition:
|
class ServiceDefinition:
|
||||||
"""Closed description of an external service type."""
|
"""Closed description of an external service type."""
|
||||||
@@ -103,6 +122,7 @@ class ServiceDefinition:
|
|||||||
config_model: type[ServiceConfigBase]
|
config_model: type[ServiceConfigBase]
|
||||||
secret_fields: list[SecretField]
|
secret_fields: list[SecretField]
|
||||||
widget_kinds: list[WidgetKind]
|
widget_kinds: list[WidgetKind]
|
||||||
|
test_callable: TestCallable | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config_schema(self) -> dict[str, Any]:
|
def config_schema(self) -> dict[str, Any]:
|
||||||
@@ -148,3 +168,58 @@ def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -
|
|||||||
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||||
instance = model_cls.model_validate(config or {})
|
instance = model_cls.model_validate(config or {})
|
||||||
return instance.model_dump(exclude_none=True)
|
return instance.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
|
||||||
|
"""Map a common connection/auth exception to a human-friendly TestResult.
|
||||||
|
|
||||||
|
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
|
||||||
|
HTTP-client patterns from the widget sources. Each per-type test routine
|
||||||
|
calls this for unexpected exceptions, but handles its **type-specific**
|
||||||
|
auth failures directly (e.g., qBit ``"Fails."``).
|
||||||
|
"""
|
||||||
|
message = str(exc)
|
||||||
|
lowered = message.lower()
|
||||||
|
|
||||||
|
# Auth failures (HTTP 401/403)
|
||||||
|
if isinstance(exc, requests.HTTPError):
|
||||||
|
status_code = exc.response.status_code if exc.response is not None else 0
|
||||||
|
if status_code in (401, 403):
|
||||||
|
return TestResult(
|
||||||
|
ok=False,
|
||||||
|
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
|
||||||
|
)
|
||||||
|
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
||||||
|
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
|
||||||
|
|
||||||
|
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
|
||||||
|
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
|
||||||
|
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
|
||||||
|
|
||||||
|
# Connection refused / DNS / unreachable
|
||||||
|
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
|
||||||
|
if (
|
||||||
|
"name or service not known" in lowered
|
||||||
|
or "nodename nor servname" in lowered
|
||||||
|
or "getaddrinfo failed" in lowered
|
||||||
|
):
|
||||||
|
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
|
||||||
|
return TestResult(
|
||||||
|
ok=False,
|
||||||
|
detail="Connection refused — the service is not reachable at the configured address.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SSL / certificate errors
|
||||||
|
if "ssl" in lowered or "certificate" in lowered:
|
||||||
|
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
|
||||||
|
|
||||||
|
# SSH banner (from test_machine_ssh pattern)
|
||||||
|
if "protocol banner" in lowered:
|
||||||
|
return TestResult(
|
||||||
|
ok=False,
|
||||||
|
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
prefix = f"{context}: " if context else ""
|
||||||
|
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
|
||||||
|
|||||||
@@ -2,31 +2,54 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
widget_kind,
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""Call JellyfinClient.users() — the lightest authenticated probe."""
|
||||||
|
try:
|
||||||
|
base_url = str(config.get("base_url") or "")
|
||||||
|
api_key = str(secrets.get("api_key") or "")
|
||||||
|
timeout = int(config.get("timeout_seconds") or 60)
|
||||||
|
client = JellyfinClient(base_url, api_key, timeout=timeout)
|
||||||
|
users = client.users()
|
||||||
|
return TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users")
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="Jellyfin")
|
||||||
|
|
||||||
|
|
||||||
class JellyfinConfig(ServiceConfigBase):
|
class JellyfinConfig(ServiceConfigBase):
|
||||||
"""Non-secret Jellyfin connection config.
|
"""Non-secret Jellyfin connection config.
|
||||||
|
|
||||||
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
|
The optional ``jellyseerr_url`` field pairs a Jellyseerr companion with this
|
||||||
paired Jellyseerr companion config, absorbed from the former standalone
|
Jellyfin instance; the matching ``jellyseerr_api_key`` is a secret field on
|
||||||
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
|
the service. When both are set, the Jellyfin service page renders a Requests
|
||||||
When both are set, the Jellyfin service page renders a Requests tab backed
|
tab backed by Jellyseerr.
|
||||||
by Jellyseerr.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
base_url: ServiceBaseUrl
|
||||||
user_id: str = ""
|
user_id: str = ""
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = 60
|
||||||
jellyseerr_url: str = ""
|
jellyseerr_url: str = ""
|
||||||
jellyseerr_api_key: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||||
@@ -42,6 +65,25 @@ class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinRequestStatWidgetConfig(WidgetConfigBase):
|
||||||
|
"""A single Jellyseerr request stat (e.g. pending / approved / total)."""
|
||||||
|
|
||||||
|
stat: Literal[
|
||||||
|
"total",
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"declined",
|
||||||
|
"processing",
|
||||||
|
"available",
|
||||||
|
] = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinRequestsOverviewWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Grid of all Jellyseerr request stats + a recent-requests list."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="jellyfin",
|
service_type="jellyfin",
|
||||||
name="Jellyfin",
|
name="Jellyfin",
|
||||||
@@ -49,6 +91,12 @@ DEFINITION = ServiceDefinition(
|
|||||||
config_model=JellyfinConfig,
|
config_model=JellyfinConfig,
|
||||||
secret_fields=[
|
secret_fields=[
|
||||||
SecretField(key="api_key", label="API key", required=True),
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
SecretField(
|
||||||
|
key="jellyseerr_api_key",
|
||||||
|
label="Jellyseerr API key",
|
||||||
|
required=False,
|
||||||
|
helper="Enables the Requests tab + request-stats widgets (optional).",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
widget_kinds=[
|
widget_kinds=[
|
||||||
widget_kind(
|
widget_kind(
|
||||||
@@ -67,5 +115,22 @@ DEFINITION = ServiceDefinition(
|
|||||||
default_config={},
|
default_config={},
|
||||||
refresh_interval_ms=30_000,
|
refresh_interval_ms=30_000,
|
||||||
),
|
),
|
||||||
|
widget_kind(
|
||||||
|
kind="stat",
|
||||||
|
name="Request stat",
|
||||||
|
description="A single Jellyseerr request statistic (e.g. pending requests).",
|
||||||
|
model_cls=JellyfinRequestStatWidgetConfig,
|
||||||
|
default_config={"stat": "pending"},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
|
widget_kind(
|
||||||
|
kind="stats_overview",
|
||||||
|
name="Requests overview",
|
||||||
|
description="All Jellyseerr request stats plus a recent-requests list.",
|
||||||
|
model_cls=JellyfinRequestsOverviewWidgetConfig,
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,13 +6,39 @@ dashboard widgets yet; its service page holds connection config only.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
|
translate_connection_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""GET {base_url}/status.php (unauthenticated server probe)."""
|
||||||
|
try:
|
||||||
|
base_url = str(config.get("base_url") or "").rstrip("/")
|
||||||
|
resp = requests.get(f"{base_url}/status.php", timeout=(5.0, 60.0))
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
version = str(payload.get("version", "") or "connected")
|
||||||
|
return TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="Nextcloud")
|
||||||
|
|
||||||
|
|
||||||
class NextcloudConfig(ServiceConfigBase):
|
class NextcloudConfig(ServiceConfigBase):
|
||||||
"""Non-secret Nextcloud connection config."""
|
"""Non-secret Nextcloud connection config."""
|
||||||
@@ -30,4 +56,5 @@ DEFINITION = ServiceDefinition(
|
|||||||
SecretField(key="app_password", label="App password", required=True),
|
SecretField(key="app_password", label="App password", required=True),
|
||||||
],
|
],
|
||||||
widget_kinds=[],
|
widget_kinds=[],
|
||||||
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,21 +2,78 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
widget_kind,
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""POST {grafana_url}/api/ds/query with expr 'up' via the Grafana gateway."""
|
||||||
|
try:
|
||||||
|
grafana_url = str(config.get("grafana_url") or "").rstrip("/")
|
||||||
|
api_key = str(secrets.get("grafana_api_key") or "")
|
||||||
|
datasource_uid = str(config.get("datasource_uid") or "prometheus")
|
||||||
|
timeout = int(config.get("timeout_seconds") or 60)
|
||||||
|
if not grafana_url:
|
||||||
|
return TestResult(ok=False, detail="Grafana gateway URL is required.")
|
||||||
|
if not api_key:
|
||||||
|
return TestResult(ok=False, detail="Grafana API key is required.")
|
||||||
|
body = {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||||
|
"expr": "up",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalMs": 15000,
|
||||||
|
"maxDataPoints": 1,
|
||||||
|
"refId": "A",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"from": "now-1m",
|
||||||
|
"to": "now",
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
f"{grafana_url}/api/ds/query",
|
||||||
|
json=body,
|
||||||
|
timeout=timeout,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return TestResult(
|
||||||
|
ok=True,
|
||||||
|
detail="Grafana gateway reachable.",
|
||||||
|
evidence="Gateway reachable; datasource responded.",
|
||||||
|
)
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
return translate_connection_error(exc, context="Prometheus via Grafana")
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="Prometheus via Grafana")
|
||||||
|
|
||||||
|
|
||||||
class PrometheusConfig(ServiceConfigBase):
|
class PrometheusConfig(ServiceConfigBase):
|
||||||
"""Non-secret Prometheus connection config."""
|
"""Non-secret Prometheus-via-Grafana gateway config."""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
grafana_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 10
|
datasource_uid: str = "prometheus"
|
||||||
|
timeout_seconds: int = 60
|
||||||
|
|
||||||
|
|
||||||
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||||
@@ -29,7 +86,19 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
|
|||||||
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
||||||
|
|
||||||
promql: str
|
promql: str
|
||||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||||
|
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
|
||||||
|
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
||||||
|
unit: Literal[
|
||||||
|
"none",
|
||||||
|
"bytes",
|
||||||
|
"bytes_per_sec",
|
||||||
|
"bits_per_sec",
|
||||||
|
"bits",
|
||||||
|
"percent",
|
||||||
|
"seconds",
|
||||||
|
] = "none"
|
||||||
|
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||||
|
|
||||||
|
|
||||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||||
@@ -47,7 +116,7 @@ class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
|||||||
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
||||||
|
|
||||||
promql: str
|
promql: str
|
||||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||||
unit: str | None = None
|
unit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +126,12 @@ DEFINITION = ServiceDefinition(
|
|||||||
description="Metrics storage and PromQL queries.",
|
description="Metrics storage and PromQL queries.",
|
||||||
config_model=PrometheusConfig,
|
config_model=PrometheusConfig,
|
||||||
secret_fields=[
|
secret_fields=[
|
||||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
SecretField(
|
||||||
|
key="grafana_api_key",
|
||||||
|
label="Grafana API key",
|
||||||
|
required=True,
|
||||||
|
helper="Service account token or API key for the Grafana gateway",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
widget_kinds=[
|
widget_kinds=[
|
||||||
widget_kind(
|
widget_kind(
|
||||||
@@ -93,4 +167,5 @@ DEFINITION = ServiceDefinition(
|
|||||||
refresh_interval_ms=60_000,
|
refresh_interval_ms=60_000,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,29 +7,105 @@ password), and three widget kinds (totals, active, speed). Models on
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
ServiceBaseUrl,
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
widget_kind,
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""Login + probe maindata; surface auth failures specifically."""
|
||||||
|
try:
|
||||||
|
base_url = str(config.get("base_url") or "")
|
||||||
|
username = str(secrets.get("username") or "")
|
||||||
|
password = str(secrets.get("password") or "")
|
||||||
|
timeout = int(config.get("timeout_seconds") or 60)
|
||||||
|
client = QbittorrentClient(base_url, username, password, timeout=timeout)
|
||||||
|
data = client.maindata()
|
||||||
|
version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected")
|
||||||
|
return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
lowered = str(exc).lower()
|
||||||
|
if "invalid username or password" in lowered:
|
||||||
|
return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.")
|
||||||
|
# Gateway timeout, wrong URL/path, empty body, etc. — surface the real
|
||||||
|
# reason instead of masking every login error as an auth failure.
|
||||||
|
return translate_connection_error(exc, context="qBittorrent")
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context="qBittorrent")
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentConfig(ServiceConfigBase):
|
class QbittorrentConfig(ServiceConfigBase):
|
||||||
"""Non-secret qBittorrent connection config."""
|
"""Non-secret qBittorrent connection and sampling config."""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = Field(default=60, ge=1, le=300)
|
||||||
|
polling_enabled: bool = Field(default=True, description="Collect speed samples without an open dashboard")
|
||||||
|
poll_interval_seconds: int = Field(default=15, ge=5, le=300, description="Seconds between speed samples")
|
||||||
|
sample_retention_seconds: int = Field(
|
||||||
|
default=1_800,
|
||||||
|
ge=60,
|
||||||
|
le=86_400,
|
||||||
|
description="How long speed samples remain available",
|
||||||
|
)
|
||||||
|
sample_max_rows: int = Field(
|
||||||
|
default=1_200,
|
||||||
|
ge=60,
|
||||||
|
le=1_200,
|
||||||
|
description="Maximum speed samples retained per service",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||||
"""Per-widget config (empty — all three kinds derive from the service connection)."""
|
"""Per-widget config for totals/active (empty — derived from the service connection)."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
||||||
|
|
||||||
|
window_seconds: int | Literal["all"] = 1_800
|
||||||
|
unit: Literal[
|
||||||
|
"none",
|
||||||
|
"bytes",
|
||||||
|
"bytes_per_sec",
|
||||||
|
"bits_per_sec",
|
||||||
|
"bits",
|
||||||
|
"percent",
|
||||||
|
"seconds",
|
||||||
|
] = "bytes_per_sec"
|
||||||
|
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||||
|
|
||||||
|
@field_validator("window_seconds")
|
||||||
|
@classmethod
|
||||||
|
def validate_window_seconds(cls, value: int | str) -> int | str:
|
||||||
|
"""Allow all retained samples while bounding explicit numeric windows."""
|
||||||
|
if value == "all":
|
||||||
|
return value
|
||||||
|
if not isinstance(value, int) or not 60 <= value <= 86_400:
|
||||||
|
raise ValueError("window_seconds must be between 60 and 86400, or 'all'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="qbittorrent",
|
service_type="qbittorrent",
|
||||||
name="qBittorrent",
|
name="qBittorrent",
|
||||||
@@ -51,7 +127,7 @@ DEFINITION = ServiceDefinition(
|
|||||||
widget_kind(
|
widget_kind(
|
||||||
kind="active",
|
kind="active",
|
||||||
name="Active torrents",
|
name="Active torrents",
|
||||||
description="Torrents currently downloading or uploading.",
|
description="All active download/upload work, including queued and stalled transfers.",
|
||||||
model_cls=QbittorrentWidgetConfig,
|
model_cls=QbittorrentWidgetConfig,
|
||||||
default_config={},
|
default_config={},
|
||||||
refresh_interval_ms=15_000,
|
refresh_interval_ms=15_000,
|
||||||
@@ -60,9 +136,10 @@ DEFINITION = ServiceDefinition(
|
|||||||
kind="speed",
|
kind="speed",
|
||||||
name="Speed chart",
|
name="Speed chart",
|
||||||
description="Live download/upload speed over a short window.",
|
description="Live download/upload speed over a short window.",
|
||||||
model_cls=QbittorrentWidgetConfig,
|
model_cls=QbittorrentSpeedWidgetConfig,
|
||||||
default_config={},
|
default_config={"window_seconds": 1_800, "unit": "bytes_per_sec", "scale": "auto"},
|
||||||
refresh_interval_ms=5_000,
|
refresh_interval_ms=15_000,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFI
|
|||||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
from media_library_viewer_api.integrations.remote_machine import DEFINITION as REMOTE_MACHINE
|
||||||
|
|
||||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||||
PROMETHEUS.service_type: PROMETHEUS,
|
PROMETHEUS.service_type: PROMETHEUS,
|
||||||
@@ -22,7 +22,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
|||||||
JELLYFIN.service_type: JELLYFIN,
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
QBITTORRENT.service_type: QBITTORRENT,
|
QBITTORRENT.service_type: QBITTORRENT,
|
||||||
SSH_TASKS.service_type: SSH_TASKS,
|
REMOTE_MACHINE.service_type: REMOTE_MACHINE,
|
||||||
BACKUPS.service_type: BACKUPS,
|
BACKUPS.service_type: BACKUPS,
|
||||||
AUTHENTIK.service_type: AUTHENTIK,
|
AUTHENTIK.service_type: AUTHENTIK,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Remote machine service definition.
|
||||||
|
|
||||||
|
An ``remote_machine`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||||
|
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||||
|
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||||
|
and shown as history on the instance's service page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
TestResult,
|
||||||
|
WidgetConfigBase,
|
||||||
|
translate_connection_error,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
config: dict[str, Any],
|
||||||
|
secrets: dict[str, str],
|
||||||
|
store: SettingsStore,
|
||||||
|
) -> TestResult:
|
||||||
|
"""Build an SSH client via build_ssh_client and attempt .connect().
|
||||||
|
|
||||||
|
Reuses the same error-translation patterns as test_machine_ssh (banner,
|
||||||
|
auth failed). Known-host recording is preserved.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
|
host = str(config.get("host") or "").strip()
|
||||||
|
port = int(config.get("port") or 22)
|
||||||
|
try:
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="",
|
||||||
|
service_type="remote_machine",
|
||||||
|
name="test",
|
||||||
|
config=config,
|
||||||
|
secrets=secrets,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
client = build_ssh_client(store, service)
|
||||||
|
try:
|
||||||
|
client.connect()
|
||||||
|
except Exception as exc:
|
||||||
|
lowered = str(exc).lower()
|
||||||
|
if "protocol banner" in lowered:
|
||||||
|
return TestResult(
|
||||||
|
ok=False,
|
||||||
|
detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.",
|
||||||
|
)
|
||||||
|
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
||||||
|
return TestResult(
|
||||||
|
ok=False,
|
||||||
|
detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.",
|
||||||
|
)
|
||||||
|
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
return TestResult(
|
||||||
|
ok=True,
|
||||||
|
detail=f"SSH connection succeeded for {host}:{port}.",
|
||||||
|
evidence=f"Connected to {host}:{port}",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return TestResult(ok=False, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteMachineConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Remote machine config.
|
||||||
|
|
||||||
|
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||||
|
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int = 22
|
||||||
|
username: str = ""
|
||||||
|
ssh_key_id: str = ""
|
||||||
|
timeout_seconds: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteMachineTaskOutputWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Output of a saved task run on this instance."""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||||
|
service_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="remote_machine",
|
||||||
|
name="Remote machine",
|
||||||
|
description="SSH transport for files and reusable actions.",
|
||||||
|
config_model=RemoteMachineConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||||
|
SecretField(key="password", label="SSH password", helper="Optional"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="task_output",
|
||||||
|
name="Task output",
|
||||||
|
description="Output of a saved task run.",
|
||||||
|
model_cls=RemoteMachineTaskOutputWidgetConfig,
|
||||||
|
default_config={"task_id": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
test_callable=test_connection,
|
||||||
|
)
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""SSH task runner service definition.
|
|
||||||
|
|
||||||
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
|
||||||
Tasks themselves stay in the global saved-task registry; the instance only owns
|
|
||||||
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
|
||||||
and shown as history on the instance's service page.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
|
||||||
SecretField,
|
|
||||||
ServiceConfigBase,
|
|
||||||
ServiceDefinition,
|
|
||||||
WidgetConfigBase,
|
|
||||||
widget_kind,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SshTasksConfig(ServiceConfigBase):
|
|
||||||
"""Non-secret SSH task runner config.
|
|
||||||
|
|
||||||
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
|
||||||
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
|
||||||
"""
|
|
||||||
|
|
||||||
host: str
|
|
||||||
port: int = 22
|
|
||||||
username: str = ""
|
|
||||||
ssh_key_id: str = ""
|
|
||||||
timeout_seconds: int = 30
|
|
||||||
|
|
||||||
|
|
||||||
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
|
||||||
"""Output of a saved task run on this instance."""
|
|
||||||
|
|
||||||
task_id: str
|
|
||||||
# service_id is implicit (the widget's service); allow overriding per-widget.
|
|
||||||
service_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
|
||||||
service_type="ssh_tasks",
|
|
||||||
name="SSH task runner",
|
|
||||||
description="Run reusable saved tasks over SSH and keep run history.",
|
|
||||||
config_model=SshTasksConfig,
|
|
||||||
secret_fields=[
|
|
||||||
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
|
||||||
],
|
|
||||||
widget_kinds=[
|
|
||||||
widget_kind(
|
|
||||||
kind="task_output",
|
|
||||||
name="Task output",
|
|
||||||
description="Output of a saved task run.",
|
|
||||||
model_cls=SshTaskOutputWidgetConfig,
|
|
||||||
default_config={"task_id": ""},
|
|
||||||
refresh_interval_ms=0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -52,42 +52,6 @@ JOB_TEMPLATES: dict[str, JobTemplate] = {
|
|||||||
description="Lists empty directories under the selected path. Does not delete anything.",
|
description="Lists empty directories under the selected path. Does not delete anything.",
|
||||||
command_template="find {path} -type d -empty -print",
|
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}"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,36 @@ from media_library_viewer_api.routers import (
|
|||||||
from media_library_viewer_api.routers import backups as backups_router
|
from media_library_viewer_api.routers import backups as backups_router
|
||||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
||||||
from media_library_viewer_api.routers import dashboards as dashboards_router
|
from media_library_viewer_api.routers import dashboards as dashboards_router
|
||||||
|
from media_library_viewer_api.routers import jellyseerr as jellyseerr_router
|
||||||
|
from media_library_viewer_api.routers import scheduler as scheduler_router # type: ignore[reportAttributeAccessIssue]
|
||||||
from media_library_viewer_api.routers import services as services_router
|
from media_library_viewer_api.routers import services as services_router
|
||||||
from media_library_viewer_api.routers import widgets as widgets_router
|
from media_library_viewer_api.routers import widgets as widgets_router
|
||||||
from media_library_viewer_api.routers.settings import router as settings_router
|
from media_library_viewer_api.routers.settings import router as settings_router
|
||||||
|
|
||||||
from .services.backup_poller import get_backup_poller
|
from .services.backup_poller import get_backup_poller
|
||||||
|
from .services.scheduler import get_scheduler # type: ignore[reportMissingImports]
|
||||||
from .version import get_backend_version, get_version_info
|
from .version import get_backend_version, get_version_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_prometheus_gateway_config() -> None:
|
||||||
|
"""Warn (not crash) about old-shape prometheus services needing migration (GM-113)."""
|
||||||
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
|
for service in store.list_services("prometheus"):
|
||||||
|
config = service.get("config") or {}
|
||||||
|
if "base_url" in config and "grafana_url" not in config:
|
||||||
|
logger.warning(
|
||||||
|
"Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. "
|
||||||
|
"Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).",
|
||||||
|
service.get("name"),
|
||||||
|
service.get("id"),
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - startup best-effort
|
||||||
|
logger.exception("Failed to validate prometheus gateway config during startup")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan — startup/shutdown."""
|
"""Application lifespan — startup/shutdown."""
|
||||||
@@ -58,11 +78,15 @@ async def lifespan(app: FastAPI):
|
|||||||
get_service_data_harness()
|
get_service_data_harness()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to initialize service data harness during startup")
|
logger.exception("Failed to initialize service data harness during startup")
|
||||||
|
_validate_prometheus_gateway_config()
|
||||||
mail_queue = get_mail_queue()
|
mail_queue = get_mail_queue()
|
||||||
backup_poller = get_backup_poller()
|
backup_poller = get_backup_poller()
|
||||||
|
scheduler = get_scheduler()
|
||||||
mail_queue.start()
|
mail_queue.start()
|
||||||
backup_poller.start()
|
backup_poller.start()
|
||||||
|
scheduler.start()
|
||||||
yield
|
yield
|
||||||
|
scheduler.stop()
|
||||||
backup_poller.stop()
|
backup_poller.stop()
|
||||||
mail_queue.stop()
|
mail_queue.stop()
|
||||||
logger.info("Backend shutdown complete")
|
logger.info("Backend shutdown complete")
|
||||||
@@ -150,7 +174,9 @@ app.include_router(tasks.router)
|
|||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(backups_router.router)
|
app.include_router(backups_router.router)
|
||||||
app.include_router(widgets_router.router)
|
app.include_router(widgets_router.router)
|
||||||
|
app.include_router(scheduler_router.router)
|
||||||
app.include_router(dashboards_router.router)
|
app.include_router(dashboards_router.router)
|
||||||
|
app.include_router(jellyseerr_router.router)
|
||||||
app.include_router(services_router.router)
|
app.include_router(services_router.router)
|
||||||
app.include_router(authentik_users_router.router)
|
app.include_router(authentik_users_router.router)
|
||||||
|
|
||||||
@@ -177,4 +203,4 @@ def metrics() -> Response:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""API models for backend scheduled actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerStatus(BaseModel):
|
||||||
|
service_id: str
|
||||||
|
action_key: str
|
||||||
|
worker_running: bool
|
||||||
|
enabled: bool
|
||||||
|
running: bool = False
|
||||||
|
poll_interval_seconds: int = Field(ge=5, le=300)
|
||||||
|
sample_retention_seconds: int = Field(ge=60, le=86_400)
|
||||||
|
sample_max_rows: int = Field(ge=60, le=1_200)
|
||||||
|
next_run_at: int | None = None
|
||||||
|
last_attempt_at: int | None = None
|
||||||
|
last_success_at: int | None = None
|
||||||
|
last_error: str = ""
|
||||||
|
consecutive_failures: int = 0
|
||||||
|
backoff_until: int | None = None
|
||||||
|
is_stale: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerRun(BaseModel):
|
||||||
|
id: str
|
||||||
|
service_id: str
|
||||||
|
action_key: str
|
||||||
|
trigger: Literal["schedule", "manual"]
|
||||||
|
started_at: int
|
||||||
|
finished_at: int | None = None
|
||||||
|
status: Literal["running", "success", "failure", "cancelled"]
|
||||||
|
attempt: int = 0
|
||||||
|
duration_ms: int | None = None
|
||||||
|
error: str = ""
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerRunsResponse(BaseModel):
|
||||||
|
items: list[SchedulerRun]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerSample(BaseModel):
|
||||||
|
ts: int
|
||||||
|
dl_speed: int
|
||||||
|
up_speed: int
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerSamplesResponse(BaseModel):
|
||||||
|
service_id: str
|
||||||
|
window_seconds: int | None
|
||||||
|
all_values: bool = False
|
||||||
|
samples: list[SchedulerSample]
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerManualRunResponse(BaseModel):
|
||||||
|
run: SchedulerRun
|
||||||
|
status: SchedulerStatus
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerActionResult(BaseModel):
|
||||||
|
"""Internal-friendly result payload exposed for diagnostics/tests."""
|
||||||
|
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -77,6 +77,28 @@ MAIL_QUEUE_SIZE = Counter(
|
|||||||
["status"],
|
["status"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SCHEDULED_ACTIONS_TOTAL = Counter(
|
||||||
|
"manage_scheduled_actions_total",
|
||||||
|
"Total typed scheduled action attempts",
|
||||||
|
["service_id", "action", "status"],
|
||||||
|
)
|
||||||
|
SCHEDULED_ACTION_DURATION = Histogram(
|
||||||
|
"manage_scheduled_action_duration_seconds",
|
||||||
|
"Typed scheduled action duration",
|
||||||
|
["action"],
|
||||||
|
buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
||||||
|
)
|
||||||
|
SCHEDULED_ACTION_LAST_SUCCESS = Gauge(
|
||||||
|
"manage_scheduled_action_last_success_timestamp",
|
||||||
|
"Unix timestamp of the last successful typed scheduled action",
|
||||||
|
["service_id", "action"],
|
||||||
|
)
|
||||||
|
SCHEDULED_ACTION_FAILURES = Gauge(
|
||||||
|
"manage_scheduled_action_consecutive_failures",
|
||||||
|
"Current consecutive failure count for a typed scheduled action",
|
||||||
|
["service_id", "action"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_current_request_id(request_id: str | None) -> None:
|
def set_current_request_id(request_id: str | None) -> None:
|
||||||
"""Set the context-local request id."""
|
"""Set the context-local request id."""
|
||||||
@@ -147,6 +169,25 @@ def record_mail_queue(status: str) -> None:
|
|||||||
MAIL_QUEUE_SIZE.labels(status=status).inc()
|
MAIL_QUEUE_SIZE.labels(status=status).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_scheduled_action(
|
||||||
|
service_id: str,
|
||||||
|
action: str,
|
||||||
|
status: str,
|
||||||
|
duration_seconds: float | None = None,
|
||||||
|
success: bool = False,
|
||||||
|
consecutive_failures: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Record secret-safe metrics for a typed scheduled action."""
|
||||||
|
safe_service = service_id or "unknown"
|
||||||
|
safe_action = action or "unknown"
|
||||||
|
SCHEDULED_ACTIONS_TOTAL.labels(service_id=safe_service, action=safe_action, status=status).inc()
|
||||||
|
SCHEDULED_ACTION_FAILURES.labels(service_id=safe_service, action=safe_action).set(consecutive_failures)
|
||||||
|
if duration_seconds is not None:
|
||||||
|
SCHEDULED_ACTION_DURATION.labels(action=safe_action).observe(duration_seconds)
|
||||||
|
if success:
|
||||||
|
SCHEDULED_ACTION_LAST_SUCCESS.labels(service_id=safe_service, action=safe_action).set_to_current_time()
|
||||||
|
|
||||||
|
|
||||||
def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]:
|
def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||||
"""Build a standard extra dict for structured logging."""
|
"""Build a standard extra dict for structured logging."""
|
||||||
extra: dict[str, Any] = {"request_id": get_request_id(request)}
|
extra: dict[str, Any] = {"request_id": get_request_id(request)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/routers
|
dir: backend/src/media_library_viewer_api/routers
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
@@ -15,6 +15,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
|||||||
- dashboard.py
|
- dashboard.py
|
||||||
- dashboards.py
|
- dashboards.py
|
||||||
- files.py
|
- files.py
|
||||||
|
- jellyseerr.py
|
||||||
- jobs.py
|
- jobs.py
|
||||||
- media.py
|
- media.py
|
||||||
- monitoring.py
|
- monitoring.py
|
||||||
|
|||||||
@@ -4,25 +4,26 @@ dir: backend/src/media_library_viewer_api/routers
|
|||||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||||
## files
|
## files
|
||||||
- __init__.py | Marks the directory as a Python package for routers.
|
- __init__.py | Marks the directory as a Python package for routers.
|
||||||
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
|
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
|
||||||
- backups.py | FastAPI router for receiving backup run reports, managing backup jobs/runs, and generating/acknowledging backup alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
- backups.py | FastAPI router providing REST endpoints for reporting, querying, and managing backup jobs, runs, and alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(service_id, store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, service_id, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, service_id, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||||
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||||
- dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store
|
- dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store
|
||||||
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
||||||
|
- jellyseerr.py | FastAPI router providing Jellyseerr request stats and recent requests endpoints for the Jellyfin page. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException, func:get_jellyseerr_requests(jellyfin_service_id, store) → dict, call:resolve_service_record, call:fetch_jellyseer_requests, call:logger.exception, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.jellyseerr_stats, media_library_viewer_api.widgets.stats_provider
|
||||||
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||||
- media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index
|
- media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index
|
||||||
- monitoring.py | FastAPI router providing monitoring observability endpoints that proxy and aggregate status, alerts, and scrape targets from Alertmanager and Prometheus. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
- monitoring.py | FastAPI router providing observability endpoints for monitoring machines, Alertmanager alerts/status, Prometheus targets/status, and webhook ingestion. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → tuple[float, float], call:int, call:service.config.get, call:http_timeout, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:requests.post, call:http_timeout, call:resp.raise_for_status, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.clients.http_timeout, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager, fastapi.APIRouter
|
||||||
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
|
- services.py | Provides REST API endpoints for listing service types and CRUD-managing service instances, including a test endpoint that validates connectivity and credentials without persisting them. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException, func:test_instance(body: ServiceInstanceInput, store) → dict[str, Any], call:_validate_input, call:require_service_definition, call:dict, call:store.get_service, call:existing.get, call:decrypt_secrets, call:logger.exception, call:secrets.get, call:stored.get, call:logger.info, call:definition.test_callable, call:TestResult | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||||
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||||
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances and their references, including CRUD operations, data fetching, and live-link detachments. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
- widgets.py | Provides a FastAPI REST API for CRUD operations on dashboard widget instances and widget references (live-links), including data fetching through registered adapters. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_stats_adapter, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||||
## arch
|
## arch
|
||||||
Modular FastAPI APIRouter pattern where each domain module exports its own router instance; routers encapsulate endpoint definitions and delegate business logic to underlying service clients, SSH utilities, and subprocess workers.
|
Modular router-per-domain pattern where each file exposes a FastAPI APIRouter for a specific feature area; routers delegate business logic to service clients and adapters, using dependency injection for SSH/database access and standard Pydantic models for request/response validation.
|
||||||
## tags
|
## tags
|
||||||
call:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get
|
call:, service, raise:httpexception, media_library_viewer_api, get, backup, call:store.get, ssh
|
||||||
## symbols
|
## symbols
|
||||||
- MessageRequest
|
- MessageRequest
|
||||||
- RunJobRequest
|
- RunJobRequest
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""Authentik directory + messaging router.
|
"""Read-only Authentik directory, access metadata, and messaging router.
|
||||||
|
|
||||||
Resolves an ``authentik`` service instance from the registry, builds an
|
Directory data is service-scoped and fails gracefully so the service page can
|
||||||
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
render a useful empty/error state when Authentik is unavailable.
|
||||||
proxies paginated directory queries plus message-compose (email enqueue).
|
|
||||||
Graceful "not configured" / "unreachable" payloads (matching the monitoring
|
|
||||||
router's pattern) so the UI always renders.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,7 +9,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||||
@@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
|||||||
|
|
||||||
|
|
||||||
class MessageRequest(BaseModel):
|
class MessageRequest(BaseModel):
|
||||||
"""Compose-request body for the Authentik messaging endpoint."""
|
"""Compose-request body for the existing Authentik messaging endpoint."""
|
||||||
|
|
||||||
recipient_emails: list[str]
|
recipient_emails: list[str]
|
||||||
subject: str
|
subject: str
|
||||||
@@ -38,39 +35,99 @@ class MessageRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
|
||||||
api_token = str(service.secrets.get("api_token") or "")
|
|
||||||
try:
|
try:
|
||||||
timeout = float(service.config.get("timeout_seconds") or 10)
|
timeout = float(service.config.get("timeout_seconds") or 10)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
timeout = 10.0
|
timeout = 10.0
|
||||||
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
return AuthentikClient(
|
||||||
|
base_url=str(service.config.get("base_url") or "").rstrip("/"),
|
||||||
|
api_token=str(service.secrets.get("api_token") or ""),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _empty(error: str) -> dict[str, Any]:
|
def _empty_directory(error: str) -> dict[str, Any]:
|
||||||
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_collection(error: str) -> dict[str, Any]:
|
||||||
|
return {"items": [], "total": 0, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None:
|
||||||
|
return resolve_service_record(store, "authentik", service_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{service_id}/users")
|
@router.get("/{service_id}/users")
|
||||||
def get_authentik_users(
|
def get_authentik_users(
|
||||||
service_id: str,
|
service_id: str,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
page: int = 1,
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = 50,
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Paginated Authentik user directory for a specific service instance."""
|
"""Paginated raw directory users for the existing messaging surface."""
|
||||||
service = resolve_service_record(store, "authentik", service_id)
|
service = _service_or_error(store, service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
return _empty_directory("Authentik service not configured")
|
||||||
return _empty("Authentik service not configured")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = _build_client(service)
|
return _build_client(service).users(search=search, page=page, page_size=page_size)
|
||||||
return client.users(search=search, page=page, page_size=page_size)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Authentik users query failed for service %s", service_id)
|
logger.exception("Authentik users query failed for service %s", service_id)
|
||||||
return _empty("Authentik is unreachable")
|
return _empty_directory("Authentik is unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service_id}/access-summary")
|
||||||
|
def get_authentik_access_summary(
|
||||||
|
service_id: str,
|
||||||
|
search: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""User groups plus explicit staff/superuser flags, not effective permissions."""
|
||||||
|
service = _service_or_error(store, service_id)
|
||||||
|
if service is None:
|
||||||
|
return _empty_directory("Authentik service not configured")
|
||||||
|
try:
|
||||||
|
return _build_client(service).access_summaries(search=search, page=page, page_size=page_size)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Authentik access summary query failed for service %s", service_id)
|
||||||
|
return _empty_directory("Authentik is unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service_id}/groups")
|
||||||
|
def get_authentik_groups(
|
||||||
|
service_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Display-safe, service-scoped Authentik group list."""
|
||||||
|
service = _service_or_error(store, service_id)
|
||||||
|
if service is None:
|
||||||
|
return _empty_collection("Authentik service not configured")
|
||||||
|
try:
|
||||||
|
return _build_client(service).groups(limit=limit)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Authentik groups query failed for service %s", service_id)
|
||||||
|
return _empty_collection("Authentik is unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service_id}/applications")
|
||||||
|
def get_authentik_applications(
|
||||||
|
service_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Display-safe Authentik applications without provider or policy details."""
|
||||||
|
service = _service_or_error(store, service_id)
|
||||||
|
if service is None:
|
||||||
|
return _empty_collection("Authentik service not configured")
|
||||||
|
try:
|
||||||
|
return _build_client(service).applications(limit=limit)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Authentik applications query failed for service %s", service_id)
|
||||||
|
return _empty_collection("Authentik is unreachable")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{service_id}/message/status")
|
@router.get("/{service_id}/message/status")
|
||||||
@@ -80,8 +137,7 @@ def get_authentik_message_status(
|
|||||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||||
service = resolve_service_record(store, "authentik", service_id)
|
if _service_or_error(store, service_id) is None:
|
||||||
if service is None:
|
|
||||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||||
return mail_queue.status()
|
return mail_queue.status()
|
||||||
|
|
||||||
@@ -94,20 +150,16 @@ def post_authentik_message(
|
|||||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||||
service = resolve_service_record(store, "authentik", service_id)
|
if _service_or_error(store, service_id) is None:
|
||||||
if service is None:
|
|
||||||
return {"status": "error", "error": "Authentik service not configured"}
|
return {"status": "error", "error": "Authentik service not configured"}
|
||||||
|
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
|
||||||
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
|
|
||||||
if not recipients:
|
if not recipients:
|
||||||
return {"status": "error", "error": "No recipients with valid email addresses."}
|
return {"status": "error", "error": "No recipients with valid email addresses."}
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
try:
|
try:
|
||||||
validate_smtp_settings(settings)
|
validate_smtp_settings(settings)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
||||||
|
|
||||||
request_id = mail_queue.enqueue(
|
request_id = mail_queue.enqueue(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
recipients=recipients,
|
recipients=recipients,
|
||||||
@@ -115,8 +167,4 @@ def post_authentik_message(
|
|||||||
html_body=body.html_body,
|
html_body=body.html_body,
|
||||||
)
|
)
|
||||||
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
||||||
return {
|
return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}
|
||||||
"status": "queued",
|
|
||||||
"request_id": request_id,
|
|
||||||
"recipient_count": len(recipients),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -129,9 +129,10 @@ def post_backup_start(
|
|||||||
|
|
||||||
@router.get("/jobs")
|
@router.get("/jobs")
|
||||||
def get_backup_jobs(
|
def get_backup_jobs(
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
jobs = store.list_backup_jobs()
|
jobs = store.list_backup_jobs(service_id=service_id)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
@@ -155,9 +156,10 @@ def get_backup_runs(
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[BackupRunResponse]:
|
) -> list[BackupRunResponse]:
|
||||||
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit)
|
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id)
|
||||||
return [BackupRunResponse(**run) for run in runs]
|
return [BackupRunResponse(**run) for run in runs]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,9 +179,15 @@ def get_backup_alerts(
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
acknowledged: bool | None = None,
|
acknowledged: bool | None = None,
|
||||||
severity: str | None = None,
|
severity: str | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[BackupAlertResponse]:
|
) -> list[BackupAlertResponse]:
|
||||||
alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity)
|
alerts = store.list_backup_alerts(
|
||||||
|
job_id=job_id,
|
||||||
|
acknowledged=acknowledged,
|
||||||
|
severity=severity,
|
||||||
|
service_id=service_id,
|
||||||
|
)
|
||||||
return [BackupAlertResponse(**alert) for alert in alerts]
|
return [BackupAlertResponse(**alert) for alert in alerts]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Jellyseerr request stats — powers the Requests tab on the Jellyfin page.
|
||||||
|
|
||||||
|
Jellyseerr is an optional companion of the Jellyfin service. This router
|
||||||
|
resolves the Jellyfin service instance (by ``jellyfin_service_id`` or the first
|
||||||
|
enabled one) and delegates to the registered Jellyseerr stats provider, which
|
||||||
|
shares its short-TTL cache with the ``stat`` / ``stats_overview`` widgets so
|
||||||
|
the tab and the widgets don't each hit Jellyseerr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — ensure provider registration
|
||||||
|
from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests
|
||||||
|
from media_library_viewer_api.widgets.stats_provider import get_stats_provider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/jellyseerr", tags=["jellyseerr"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(result) -> dict:
|
||||||
|
return {
|
||||||
|
"stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats],
|
||||||
|
"recent": result.recent,
|
||||||
|
"detail": result.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_jellyseerr_stats(
|
||||||
|
jellyfin_service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict:
|
||||||
|
"""Return Jellyseerr request counts + a recent-requests list."""
|
||||||
|
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
|
||||||
|
provider = get_stats_provider("jellyfin")
|
||||||
|
if provider is None: # pragma: no cover - registered at import
|
||||||
|
raise HTTPException(status_code=503, detail="Jellyseerr stats provider is not available.")
|
||||||
|
try:
|
||||||
|
result = provider.fetch_stats(service)
|
||||||
|
except Exception as exc: # pragma: no cover - provider guards internally
|
||||||
|
logger.exception("Jellyseerr stats endpoint failed")
|
||||||
|
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests")
|
||||||
|
def get_jellyseerr_requests(
|
||||||
|
jellyfin_service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict:
|
||||||
|
"""Return Jellyseerr requests for the Requests tab table (filter/sort client-side)."""
|
||||||
|
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
|
||||||
|
try:
|
||||||
|
requests = fetch_jellyseer_requests(service)
|
||||||
|
except Exception as exc: # pragma: no cover - client guards internally
|
||||||
|
logger.exception("Jellyseerr requests endpoint failed")
|
||||||
|
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
|
||||||
|
return {"requests": requests}
|
||||||
@@ -14,10 +14,10 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Body, Depends
|
from fastapi import APIRouter, Body, Depends
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import http_timeout
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
|
||||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -27,8 +27,10 @@ def _base_url(service: ServiceRecord) -> str:
|
|||||||
return str(service.config.get("base_url") or "").rstrip("/")
|
return str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def _timeout(service: ServiceRecord, default: int) -> int:
|
def _timeout(service: ServiceRecord, default: int) -> tuple[float, float]:
|
||||||
return int(service.config.get("timeout_seconds") or default)
|
"""Return a (connect, read) timeout tuple from the service config."""
|
||||||
|
read = int(service.config.get("timeout_seconds") or default)
|
||||||
|
return http_timeout(read)
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
||||||
@@ -56,21 +58,6 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
|||||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/machines")
|
|
||||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
|
||||||
"""Return enabled monitoring machines for the UI."""
|
|
||||||
return [m for m in store.list_machines() if m.get("enabled")]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prometheus-targets")
|
|
||||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
|
||||||
"""Return Prometheus scrape targets for remote Node Exporters.
|
|
||||||
|
|
||||||
External Prometheus instances consume this list via ``http_sd_configs``.
|
|
||||||
"""
|
|
||||||
targets = build_node_exporter_targets(store)
|
|
||||||
logger.info("Prometheus targets requested count=%s", len(targets))
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/alerts")
|
@router.get("/alerts")
|
||||||
@@ -177,23 +164,52 @@ def get_prometheus_status(
|
|||||||
service_id: str | None = None,
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Probe a Prometheus service instance's health and build info."""
|
"""Probe a Prometheus service's health via the Grafana gateway path (GM-110).
|
||||||
|
|
||||||
|
Issues a trivial ``up`` query through Grafana ``/api/ds/query``. Success
|
||||||
|
validates the full path: Grafana is reachable, the API key works, and the
|
||||||
|
Prometheus datasource responds.
|
||||||
|
"""
|
||||||
service = resolve_service_record(store, "prometheus", service_id)
|
service = resolve_service_record(store, "prometheus", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return _status_response(None, error="no_service_configured")
|
return _status_response(None, error="no_service_configured")
|
||||||
base = _base_url(service)
|
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||||
timeout = _timeout(service, 10)
|
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||||
headers = _auth_headers(service)
|
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||||
|
if not grafana_url or not api_key:
|
||||||
|
return _status_response(service, error="gateway_not_configured")
|
||||||
|
body = {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||||
|
"expr": "up",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalMs": 15_000,
|
||||||
|
"maxDataPoints": 1,
|
||||||
|
"refId": "A",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"from": "now-1m",
|
||||||
|
"to": "now",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
resp = requests.post(
|
||||||
health.raise_for_status()
|
f"{grafana_url}/api/ds/query",
|
||||||
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
json=body,
|
||||||
build_info.raise_for_status()
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
version = build_info.json().get("data", {}).get("version", "")
|
timeout=http_timeout(timeout),
|
||||||
except Exception:
|
)
|
||||||
logger.exception("Failed to fetch Prometheus status")
|
resp.raise_for_status()
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
status_code = exc.response.status_code if exc.response else 0
|
||||||
|
if status_code in (401, 403):
|
||||||
|
return _status_response(service, error="auth_failed")
|
||||||
|
return _status_response(service, error="gateway_error")
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.exception("Failed to fetch Prometheus status via gateway")
|
||||||
return _status_response(service, error="prometheus_unreachable")
|
return _status_response(service, error="prometheus_unreachable")
|
||||||
return _status_response(service, version=version)
|
return _status_response(service, version="ok")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alertmanager-webhook")
|
@router.post("/alertmanager-webhook")
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Endpoints for typed scheduled-action status and history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.models.scheduler import ( # type: ignore[reportMissingImports]
|
||||||
|
SchedulerManualRunResponse,
|
||||||
|
SchedulerRun,
|
||||||
|
SchedulerRunsResponse,
|
||||||
|
SchedulerSamplesResponse,
|
||||||
|
SchedulerStatus,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||||
|
from media_library_viewer_api.services.scheduler import ( # type: ignore[reportMissingImports]
|
||||||
|
SchedulerBusyError,
|
||||||
|
get_scheduler,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.scheduler_actions import (
|
||||||
|
QBITTORRENT_SPEED_ACTION, # type: ignore[reportMissingImports]
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.scheduler_store import SchedulerRunStore # type: ignore[reportMissingImports]
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _require_qbittorrent(service_id: str, store: SettingsStore) -> dict[str, Any]:
|
||||||
|
service = store.get_service(service_id)
|
||||||
|
if not service or service.get("service_type") != "qbittorrent":
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="qBittorrent service not found")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services/{service_id}/status", response_model=SchedulerStatus)
|
||||||
|
def get_scheduler_status(
|
||||||
|
service_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> SchedulerStatus:
|
||||||
|
_require_qbittorrent(service_id, store)
|
||||||
|
try:
|
||||||
|
return SchedulerStatus(**get_scheduler().status(service_id, store))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services/{service_id}/runs", response_model=SchedulerRunsResponse)
|
||||||
|
def get_scheduler_runs(
|
||||||
|
service_id: str,
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
trigger: str | None = None,
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> SchedulerRunsResponse:
|
||||||
|
_require_qbittorrent(service_id, store)
|
||||||
|
items, total = SchedulerRunStore().list_runs(
|
||||||
|
service_id,
|
||||||
|
QBITTORRENT_SPEED_ACTION,
|
||||||
|
status=status_filter,
|
||||||
|
trigger=trigger,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return SchedulerRunsResponse(
|
||||||
|
items=[SchedulerRun(**item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/services/{service_id}/run", response_model=SchedulerManualRunResponse)
|
||||||
|
def run_scheduler_action(
|
||||||
|
service_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> SchedulerManualRunResponse:
|
||||||
|
_require_qbittorrent(service_id, store)
|
||||||
|
try:
|
||||||
|
result = get_scheduler().run_now(service_id, store)
|
||||||
|
except SchedulerBusyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
return SchedulerManualRunResponse(
|
||||||
|
run=SchedulerRun(**result["run"]),
|
||||||
|
status=SchedulerStatus(**result["status"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services/{service_id}/samples", response_model=SchedulerSamplesResponse)
|
||||||
|
def get_scheduler_samples(
|
||||||
|
service_id: str,
|
||||||
|
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
|
||||||
|
all_values: bool = Query(default=False),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> SchedulerSamplesResponse:
|
||||||
|
_require_qbittorrent(service_id, store)
|
||||||
|
sample_store = QbittorrentSampleStore()
|
||||||
|
if all_values:
|
||||||
|
samples = sample_store.window(service_id)
|
||||||
|
response_window: int | None = None
|
||||||
|
else:
|
||||||
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
|
samples = sample_store.window(service_id, since_ts=since_ts)
|
||||||
|
response_window = window_seconds
|
||||||
|
return SchedulerSamplesResponse(
|
||||||
|
service_id=service_id,
|
||||||
|
window_seconds=response_window,
|
||||||
|
all_values=all_values,
|
||||||
|
samples=samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -12,7 +12,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.integrations.base import validate_config
|
from media_library_viewer_api.integrations.base import TestResult, validate_config
|
||||||
from media_library_viewer_api.integrations.registry import (
|
from media_library_viewer_api.integrations.registry import (
|
||||||
SERVICE_DEFINITIONS,
|
SERVICE_DEFINITIONS,
|
||||||
get_service_definition,
|
get_service_definition,
|
||||||
@@ -180,3 +180,50 @@ def delete_instance(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||||
store.delete_service(service_id)
|
store.delete_service(service_id)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
def test_instance(
|
||||||
|
body: ServiceInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Test connectivity + credentials for unsaved service input.
|
||||||
|
|
||||||
|
Validates first (422 on malformed config), dispatches to the per-type
|
||||||
|
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
|
||||||
|
"""
|
||||||
|
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
|
||||||
|
definition = require_service_definition(body.service_type)
|
||||||
|
|
||||||
|
# When editing an existing service, secret fields are masked and not
|
||||||
|
# re-entered (the UI says "leave blank to keep current"), so body.secrets
|
||||||
|
# only carries freshly-typed values. Fall back to the stored (decrypted)
|
||||||
|
# secret for any known key that is absent or blank, so the test runs with
|
||||||
|
# the effective credentials rather than failing auth on empty fields.
|
||||||
|
secrets = dict(body.secrets)
|
||||||
|
if body.id:
|
||||||
|
existing = store.get_service(body.id)
|
||||||
|
if existing and existing.get("service_type") == body.service_type:
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
stored: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
stored = decrypt_secrets(existing.get("secrets") or {})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to decrypt stored secrets for test service_id=%s", body.id)
|
||||||
|
for key in definition.secret_keys:
|
||||||
|
if not secrets.get(key) and stored.get(key):
|
||||||
|
secrets[key] = stored[key]
|
||||||
|
|
||||||
|
if definition.test_callable is None:
|
||||||
|
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
|
||||||
|
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result: TestResult = definition.test_callable(body.config, secrets, store)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("test_callable raised for type=%s", body.service_type)
|
||||||
|
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
|
||||||
|
|
||||||
|
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
|
||||||
|
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import paramiko
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
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_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
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.media_index import MediaIndex
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
@@ -23,188 +20,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
class MonitoringMachineInput(BaseModel):
|
|
||||||
"""Payload for creating or updating a machine."""
|
|
||||||
|
|
||||||
id: str | None = None
|
|
||||||
name: str = Field(default="")
|
|
||||||
mode: str = Field(default="local", description="local or ssh")
|
|
||||||
enabled: bool = True
|
|
||||||
services: list[str] = Field(default_factory=list)
|
|
||||||
host: str = ""
|
|
||||||
port: int = 22
|
|
||||||
username: str = ""
|
|
||||||
key_directory: str = ""
|
|
||||||
key_name: str = ""
|
|
||||||
ssh_key_id: str = ""
|
|
||||||
ssh_private_key: str = ""
|
|
||||||
ssh_private_key_passphrase: str = ""
|
|
||||||
password: str = ""
|
|
||||||
notes: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/machines")
|
|
||||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
|
||||||
return store.list_machines()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ssh_client(
|
|
||||||
machine: MonitoringMachineInput,
|
|
||||||
store: SettingsStore,
|
|
||||||
) -> tuple[RemoteSSHClient, str, int]:
|
|
||||||
host = machine.host.strip()
|
|
||||||
username = machine.username.strip()
|
|
||||||
port = int(machine.port or 22)
|
|
||||||
if not host or not username:
|
|
||||||
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
|
|
||||||
|
|
||||||
private_key = machine.ssh_private_key
|
|
||||||
passphrase = machine.ssh_private_key_passphrase
|
|
||||||
if machine.ssh_key_id:
|
|
||||||
ssh_key = store.get_ssh_key(machine.ssh_key_id)
|
|
||||||
if ssh_key:
|
|
||||||
private_key = str(ssh_key.get("private_key") or private_key)
|
|
||||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
|
||||||
|
|
||||||
key_filename = ""
|
|
||||||
if machine.key_directory and machine.key_name:
|
|
||||||
key_filename = f"{machine.key_directory}/{machine.key_name}"
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
client = RemoteSSHClient(
|
|
||||||
host=host,
|
|
||||||
username=username,
|
|
||||||
port=port,
|
|
||||||
key_filename=key_filename or None,
|
|
||||||
private_key=private_key or None,
|
|
||||||
private_key_passphrase=passphrase or None,
|
|
||||||
password=machine.password or None,
|
|
||||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
|
||||||
)
|
|
||||||
return client, host, port
|
|
||||||
|
|
||||||
|
|
||||||
def _raise_ssh_validation_error(host: str, port: int, exc: Exception) -> None:
|
|
||||||
message = str(exc)
|
|
||||||
lowered = message.lower()
|
|
||||||
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."),
|
|
||||||
) from exc
|
|
||||||
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail=(
|
|
||||||
f"SSH authentication failed for {host}:{port}. "
|
|
||||||
"Check the selected SSH key, passphrase, username, or password."
|
|
||||||
),
|
|
||||||
) from exc
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=f"SSH validation failed for {host}:{port}: {message}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) -> None:
|
|
||||||
if str(machine.mode or "").strip().lower() != "ssh":
|
|
||||||
return
|
|
||||||
client, host, port = _resolve_ssh_client(machine, store)
|
|
||||||
try:
|
|
||||||
client.connect()
|
|
||||||
except Exception as exc:
|
|
||||||
_raise_ssh_validation_error(host, port, exc)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/machines/test-ssh")
|
|
||||||
def test_machine_ssh(
|
|
||||||
machine: MonitoringMachineInput,
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
client, host, port = _resolve_ssh_client(machine, store)
|
|
||||||
settings = get_settings()
|
|
||||||
known_hosts_updated = not has_known_host(host, port, settings.ssh_known_hosts_file)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client.connect()
|
|
||||||
except Exception as exc:
|
|
||||||
message = str(exc)
|
|
||||||
lowered = message.lower()
|
|
||||||
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 recorded the host key, "
|
|
||||||
"but SSH auth could not be validated. Confirm the SSH service is running."
|
|
||||||
),
|
|
||||||
) from exc
|
|
||||||
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail=(
|
|
||||||
f"SSH banner received from {host}:{port}, but authentication failed. "
|
|
||||||
"Check the selected SSH key, passphrase, username, or password."
|
|
||||||
),
|
|
||||||
) from exc
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=f"SSH validation failed for {host}:{port}: {message}",
|
|
||||||
) from exc
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"message": (
|
|
||||||
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,
|
|
||||||
"known_hosts_updated": known_hosts_updated,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/machines", status_code=status.HTTP_201_CREATED)
|
|
||||||
def post_machine(
|
|
||||||
machine: MonitoringMachineInput,
|
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/machines/{machine_id}")
|
|
||||||
def put_machine(
|
|
||||||
machine_id: str,
|
|
||||||
machine: MonitoringMachineInput,
|
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
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)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/machines/{machine_id}")
|
|
||||||
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
|
||||||
if not store.get_machine(machine_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
|
||||||
store.delete_machine(machine_id)
|
|
||||||
return {"status": "deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyInput(BaseModel):
|
class SSHKeyInput(BaseModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str = Field(default="")
|
name: str = Field(default="")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class TaskInput(BaseModel):
|
|||||||
task_type: str = Field(default="shell", description="shell or python")
|
task_type: str = Field(default="shell", description="shell or python")
|
||||||
content: str = Field(default="")
|
content: str = Field(default="")
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
default_service_id: str = ""
|
service_id: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -38,18 +38,16 @@ def _service_label(service: dict[str, Any] | None) -> str:
|
|||||||
return str(service.get("name") or service.get("id") or "")
|
return str(service.get("name") or service.get("id") or "")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_service_for_task(
|
def _owned_remote_machine(store: SettingsStore, service_id: str) -> dict[str, Any] | None:
|
||||||
store: SettingsStore,
|
service = store.get_service(service_id)
|
||||||
task: dict[str, Any],
|
if service and service.get("service_type") == "remote_machine" and service.get("enabled", True):
|
||||||
service_id: str | None,
|
return service
|
||||||
) -> dict[str, Any] | None:
|
return None
|
||||||
if service_id:
|
|
||||||
return store.get_service(service_id)
|
|
||||||
default_service_id = str(task.get("default_service_id") or "").strip()
|
def _require_task_owner(task: dict[str, Any], service_id: str) -> None:
|
||||||
if default_service_id:
|
if str(task.get("service_id") or "") != service_id:
|
||||||
return store.get_service(default_service_id)
|
raise HTTPException(status_code=404, detail="Task not found for this remote machine service")
|
||||||
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
|
|
||||||
return services[0] if services else None
|
|
||||||
|
|
||||||
|
|
||||||
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
|
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
|
||||||
@@ -60,12 +58,23 @@ def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
def list_tasks(
|
||||||
return store.list_tasks()
|
service_id: str = Query(..., min_length=1),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not _owned_remote_machine(store, service_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
|
||||||
|
return [task for task in store.list_tasks() if task.get("service_id") == service_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_task_owner(task: TaskInput, store: SettingsStore) -> None:
|
||||||
|
if not task.service_id or not _owned_remote_machine(store, task.service_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Task owner must be an enabled remote machine service")
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||||
|
_validate_task_owner(task, store)
|
||||||
return store.upsert_task(task.model_dump(exclude_none=True), task.id)
|
return store.upsert_task(task.model_dump(exclude_none=True), task.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,13 +82,20 @@ def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_sto
|
|||||||
def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||||
if not store.get_task(task_id):
|
if not store.get_task(task_id):
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
_validate_task_owner(task, store)
|
||||||
return store.upsert_task(task.model_dump(exclude_none=True), task_id)
|
return store.upsert_task(task.model_dump(exclude_none=True), task_id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}")
|
@router.delete("/{task_id}")
|
||||||
def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
def delete_task(
|
||||||
if not store.get_task(task_id):
|
task_id: str,
|
||||||
|
service_id: str = Query(..., min_length=1),
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
_require_task_owner(task, service_id)
|
||||||
store.delete_task(task_id)
|
store.delete_task(task_id)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
@@ -87,19 +103,22 @@ def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)
|
|||||||
@router.get("/{task_id}/runs")
|
@router.get("/{task_id}/runs")
|
||||||
def list_task_runs(
|
def list_task_runs(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
service_id: str = Query(..., min_length=1),
|
||||||
limit: int = Query(default=10, ge=1, le=50),
|
limit: int = Query(default=10, ge=1, le=50),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not store.get_task(task_id):
|
task = store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
|
_require_task_owner(task, service_id)
|
||||||
|
runs = store.list_service_task_runs(service_id=service_id, task_id=task_id, limit=limit)
|
||||||
return {"items": runs, "total": len(runs)}
|
return {"items": runs, "total": len(runs)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run")
|
@router.post("/run")
|
||||||
def run_task(
|
def run_task(
|
||||||
request: RunTaskRequest,
|
request: RunTaskRequest,
|
||||||
service_id: str | None = Query(default=None),
|
service_id: str = Query(..., min_length=1),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
task = store.get_task(request.task_id)
|
task = store.get_task(request.task_id)
|
||||||
@@ -108,11 +127,10 @@ def run_task(
|
|||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
raise HTTPException(status_code=400, detail="Task is disabled")
|
raise HTTPException(status_code=400, detail="Task is disabled")
|
||||||
|
|
||||||
service_row = _resolve_service_for_task(store, task, service_id)
|
_require_task_owner(task, service_id)
|
||||||
|
service_row = _owned_remote_machine(store, service_id)
|
||||||
if not service_row:
|
if not service_row:
|
||||||
raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
|
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
|
||||||
if not service_row.get("enabled", True):
|
|
||||||
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
|
|
||||||
|
|
||||||
service = _service_row_to_record(service_row)
|
service = _service_row_to_record(service_row)
|
||||||
result = run_saved_task(store, task, service)
|
result = run_saved_task(store, task, service)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from media_library_viewer_api.widgets.sources import (
|
|||||||
build_service_record,
|
build_service_record,
|
||||||
get_builtin_adapter,
|
get_builtin_adapter,
|
||||||
get_service_adapter,
|
get_service_adapter,
|
||||||
|
get_stats_adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -199,7 +200,11 @@ async def fetch_data(
|
|||||||
error="Service is disabled",
|
error="Service is disabled",
|
||||||
fetched_at=int(time.time()),
|
fetched_at=int(time.time()),
|
||||||
).model_dump()
|
).model_dump()
|
||||||
adapter = get_service_adapter(service_row["service_type"])
|
adapter = (
|
||||||
|
get_stats_adapter()
|
||||||
|
if widget_kind in ("stat", "stats_overview")
|
||||||
|
else get_service_adapter(service_row["service_type"])
|
||||||
|
)
|
||||||
if adapter is None:
|
if adapter is None:
|
||||||
return WidgetDataResponse(
|
return WidgetDataResponse(
|
||||||
widget_id=widget_id,
|
widget_id=widget_id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/services
|
dir: backend/src/media_library_viewer_api/services
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Backend service layer providing business logic for media indexing, backup monitoring, email delivery, secrets management, task execution, and persistent storage operations.
|
Backend service layer providing business logic for backup monitoring, media indexing, email delivery, task execution, secrets management, and persistent data storage.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ the first real consumer of the harness lifecycle layer.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from media_library_viewer_api.services.service_data import StorageConcern
|
from media_library_viewer_api.services.service_data import StorageConcern
|
||||||
@@ -37,8 +38,17 @@ QBITTORRENT_CONCERN = StorageConcern(
|
|||||||
tables=["qbittorrent_speed_samples"],
|
tables=["qbittorrent_speed_samples"],
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
|
#: Maximum samples kept per service. The scheduler may choose a lower cap.
|
||||||
MAX_SAMPLES = 120
|
MAX_SAMPLES = 1_200
|
||||||
|
MIN_SAMPLE_ROWS = 60
|
||||||
|
DEFAULT_RETENTION_SECONDS = 1_800
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentSampleStore:
|
class QbittorrentSampleStore:
|
||||||
@@ -56,22 +66,53 @@ class QbittorrentSampleStore:
|
|||||||
harness = get_service_data_harness()
|
harness = get_service_data_harness()
|
||||||
self._harness = harness
|
self._harness = harness
|
||||||
|
|
||||||
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
def append(
|
||||||
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
|
self,
|
||||||
|
service_id: str,
|
||||||
|
ts: int,
|
||||||
|
dl_speed: int,
|
||||||
|
up_speed: int,
|
||||||
|
*,
|
||||||
|
retention_seconds: int | None = None,
|
||||||
|
max_rows: int = MAX_SAMPLES,
|
||||||
|
) -> None:
|
||||||
|
"""Append a sample and prune by the configured time and row limits."""
|
||||||
|
max_rows = max(MIN_SAMPLE_ROWS, min(_safe_int(max_rows, MAX_SAMPLES), MAX_SAMPLES))
|
||||||
|
cutoff = None
|
||||||
|
if retention_seconds is not None:
|
||||||
|
retention = max(MIN_SAMPLE_ROWS, _safe_int(retention_seconds, DEFAULT_RETENTION_SECONDS))
|
||||||
|
cutoff = _safe_int(time.time()) - retention
|
||||||
with self._harness.connect("qbittorrent") as conn:
|
with self._harness.connect("qbittorrent") as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||||
(service_id, ts, dl_speed, up_speed),
|
(service_id, _safe_int(ts), _safe_int(dl_speed), _safe_int(up_speed)),
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM qbittorrent_speed_samples "
|
|
||||||
"WHERE service_id = ? AND ts NOT IN ("
|
|
||||||
" SELECT ts FROM qbittorrent_speed_samples"
|
|
||||||
" WHERE service_id = ?"
|
|
||||||
" ORDER BY ts DESC LIMIT ?"
|
|
||||||
")",
|
|
||||||
(service_id, service_id, MAX_SAMPLES),
|
|
||||||
)
|
)
|
||||||
|
if cutoff is None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM qbittorrent_speed_samples
|
||||||
|
WHERE service_id = ? AND rowid NOT IN (
|
||||||
|
SELECT rowid FROM qbittorrent_speed_samples
|
||||||
|
WHERE service_id = ?
|
||||||
|
ORDER BY ts DESC, rowid DESC LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(service_id, service_id, max_rows),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM qbittorrent_speed_samples
|
||||||
|
WHERE service_id = ? AND (
|
||||||
|
ts < ? OR rowid NOT IN (
|
||||||
|
SELECT rowid FROM qbittorrent_speed_samples
|
||||||
|
WHERE service_id = ?
|
||||||
|
ORDER BY ts DESC, rowid DESC LIMIT ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(service_id, cutoff, service_id, max_rows),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Single-worker scheduler for typed backend actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.observability import record_scheduled_action
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||||
|
|
||||||
|
from .scheduler_actions import ( # type: ignore[reportMissingImports]
|
||||||
|
QBITTORRENT_SPEED_ACTION,
|
||||||
|
get_scheduled_action,
|
||||||
|
)
|
||||||
|
from .scheduler_store import SchedulerRunStore # type: ignore[reportMissingImports]
|
||||||
|
from .settings_store import SettingsStore, get_settings_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_INTERVAL_SECONDS = 15
|
||||||
|
MIN_INTERVAL_SECONDS = 5
|
||||||
|
MAX_INTERVAL_SECONDS = 300
|
||||||
|
DEFAULT_RETENTION_SECONDS = 1_800
|
||||||
|
MAX_RETENTION_SECONDS = 86_400
|
||||||
|
DEFAULT_MAX_ROWS = 1_200
|
||||||
|
MAX_MAX_ROWS = 1_200
|
||||||
|
SCHEDULER_LOOP_SECONDS = 1.0
|
||||||
|
BACKOFF_CAP_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerBusyError(RuntimeError):
|
||||||
|
"""Raised when a manual action overlaps an existing service run."""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_config(config: dict[str, Any]) -> tuple[int, int, int]:
|
||||||
|
interval = max(
|
||||||
|
MIN_INTERVAL_SECONDS,
|
||||||
|
min(_safe_int(config.get("poll_interval_seconds"), DEFAULT_INTERVAL_SECONDS), MAX_INTERVAL_SECONDS),
|
||||||
|
)
|
||||||
|
retention = max(
|
||||||
|
60,
|
||||||
|
min(_safe_int(config.get("sample_retention_seconds"), DEFAULT_RETENTION_SECONDS), MAX_RETENTION_SECONDS),
|
||||||
|
)
|
||||||
|
max_rows = max(60, min(_safe_int(config.get("sample_max_rows"), DEFAULT_MAX_ROWS), MAX_MAX_ROWS))
|
||||||
|
return interval, retention, max_rows
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ServiceState:
|
||||||
|
signature: tuple[Any, ...]
|
||||||
|
next_run_at: float | None = None
|
||||||
|
running: bool = False
|
||||||
|
last_attempt_at: int | None = None
|
||||||
|
last_success_at: int | None = None
|
||||||
|
last_error: str = ""
|
||||||
|
consecutive_failures: int = 0
|
||||||
|
backoff_until: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
"""Run registered service actions from one lifespan-managed worker."""
|
||||||
|
|
||||||
|
action_key = QBITTORRENT_SPEED_ACTION
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._state_lock = threading.RLock()
|
||||||
|
self._service_locks: dict[str, threading.Lock] = {}
|
||||||
|
self._states: dict[str, _ServiceState] = {}
|
||||||
|
self._run_store = SchedulerRunStore()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run, name="scheduled-actions", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Scheduled action worker started")
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
thread = self._thread
|
||||||
|
if not thread:
|
||||||
|
return
|
||||||
|
self._stop_event.set()
|
||||||
|
thread.join(timeout=timeout)
|
||||||
|
if thread.is_alive():
|
||||||
|
logger.warning("Scheduled action worker did not stop within %.1fs", timeout)
|
||||||
|
else:
|
||||||
|
logger.info("Scheduled action worker stopped")
|
||||||
|
|
||||||
|
def status(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
|
||||||
|
store = store or get_settings_store()
|
||||||
|
service = store.get_service(service_id)
|
||||||
|
if not service or service.get("service_type") != "qbittorrent":
|
||||||
|
raise ValueError("qBittorrent service not found")
|
||||||
|
config = service.get("config") or {}
|
||||||
|
interval, retention, max_rows = _bounded_config(config)
|
||||||
|
with self._state_lock:
|
||||||
|
state = self._states.get(service_id)
|
||||||
|
worker_running = bool(self._thread and self._thread.is_alive())
|
||||||
|
if state is None:
|
||||||
|
state = _ServiceState(signature=())
|
||||||
|
last_success = state.last_success_at
|
||||||
|
is_stale = last_success is None or time.time() - last_success > max(2 * interval, 60)
|
||||||
|
return {
|
||||||
|
"service_id": service_id,
|
||||||
|
"action_key": self.action_key,
|
||||||
|
"worker_running": worker_running,
|
||||||
|
"enabled": bool(service.get("enabled", True)) and bool(config.get("polling_enabled", True)),
|
||||||
|
"running": state.running,
|
||||||
|
"poll_interval_seconds": interval,
|
||||||
|
"sample_retention_seconds": retention,
|
||||||
|
"sample_max_rows": max_rows,
|
||||||
|
"next_run_at": _safe_int(state.next_run_at) if state.next_run_at is not None else None,
|
||||||
|
"last_attempt_at": state.last_attempt_at,
|
||||||
|
"last_success_at": last_success,
|
||||||
|
"last_error": state.last_error,
|
||||||
|
"consecutive_failures": state.consecutive_failures,
|
||||||
|
"backoff_until": state.backoff_until,
|
||||||
|
"is_stale": is_stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_now(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
|
||||||
|
store = store or get_settings_store()
|
||||||
|
service_row = store.get_service(service_id)
|
||||||
|
if not service_row or service_row.get("service_type") != "qbittorrent":
|
||||||
|
raise ValueError("qBittorrent service not found")
|
||||||
|
if not service_row.get("enabled", True):
|
||||||
|
raise ValueError("Service is disabled")
|
||||||
|
config = service_row.get("config") or {}
|
||||||
|
if not config.get("polling_enabled", True):
|
||||||
|
raise ValueError("Polling is disabled")
|
||||||
|
service = build_service_record(store, service_row)
|
||||||
|
run = self._execute(service, "manual")
|
||||||
|
return {"run": run, "status": self.status(service_id, store)}
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self._reconcile_and_run()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduled action cycle failed")
|
||||||
|
self._stop_event.wait(SCHEDULER_LOOP_SECONDS)
|
||||||
|
|
||||||
|
def _reconcile_and_run(self) -> None:
|
||||||
|
store = get_settings_store()
|
||||||
|
services = sorted(store.list_services("qbittorrent"), key=lambda row: str(row.get("id") or ""))
|
||||||
|
active_ids = {str(row.get("id") or "") for row in services}
|
||||||
|
with self._state_lock:
|
||||||
|
for service_id in set(self._states) - active_ids:
|
||||||
|
self._states.pop(service_id, None)
|
||||||
|
self._service_locks.pop(service_id, None)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
for index, service_row in enumerate(services):
|
||||||
|
service_id = str(service_row.get("id") or "")
|
||||||
|
if not service_id:
|
||||||
|
continue
|
||||||
|
config = service_row.get("config") or {}
|
||||||
|
interval, _, _ = _bounded_config(config)
|
||||||
|
enabled = bool(service_row.get("enabled", True)) and bool(config.get("polling_enabled", True))
|
||||||
|
signature = (enabled, interval, config.get("sample_retention_seconds"), config.get("sample_max_rows"))
|
||||||
|
with self._state_lock:
|
||||||
|
state = self._states.get(service_id)
|
||||||
|
if state is None:
|
||||||
|
state = _ServiceState(signature=signature, next_run_at=now + min(index * 0.5, 5.0))
|
||||||
|
self._states[service_id] = state
|
||||||
|
elif state.signature != signature:
|
||||||
|
state.signature = signature
|
||||||
|
state.next_run_at = now if enabled else None
|
||||||
|
if not enabled:
|
||||||
|
state.next_run_at = None
|
||||||
|
continue
|
||||||
|
due = state.next_run_at is not None and now >= state.next_run_at
|
||||||
|
if due:
|
||||||
|
service = build_service_record(store, service_row)
|
||||||
|
try:
|
||||||
|
self._execute(service, "schedule")
|
||||||
|
except SchedulerBusyError:
|
||||||
|
logger.debug("Scheduled action already running service_id=%s", service_id)
|
||||||
|
with self._state_lock:
|
||||||
|
state = self._states.get(service_id)
|
||||||
|
if state:
|
||||||
|
delay = interval
|
||||||
|
if state.backoff_until:
|
||||||
|
delay = max(delay, state.backoff_until - _safe_int(time.time()))
|
||||||
|
state.next_run_at = time.time() + max(1, delay)
|
||||||
|
|
||||||
|
def _execute(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
|
||||||
|
lock = self._service_lock(service.id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
raise SchedulerBusyError(f"Action already running for service {service.id}")
|
||||||
|
try:
|
||||||
|
return self._execute_locked(service, trigger)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def _execute_locked(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
|
||||||
|
config = service.config
|
||||||
|
interval, _, _ = _bounded_config(config)
|
||||||
|
state = self._state_for(service.id, config)
|
||||||
|
attempt = state.consecutive_failures
|
||||||
|
start = time.perf_counter()
|
||||||
|
run = self._run_store.start_run(service.id, self.action_key, trigger, attempt=attempt)
|
||||||
|
with self._state_lock:
|
||||||
|
state.running = True
|
||||||
|
state.last_attempt_at = _safe_int(time.time())
|
||||||
|
action = get_scheduled_action(self.action_key)
|
||||||
|
try:
|
||||||
|
if action is None:
|
||||||
|
raise RuntimeError(f"Scheduled action is not registered: {self.action_key}")
|
||||||
|
action.run(service)
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
|
||||||
|
error = str(exc)[:1000]
|
||||||
|
finished = self._run_store.finish_run(run["id"], "failure", duration_ms=duration_ms, error=error)
|
||||||
|
self._run_store.prune(service.id, self.action_key)
|
||||||
|
with self._state_lock:
|
||||||
|
state.running = False
|
||||||
|
state.last_error = error
|
||||||
|
state.consecutive_failures += 1
|
||||||
|
delay = min(BACKOFF_CAP_SECONDS, max(interval, 2**state.consecutive_failures))
|
||||||
|
state.backoff_until = _safe_int(time.time()) + delay
|
||||||
|
record_scheduled_action(
|
||||||
|
service.id,
|
||||||
|
self.action_key,
|
||||||
|
"failure",
|
||||||
|
duration_seconds=duration_ms / 1000.0,
|
||||||
|
consecutive_failures=state.consecutive_failures,
|
||||||
|
)
|
||||||
|
logger.warning("Scheduled action failed service_id=%s action=%s: %s", service.id, self.action_key, error)
|
||||||
|
return finished if finished is not None else run
|
||||||
|
|
||||||
|
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
|
||||||
|
finished = self._run_store.finish_run(run["id"], "success", duration_ms=duration_ms)
|
||||||
|
self._run_store.prune(service.id, self.action_key)
|
||||||
|
with self._state_lock:
|
||||||
|
state.running = False
|
||||||
|
state.last_success_at = _safe_int(time.time())
|
||||||
|
state.last_error = ""
|
||||||
|
state.consecutive_failures = 0
|
||||||
|
state.backoff_until = None
|
||||||
|
if trigger == "manual":
|
||||||
|
state.next_run_at = max(state.next_run_at or 0, time.time() + interval)
|
||||||
|
record_scheduled_action(
|
||||||
|
service.id,
|
||||||
|
self.action_key,
|
||||||
|
"success",
|
||||||
|
duration_seconds=duration_ms / 1000.0,
|
||||||
|
success=True,
|
||||||
|
consecutive_failures=0,
|
||||||
|
)
|
||||||
|
return finished if finished is not None else run
|
||||||
|
|
||||||
|
def _state_for(self, service_id: str, config: dict[str, Any]) -> _ServiceState:
|
||||||
|
with self._state_lock:
|
||||||
|
state = self._states.get(service_id)
|
||||||
|
if state is None:
|
||||||
|
interval, _, _ = _bounded_config(config)
|
||||||
|
state = _ServiceState(signature=(), next_run_at=time.time() + interval)
|
||||||
|
self._states[service_id] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _service_lock(self, service_id: str) -> threading.Lock:
|
||||||
|
with self._state_lock:
|
||||||
|
return self._service_locks.setdefault(service_id, threading.Lock())
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEDULER = Scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> Scheduler:
|
||||||
|
return _SCHEDULER
|
||||||
|
|
||||||
|
|
||||||
|
def reset_scheduler() -> None:
|
||||||
|
"""Reset the singleton for tests."""
|
||||||
|
global _SCHEDULER
|
||||||
|
_SCHEDULER.stop()
|
||||||
|
_SCHEDULER = Scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Scheduler", "SchedulerBusyError", "get_scheduler", "reset_scheduler"]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Typed scheduled-action registry and qBittorrent sampling action."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, _qbittorrent_client
|
||||||
|
|
||||||
|
QBITTORRENT_SPEED_ACTION = "qbittorrent.speed_sample"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionResult:
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledAction(Protocol):
|
||||||
|
action_key: str
|
||||||
|
|
||||||
|
def run(self, service: ServiceRecord) -> ActionResult:
|
||||||
|
"""Run one action for one service instance."""
|
||||||
|
return ActionResult(data={})
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentSpeedAction:
|
||||||
|
action_key = QBITTORRENT_SPEED_ACTION
|
||||||
|
|
||||||
|
def run(self, service: ServiceRecord) -> ActionResult:
|
||||||
|
base_url = str(service.config.get("base_url") or "")
|
||||||
|
username = str(service.secrets.get("username") or "")
|
||||||
|
password = str(service.secrets.get("password") or "")
|
||||||
|
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
|
||||||
|
if not base_url or not username or not password:
|
||||||
|
raise ValueError("qBittorrent service is missing base_url, username, or password")
|
||||||
|
|
||||||
|
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
|
||||||
|
payload = client.maindata()
|
||||||
|
server_state = payload.get("server_state", {})
|
||||||
|
dl_speed = _safe_int(server_state.get("dl_info_speed", 0) or 0)
|
||||||
|
up_speed = _safe_int(server_state.get("up_info_speed", 0) or 0)
|
||||||
|
ts = _safe_int(time.time())
|
||||||
|
store = QbittorrentSampleStore()
|
||||||
|
store.append(
|
||||||
|
service.id,
|
||||||
|
ts,
|
||||||
|
dl_speed,
|
||||||
|
up_speed,
|
||||||
|
retention_seconds=_safe_int(service.config.get("sample_retention_seconds") or 1800, 1800),
|
||||||
|
max_rows=_safe_int(service.config.get("sample_max_rows") or 1200, 1200),
|
||||||
|
)
|
||||||
|
return ActionResult(data={"ts": ts, "dl_speed": dl_speed, "up_speed": up_speed})
|
||||||
|
|
||||||
|
|
||||||
|
_ACTIONS: dict[str, ScheduledAction] = {
|
||||||
|
QBITTORRENT_SPEED_ACTION: QbittorrentSpeedAction(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduled_action(action_key: str) -> ScheduledAction | None:
|
||||||
|
return _ACTIONS.get(action_key)
|
||||||
|
|
||||||
|
|
||||||
|
def list_scheduled_actions() -> list[str]:
|
||||||
|
return sorted(_ACTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ActionResult",
|
||||||
|
"QBITTORRENT_SPEED_ACTION",
|
||||||
|
"ScheduledAction",
|
||||||
|
"get_scheduled_action",
|
||||||
|
"list_scheduled_actions",
|
||||||
|
]
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Persistence for typed scheduled-action execution history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.service_data import ServiceDataHarness, StorageConcern
|
||||||
|
|
||||||
|
SCHEDULER_CONCERN = StorageConcern(
|
||||||
|
concern_key="scheduler",
|
||||||
|
db_filename="scheduler.db",
|
||||||
|
migrations=[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_action_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
action_key TEXT NOT NULL,
|
||||||
|
trigger TEXT NOT NULL,
|
||||||
|
started_at INTEGER NOT NULL,
|
||||||
|
finished_at INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_service_action_started
|
||||||
|
ON scheduler_action_runs(service_id, action_key, started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_status_started
|
||||||
|
ON scheduler_action_runs(status, started_at DESC);
|
||||||
|
"""
|
||||||
|
],
|
||||||
|
tables=["scheduler_action_runs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_RUNS_PER_ACTION = 1_000
|
||||||
|
RUN_RETENTION_SECONDS = 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerRunStore:
|
||||||
|
"""Store scheduler run records in the scheduler service-data concern."""
|
||||||
|
|
||||||
|
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||||
|
if harness is None:
|
||||||
|
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||||
|
|
||||||
|
harness = get_service_data_harness()
|
||||||
|
self._harness = harness
|
||||||
|
|
||||||
|
def start_run(self, service_id: str, action_key: str, trigger: str, attempt: int = 0) -> dict[str, Any]:
|
||||||
|
now = _safe_int(time.time())
|
||||||
|
run = {
|
||||||
|
"id": uuid.uuid4().hex[:12],
|
||||||
|
"service_id": service_id,
|
||||||
|
"action_key": action_key,
|
||||||
|
"trigger": trigger,
|
||||||
|
"started_at": now,
|
||||||
|
"finished_at": None,
|
||||||
|
"status": "running",
|
||||||
|
"attempt": _safe_int(attempt),
|
||||||
|
"duration_ms": None,
|
||||||
|
"error": "",
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
with self._harness.connect("scheduler") as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO scheduler_action_runs
|
||||||
|
(id, service_id, action_key, trigger, started_at, finished_at,
|
||||||
|
status, attempt, duration_ms, error, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run["id"],
|
||||||
|
run["service_id"],
|
||||||
|
run["action_key"],
|
||||||
|
run["trigger"],
|
||||||
|
run["started_at"],
|
||||||
|
run["finished_at"],
|
||||||
|
run["status"],
|
||||||
|
run["attempt"],
|
||||||
|
run["duration_ms"],
|
||||||
|
run["error"],
|
||||||
|
run["created_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return run
|
||||||
|
|
||||||
|
def finish_run(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
status: str,
|
||||||
|
finished_at: int | None = None,
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
error: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
finished_at = _safe_int(finished_at if finished_at is not None else time.time())
|
||||||
|
with self._harness.connect("scheduler") as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE scheduler_action_runs
|
||||||
|
SET finished_at = ?, status = ?, duration_ms = ?, error = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(finished_at, status, duration_ms, str(error or "")[:1000], run_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._harness.connect("scheduler") as conn:
|
||||||
|
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def list_runs(
|
||||||
|
self,
|
||||||
|
service_id: str,
|
||||||
|
action_key: str,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
trigger: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
limit = max(1, min(_safe_int(limit, 50), 100))
|
||||||
|
offset = max(0, _safe_int(offset))
|
||||||
|
with self._harness.connect("scheduler") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM scheduler_action_runs
|
||||||
|
WHERE service_id = ? AND action_key = ?
|
||||||
|
ORDER BY started_at DESC, id DESC LIMIT 1000
|
||||||
|
""",
|
||||||
|
(service_id, action_key),
|
||||||
|
).fetchall()
|
||||||
|
filtered = [
|
||||||
|
dict(row)
|
||||||
|
for row in rows
|
||||||
|
if (not status or row["status"] == status) and (not trigger or row["trigger"] == trigger)
|
||||||
|
]
|
||||||
|
return filtered[offset : offset + limit], len(filtered)
|
||||||
|
|
||||||
|
def prune(self, service_id: str, action_key: str, now: int | None = None) -> int:
|
||||||
|
now = _safe_int(now if now is not None else time.time())
|
||||||
|
cutoff = now - RUN_RETENTION_SECONDS
|
||||||
|
with self._harness.connect("scheduler") as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM scheduler_action_runs
|
||||||
|
WHERE service_id = ? AND action_key = ?
|
||||||
|
AND (
|
||||||
|
created_at < ? OR rowid NOT IN (
|
||||||
|
SELECT rowid FROM scheduler_action_runs
|
||||||
|
WHERE service_id = ? AND action_key = ?
|
||||||
|
ORDER BY started_at DESC, rowid DESC LIMIT ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(service_id, action_key, cutoff, service_id, action_key, MAX_RUNS_PER_ACTION),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return _safe_int(cursor.rowcount)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_RUNS_PER_ACTION",
|
||||||
|
"RUN_RETENTION_SECONDS",
|
||||||
|
"SCHEDULER_CONCERN",
|
||||||
|
"SchedulerRunStore",
|
||||||
|
]
|
||||||
@@ -16,12 +16,33 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(value: str) -> str:
|
||||||
|
if not _IDENTIFIER_RE.fullmatch(value):
|
||||||
|
raise ValueError(f"Unsafe SQLite identifier: {value!r}")
|
||||||
|
return f'"{value}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _cascade_delete(conn: sqlite3.Connection, table: str, column: str, service_id: str) -> None:
|
||||||
|
table_sql = _quote_identifier(table)
|
||||||
|
column_sql = _quote_identifier(column)
|
||||||
|
# Identifiers are strictly allowlisted; the value remains parameterized.
|
||||||
|
# nosemgrep: python.lang.security.audit.formatted-sql-query.formatted-sql-query
|
||||||
|
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM {table_sql} WHERE {column_sql} = ?",
|
||||||
|
(service_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class StorageConcern:
|
class StorageConcern:
|
||||||
@@ -96,7 +117,7 @@ class ServiceDataHarness:
|
|||||||
conn.execute(stmt)
|
conn.execute(stmt)
|
||||||
except sqlite3.OperationalError as exc:
|
except sqlite3.OperationalError as exc:
|
||||||
lowered = str(exc).lower()
|
lowered = str(exc).lower()
|
||||||
if "duplicate column name" in lowered or "no such table" in lowered:
|
if any(marker in lowered for marker in ("duplicate column name", "no such table")):
|
||||||
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
@@ -118,9 +139,7 @@ class ServiceDataHarness:
|
|||||||
continue
|
continue
|
||||||
with sqlite3.connect(path, timeout=30) as conn:
|
with sqlite3.connect(path, timeout=30) as conn:
|
||||||
for table in concern.tables:
|
for table in concern.tables:
|
||||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
_cascade_delete(conn, table, col, service_id)
|
||||||
if col in cols:
|
|
||||||
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -148,6 +167,10 @@ def get_service_data_harness() -> ServiceDataHarness:
|
|||||||
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
||||||
|
|
||||||
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
||||||
|
|
||||||
|
from .scheduler_store import SCHEDULER_CONCERN # type: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
_HARNESS.register(SCHEDULER_CONCERN)
|
||||||
_HARNESS.run_migrations()
|
_HARNESS.run_migrations()
|
||||||
return _HARNESS
|
return _HARNESS
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
"""Persistent application settings stored in a small SQLite database.
|
"""Persistent application settings stored in a small SQLite database."""
|
||||||
|
|
||||||
The store manages machine definitions, machine services, and per-machine
|
|
||||||
application configuration so the frontend can present local and remote targets
|
|
||||||
in the same UI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -23,31 +18,6 @@ from media_library_viewer_api.models.widgets import _validate_config_keys
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||||
LOCAL_MACHINE_ID = "local"
|
|
||||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_local_machine() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": LOCAL_MACHINE_ID,
|
|
||||||
"name": "This machine",
|
|
||||||
"mode": "local",
|
|
||||||
"enabled": True,
|
|
||||||
"services": list(DEFAULT_SERVICES),
|
|
||||||
"host": "",
|
|
||||||
"port": 22,
|
|
||||||
"username": "",
|
|
||||||
"key_directory": "",
|
|
||||||
"key_name": "",
|
|
||||||
"ssh_key_id": "",
|
|
||||||
"ssh_private_key": "",
|
|
||||||
"ssh_private_key_passphrase": "",
|
|
||||||
"password": "",
|
|
||||||
"node_exporter_enabled": False,
|
|
||||||
"node_exporter_port": 9100,
|
|
||||||
"node_exporter_scrape_host": "",
|
|
||||||
"notes": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
@@ -66,23 +36,6 @@ class SettingsStore:
|
|||||||
|
|
||||||
def init_schema(self) -> None:
|
def init_schema(self) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS monitoring_machines (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
mode TEXT NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL,
|
|
||||||
config_json TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
|
||||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
|
||||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
|
||||||
# table on startup so existing databases get a clean slate.
|
|
||||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -112,7 +65,7 @@ class SettingsStore:
|
|||||||
task_type TEXT NOT NULL,
|
task_type TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
enabled INTEGER NOT NULL,
|
enabled INTEGER NOT NULL,
|
||||||
default_service_id TEXT NOT NULL,
|
service_id TEXT NOT NULL,
|
||||||
notes TEXT NOT NULL,
|
notes TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -120,11 +73,15 @@ 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)")
|
||||||
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
# Migrate legacy task ownership column names in place.
|
||||||
# target ssh_tasks service instances). Migrate existing columns.
|
|
||||||
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
||||||
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
|
if "service_id" not in saved_tasks_cols:
|
||||||
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
|
legacy_column = next(
|
||||||
|
(column for column in ("default_service_id", "default_machine_id") if column in saved_tasks_cols),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if legacy_column:
|
||||||
|
conn.execute(f"ALTER TABLE saved_tasks RENAME COLUMN {legacy_column} TO service_id")
|
||||||
# Run history for saved tasks now lives in service_task_runs; the
|
# Run history for saved tasks now lives in service_task_runs; the
|
||||||
# legacy machine-based table is dropped.
|
# legacy machine-based table is dropped.
|
||||||
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
||||||
@@ -276,191 +233,163 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._migrate_remote_machine_services(conn)
|
||||||
|
|
||||||
@staticmethod
|
def _migrate_remote_machine_services(self, conn: sqlite3.Connection) -> None:
|
||||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
"""Migrate legacy SSH endpoints into encrypted ``remote_machine`` services.
|
||||||
if isinstance(value, str):
|
|
||||||
items = [part.strip() for part in value.split(",")]
|
|
||||||
elif isinstance(value, list):
|
|
||||||
items = [str(part).strip() for part in value]
|
|
||||||
else:
|
|
||||||
items = list(fallback or DEFAULT_SERVICES)
|
|
||||||
services = [item for item in items if item]
|
|
||||||
if not services:
|
|
||||||
services = list(fallback or DEFAULT_SERVICES)
|
|
||||||
deduped: list[str] = []
|
|
||||||
for service in services:
|
|
||||||
if service not in deduped:
|
|
||||||
deduped.append(service)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]:
|
Local placeholders are deliberately skipped. Invalid legacy rows abort
|
||||||
data = json.loads(row["config_json"])
|
the transaction, retaining the source table instead of silently losing
|
||||||
default_services = DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []
|
credential material.
|
||||||
services = self._normalize_services(data.get("services"), default_services)
|
"""
|
||||||
return {
|
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
"id": row["id"],
|
conn.execute("UPDATE services SET service_type = 'remote_machine' WHERE service_type = 'ssh_tasks'")
|
||||||
"name": row["name"],
|
if "monitoring_machines" not in tables:
|
||||||
"mode": row["mode"],
|
return
|
||||||
"enabled": bool(row["enabled"]),
|
|
||||||
"services": services,
|
|
||||||
"host": data.get("host", ""),
|
|
||||||
"port": int(data.get("port", 22) or 22),
|
|
||||||
"username": data.get("username", ""),
|
|
||||||
"key_directory": data.get("key_directory", ""),
|
|
||||||
"key_name": data.get("key_name", ""),
|
|
||||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
|
||||||
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
|
||||||
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
|
||||||
"password_set": bool(data.get("password")),
|
|
||||||
"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"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _normalize_machine_payload(
|
from media_library_viewer_api.services.secrets import encrypt_value
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
machine_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
current = self.get_machine(machine_id) if machine_id else None
|
|
||||||
machine_id = str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
|
||||||
mode = str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower()
|
|
||||||
if mode not in {"local", "ssh"}:
|
|
||||||
mode = "local"
|
|
||||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
|
||||||
name = str(payload.get("name") or (current or {}).get("name") or "").strip() or (
|
|
||||||
"This machine" if mode == "local" else machine_id
|
|
||||||
)
|
|
||||||
services = self._normalize_services(payload.get("services"), (current or {}).get("services", []))
|
|
||||||
|
|
||||||
def _current_str(field: str, default: str = "") -> str:
|
rows = conn.execute("SELECT * FROM monitoring_machines ORDER BY created_at, id").fetchall()
|
||||||
return str(
|
for row in rows:
|
||||||
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default
|
try:
|
||||||
).strip()
|
data = json.loads(row["config_json"] or "{}")
|
||||||
|
except (TypeError, json.JSONDecodeError) as exc:
|
||||||
host = _current_str("host")
|
raise RuntimeError(f"Legacy machine {row['id']!r} has invalid config JSON") from exc
|
||||||
port = int(payload.get("port") or (current or {}).get("port", 22) or 22)
|
if not isinstance(data, dict):
|
||||||
username = _current_str("username")
|
raise RuntimeError(f"Legacy machine {row['id']!r} config must be an object")
|
||||||
key_directory = _current_str("key_directory")
|
if str(row["mode"] or "").lower() != "ssh":
|
||||||
key_name = _current_str("key_name")
|
continue
|
||||||
ssh_key_id = _current_str("ssh_key_id")
|
old_id = str(row["id"])
|
||||||
ssh_private_key = payload.get("ssh_private_key")
|
target_id = self._remote_machine_target_id(conn, old_id)
|
||||||
if ssh_private_key in (None, ""):
|
ssh_key_id = self._migrate_inline_ssh_key(conn, row, data, old_id)
|
||||||
ssh_private_key = (current or {}).get("ssh_private_key", "")
|
config = self._legacy_remote_machine_config(data, ssh_key_id, old_id)
|
||||||
ssh_private_key = str(ssh_private_key or "")
|
secrets = self._legacy_remote_machine_secrets(data, encrypt_value)
|
||||||
ssh_private_key_passphrase = payload.get("ssh_private_key_passphrase")
|
|
||||||
if ssh_private_key_passphrase in (None, ""):
|
|
||||||
ssh_private_key_passphrase = (current or {}).get("ssh_private_key_passphrase", "")
|
|
||||||
ssh_private_key_passphrase = str(ssh_private_key_passphrase or "")
|
|
||||||
password = payload.get("password")
|
|
||||||
if password in (None, ""):
|
|
||||||
password = (current or {}).get("password", "")
|
|
||||||
password = str(password or "")
|
|
||||||
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 ""
|
|
||||||
return {
|
|
||||||
"id": machine_id,
|
|
||||||
"name": name,
|
|
||||||
"mode": mode,
|
|
||||||
"enabled": enabled,
|
|
||||||
"services": services,
|
|
||||||
"host": host,
|
|
||||||
"port": port,
|
|
||||||
"username": username,
|
|
||||||
"key_directory": key_directory,
|
|
||||||
"key_name": key_name,
|
|
||||||
"ssh_key_id": ssh_key_id,
|
|
||||||
"ssh_private_key": ssh_private_key,
|
|
||||||
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
|
||||||
"password": password,
|
|
||||||
"node_exporter_enabled": node_exporter_enabled,
|
|
||||||
"node_exporter_port": node_exporter_port,
|
|
||||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
|
||||||
"notes": notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _seed_local_machine(self) -> None:
|
|
||||||
"""Seed the default local machine if none exists."""
|
|
||||||
machine = _default_local_machine()
|
|
||||||
now = int(time.time())
|
|
||||||
config = {
|
|
||||||
"services": machine["services"],
|
|
||||||
"host": machine["host"],
|
|
||||||
"port": machine["port"],
|
|
||||||
"username": machine["username"],
|
|
||||||
"key_directory": machine["key_directory"],
|
|
||||||
"key_name": machine["key_name"],
|
|
||||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
|
||||||
"ssh_private_key": "",
|
|
||||||
"ssh_private_key_passphrase": "",
|
|
||||||
"password": "",
|
|
||||||
"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:
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
INSERT INTO services (
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
id, service_type, name, config_json, secrets_json, enabled, created_at, updated_at
|
||||||
|
) VALUES (?, 'remote_machine', ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
machine["id"],
|
target_id,
|
||||||
machine["name"],
|
row["name"],
|
||||||
machine["mode"],
|
|
||||||
1,
|
|
||||||
json.dumps(config),
|
json.dumps(config),
|
||||||
now,
|
json.dumps(secrets),
|
||||||
now,
|
row["enabled"],
|
||||||
|
row["created_at"],
|
||||||
|
row["updated_at"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if target_id != old_id:
|
||||||
|
conn.execute("UPDATE saved_tasks SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||||
|
conn.execute("UPDATE service_task_runs SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||||
|
conn.execute("UPDATE dashboard_widgets SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||||
|
conn.execute("DROP TABLE monitoring_machines")
|
||||||
|
|
||||||
def _seed_dashboard_widgets(self) -> None:
|
@staticmethod
|
||||||
"""Default widget seeding was removed.
|
def _remote_machine_target_id(conn: sqlite3.Connection, old_id: str) -> str:
|
||||||
|
existing = conn.execute("SELECT service_type FROM services WHERE id = ?", (old_id,)).fetchone()
|
||||||
|
if not existing or existing[0] == "remote_machine":
|
||||||
|
return old_id
|
||||||
|
base = f"remote-machine-{old_id}"
|
||||||
|
target_id, suffix = base, 2
|
||||||
|
while conn.execute("SELECT 1 FROM services WHERE id = ?", (target_id,)).fetchone():
|
||||||
|
target_id = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return target_id
|
||||||
|
|
||||||
Widgets are now service-bound (or built-in). A fresh install starts with
|
def _migrate_inline_ssh_key(
|
||||||
no widgets; the user configures services and adds widgets from the UI.
|
self, conn: sqlite3.Connection, row: sqlite3.Row, data: dict[str, Any], old_id: str
|
||||||
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
) -> str:
|
||||||
"""
|
ssh_key_id = str(data.get("ssh_key_id") or "").strip()
|
||||||
return None
|
inline_key = str(data.get("ssh_private_key") or "")
|
||||||
|
if not inline_key or ssh_key_id:
|
||||||
|
return ssh_key_id
|
||||||
|
base = f"legacy-key-{old_id}"
|
||||||
|
ssh_key_id, suffix = base, 2
|
||||||
|
while conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (ssh_key_id,)).fetchone():
|
||||||
|
ssh_key_id = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
summary = self._private_key_summary(inline_key)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ssh_keys (
|
||||||
|
id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
ssh_key_id,
|
||||||
|
f"Migrated key for {row['name']}",
|
||||||
|
inline_key,
|
||||||
|
summary["public_key"],
|
||||||
|
summary["fingerprint"],
|
||||||
|
"Migrated from legacy remote machine",
|
||||||
|
row["created_at"],
|
||||||
|
row["updated_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ssh_key_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_remote_machine_config(data: dict[str, Any], ssh_key_id: str, machine_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
port = int(data.get("port") or 22)
|
||||||
|
timeout = int(data.get("timeout_seconds") or 30)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout") from exc
|
||||||
|
if not 1 <= port <= 65535 or timeout <= 0:
|
||||||
|
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout")
|
||||||
|
return {
|
||||||
|
"host": str(data.get("host") or ""),
|
||||||
|
"port": port,
|
||||||
|
"username": str(data.get("username") or ""),
|
||||||
|
"ssh_key_id": ssh_key_id,
|
||||||
|
"timeout_seconds": timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_remote_machine_secrets(data: dict[str, Any], encrypt_value: Any) -> dict[str, str]:
|
||||||
|
secrets: dict[str, str] = {}
|
||||||
|
for legacy, secret in (("ssh_private_key_passphrase", "passphrase"), ("password", "password")):
|
||||||
|
value = str(data.get(legacy) or "")
|
||||||
|
if value:
|
||||||
|
secrets[secret] = encrypt_value(value)
|
||||||
|
return secrets
|
||||||
|
|
||||||
def ensure_defaults(self) -> None:
|
def ensure_defaults(self) -> None:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
|
||||||
if not row or int(row[0]) == 0:
|
|
||||||
self._seed_local_machine()
|
|
||||||
self._migrate_jellyseerr_into_jellyfin()
|
self._migrate_jellyseerr_into_jellyfin()
|
||||||
|
self._migrate_jellyseerr_api_key_to_secret()
|
||||||
|
|
||||||
|
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
|
||||||
|
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets."""
|
||||||
|
from media_library_viewer_api.services.secrets import encrypt_value
|
||||||
|
|
||||||
|
moved = 0
|
||||||
|
for row in self.list_services("jellyfin"):
|
||||||
|
config = dict(row.get("config") or {})
|
||||||
|
plaintext = str(config.get("jellyseerr_api_key") or "").strip()
|
||||||
|
if not plaintext:
|
||||||
|
continue
|
||||||
|
secrets_blob = dict(row.get("secrets") or {})
|
||||||
|
if "jellyseerr_api_key" not in secrets_blob:
|
||||||
|
secrets_blob["jellyseerr_api_key"] = encrypt_value(plaintext)
|
||||||
|
config.pop("jellyseerr_api_key", None)
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
|
||||||
|
)
|
||||||
|
moved += 1
|
||||||
|
logger.info("migrated jellyseerr_api_key config->secret for jellyfin service %r", row["name"])
|
||||||
|
if moved:
|
||||||
|
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", moved)
|
||||||
|
|
||||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin."""
|
||||||
|
|
||||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
|
||||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
|
||||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
|
||||||
paired -> drop with a logged warning.
|
|
||||||
"""
|
|
||||||
from media_library_viewer_api.services.secrets import decrypt_value
|
from media_library_viewer_api.services.secrets import decrypt_value
|
||||||
|
|
||||||
self.init_schema()
|
|
||||||
jellyseerr_rows: list[sqlite3.Row] = []
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
jellyseerr_rows = conn.execute(
|
jellyseerr_rows = conn.execute(
|
||||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||||
@@ -474,28 +403,32 @@ class SettingsStore:
|
|||||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||||
js_url = str(js_config.get("base_url", "")).strip()
|
js_url = str(js_config.get("base_url", "")).strip()
|
||||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
|
||||||
if js_api_key:
|
if js_api_key:
|
||||||
try:
|
try:
|
||||||
js_api_key = decrypt_value(js_api_key)
|
js_api_key = decrypt_value(js_api_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||||
js_api_key = ""
|
js_api_key = ""
|
||||||
js_name = js_row["name"]
|
|
||||||
|
|
||||||
target = None
|
target = None
|
||||||
if len(jellyfin_rows) == 1:
|
if len(jellyfin_rows) == 1:
|
||||||
target = jellyfin_rows[0]
|
target = jellyfin_rows[0]
|
||||||
elif len(jellyfin_rows) > 1:
|
elif len(jellyfin_rows) > 1:
|
||||||
for jf in jellyfin_rows:
|
target = next(
|
||||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
(row for row in jellyfin_rows if not str(row["config"].get("jellyseerr_url", "")).strip()),
|
||||||
target = jf
|
None,
|
||||||
break
|
)
|
||||||
|
|
||||||
if target:
|
if target:
|
||||||
|
target_api_key = str(target["secrets"].get("api_key") or "")
|
||||||
|
if target_api_key:
|
||||||
|
try:
|
||||||
|
target_api_key = decrypt_value(target_api_key)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not decrypt jellyfin api_key for %r", target["name"])
|
||||||
|
target_api_key = ""
|
||||||
merged_config = dict(target["config"])
|
merged_config = dict(target["config"])
|
||||||
merged_config["jellyseerr_url"] = js_url
|
merged_config["jellyseerr_url"] = js_url
|
||||||
merged_config["jellyseerr_api_key"] = js_api_key
|
|
||||||
self.upsert_service(
|
self.upsert_service(
|
||||||
{
|
{
|
||||||
"id": target["id"],
|
"id": target["id"],
|
||||||
@@ -504,139 +437,14 @@ class SettingsStore:
|
|||||||
"config": merged_config,
|
"config": merged_config,
|
||||||
"enabled": target["enabled"],
|
"enabled": target["enabled"],
|
||||||
},
|
},
|
||||||
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
|
secret_values={"api_key": target_api_key, "jellyseerr_api_key": js_api_key},
|
||||||
)
|
)
|
||||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_row["name"], target["name"])
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning("dropped unpaired jellyseerr service %r; reconfigure manually", js_row["name"])
|
||||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
|
||||||
js_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def list_machines(self) -> list[dict[str, Any]]:
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE",
|
|
||||||
(LOCAL_MACHINE_ID,),
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_machine(row) for row in rows]
|
|
||||||
|
|
||||||
def get_machine(self, machine_id: str | None) -> dict[str, Any] | None:
|
|
||||||
if not machine_id:
|
|
||||||
return None
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
|
||||||
return self._row_to_machine(row) if row else None
|
|
||||||
|
|
||||||
def get_machine_config(self, machine_id: str | None) -> dict[str, Any] | None:
|
|
||||||
"""Return the full machine config including secrets."""
|
|
||||||
if not machine_id:
|
|
||||||
return None
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
data = json.loads(row["config_json"])
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"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 [],
|
|
||||||
),
|
|
||||||
"host": data.get("host", ""),
|
|
||||||
"port": int(data.get("port", 22) or 22),
|
|
||||||
"username": data.get("username", ""),
|
|
||||||
"key_directory": data.get("key_directory", ""),
|
|
||||||
"key_name": data.get("key_name", ""),
|
|
||||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
|
||||||
"ssh_private_key": data.get("ssh_private_key", ""),
|
|
||||||
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
|
||||||
"password": data.get("password", ""),
|
|
||||||
"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")
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None:
|
|
||||||
if machine_id:
|
|
||||||
machine = self.get_machine(machine_id)
|
|
||||||
if machine and service in machine.get("services", []) and machine.get("enabled"):
|
|
||||||
return machine
|
|
||||||
return machine if machine else None
|
|
||||||
machines = self.list_machines_for_service(service)
|
|
||||||
return machines[0] if machines else None
|
|
||||||
|
|
||||||
def upsert_machine(self, payload: dict[str, Any], machine_id: str | None = None) -> dict[str, Any]:
|
|
||||||
self.init_schema()
|
|
||||||
machine = self._normalize_machine_payload(payload, machine_id)
|
|
||||||
now = int(time.time())
|
|
||||||
config = {
|
|
||||||
"services": machine["services"],
|
|
||||||
"host": machine["host"],
|
|
||||||
"port": machine["port"],
|
|
||||||
"username": machine["username"],
|
|
||||||
"key_directory": machine["key_directory"],
|
|
||||||
"key_name": machine["key_name"],
|
|
||||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
|
||||||
"ssh_private_key": machine["ssh_private_key"],
|
|
||||||
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
|
||||||
"password": machine["password"],
|
|
||||||
"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()
|
|
||||||
created_at = int(existing[0]) if existing else now
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
name = excluded.name,
|
|
||||||
mode = excluded.mode,
|
|
||||||
enabled = excluded.enabled,
|
|
||||||
config_json = excluded.config_json,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
machine["id"],
|
|
||||||
machine["name"],
|
|
||||||
machine["mode"],
|
|
||||||
1 if machine["enabled"] else 0,
|
|
||||||
json.dumps(config),
|
|
||||||
created_at,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return self.get_machine(machine["id"]) or machine
|
|
||||||
|
|
||||||
def delete_machine(self, machine_id: str) -> None:
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||||
@@ -707,10 +515,9 @@ class SettingsStore:
|
|||||||
|
|
||||||
def list_ssh_keys(self) -> list[dict[str, Any]]:
|
def list_ssh_keys(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
machines = self.list_machines()
|
|
||||||
usage_counts: dict[str, int] = {}
|
usage_counts: dict[str, int] = {}
|
||||||
for machine in machines:
|
for service in self.list_services("remote_machine"):
|
||||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
ssh_key_id = str((service.get("config") or {}).get("ssh_key_id") or "").strip()
|
||||||
if ssh_key_id:
|
if ssh_key_id:
|
||||||
usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1
|
usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
@@ -785,7 +592,7 @@ class SettingsStore:
|
|||||||
"task_type": row["task_type"],
|
"task_type": row["task_type"],
|
||||||
"content": row["content"],
|
"content": row["content"],
|
||||||
"enabled": bool(row["enabled"]),
|
"enabled": bool(row["enabled"]),
|
||||||
"default_service_id": row["default_service_id"],
|
"service_id": row["service_id"],
|
||||||
"notes": row["notes"],
|
"notes": row["notes"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"updated_at": row["updated_at"],
|
"updated_at": row["updated_at"],
|
||||||
@@ -802,10 +609,10 @@ class SettingsStore:
|
|||||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
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)))
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
default_service_id = str(
|
service_id = str(
|
||||||
payload.get("default_service_id")
|
payload.get("service_id")
|
||||||
if payload.get("default_service_id") is not None
|
if payload.get("service_id") is not None
|
||||||
else (current or {}).get("default_service_id", "") or ""
|
else (current or {}).get("service_id", "") or ""
|
||||||
).strip()
|
).strip()
|
||||||
notes = str(
|
notes = str(
|
||||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||||
@@ -816,7 +623,7 @@ class SettingsStore:
|
|||||||
"task_type": task_type,
|
"task_type": task_type,
|
||||||
"content": content,
|
"content": content,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"default_service_id": default_service_id,
|
"service_id": service_id,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +651,7 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO saved_tasks (
|
INSERT INTO saved_tasks (
|
||||||
id, name, task_type, content, enabled, default_service_id,
|
id, name, task_type, content, enabled, service_id,
|
||||||
notes, created_at, updated_at
|
notes, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -853,7 +660,7 @@ class SettingsStore:
|
|||||||
task_type = excluded.task_type,
|
task_type = excluded.task_type,
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
default_service_id = excluded.default_service_id,
|
service_id = excluded.service_id,
|
||||||
notes = excluded.notes,
|
notes = excluded.notes,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
@@ -863,7 +670,7 @@ class SettingsStore:
|
|||||||
task["task_type"],
|
task["task_type"],
|
||||||
task["content"],
|
task["content"],
|
||||||
1 if task["enabled"] else 0,
|
1 if task["enabled"] else 0,
|
||||||
task["default_service_id"],
|
task["service_id"],
|
||||||
task["notes"],
|
task["notes"],
|
||||||
created_at,
|
created_at,
|
||||||
now,
|
now,
|
||||||
@@ -1075,10 +882,16 @@ class SettingsStore:
|
|||||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
return self._row_to_job(row) if row else None
|
return self._row_to_job(row) if row else None
|
||||||
|
|
||||||
def list_backup_jobs(self) -> list[dict[str, Any]]:
|
def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
where = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if service_id:
|
||||||
|
where = "WHERE service_id = ?"
|
||||||
|
params.append(service_id)
|
||||||
|
sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC"
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
return [self._row_to_job(row) for row in rows]
|
return [self._row_to_job(row) for row in rows]
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1167,6 +980,7 @@ class SettingsStore:
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
service_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
@@ -1177,6 +991,9 @@ class SettingsStore:
|
|||||||
if status:
|
if status:
|
||||||
clauses.append("status = ?")
|
clauses.append("status = ?")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
if service_id:
|
||||||
|
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||||
|
params.append(service_id)
|
||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
|
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
|
||||||
params.append(max(1, min(int(limit), 200)))
|
params.append(max(1, min(int(limit), 200)))
|
||||||
@@ -1257,6 +1074,7 @@ class SettingsStore:
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
acknowledged: bool | None = None,
|
acknowledged: bool | None = None,
|
||||||
severity: str | None = None,
|
severity: str | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
@@ -1270,6 +1088,9 @@ class SettingsStore:
|
|||||||
if severity:
|
if severity:
|
||||||
clauses.append("severity = ?")
|
clauses.append("severity = ?")
|
||||||
params.append(severity)
|
params.append(severity)
|
||||||
|
if service_id:
|
||||||
|
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||||
|
params.append(service_id)
|
||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
|
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Prometheus Node Exporter target discovery.
|
|
||||||
|
|
||||||
The backend owns the list of remote Node Exporter targets so that operators can
|
|
||||||
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
|
|
||||||
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
|
|
||||||
via ``http_sd_configs`` (no shared volume required).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
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 an http-SD target list for all enabled SSH machines.
|
|
||||||
|
|
||||||
Local machines are excluded because the Docker host is scraped directly.
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Shared runner for saved tasks over SSH task services.
|
"""Shared runner for saved tasks over Remote machine services.
|
||||||
|
|
||||||
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
||||||
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances.
|
(``widgets/sources.py``) run saved tasks against ``remote_machine`` service instances.
|
||||||
This module is the single execution path: build the client from the service
|
This module is the single execution path: build the client from the service
|
||||||
record, render the command, run it with the service timeout, append a
|
record, render the command, run it with the service timeout, append a
|
||||||
``service_task_runs`` row, and return the result.
|
``service_task_runs`` row, and return the result.
|
||||||
@@ -40,12 +40,12 @@ class TaskRunResult:
|
|||||||
|
|
||||||
|
|
||||||
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
||||||
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
"""Build an SSH client from an remote_machine service instance + referenced key."""
|
||||||
config = service.config
|
config = service.config
|
||||||
host = str(config.get("host") or "").strip()
|
host = str(config.get("host") or "").strip()
|
||||||
username = str(config.get("username") or "").strip()
|
username = str(config.get("username") or "").strip()
|
||||||
if not host or not username:
|
if not host or not username:
|
||||||
raise ValueError("SSH task service is missing host or username")
|
raise ValueError("Remote machine service is missing host or username")
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
private_key = ""
|
private_key = ""
|
||||||
@@ -65,6 +65,7 @@ def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSS
|
|||||||
port=int(config.get("port") or 22),
|
port=int(config.get("port") or 22),
|
||||||
private_key=private_key or None,
|
private_key=private_key or None,
|
||||||
private_key_passphrase=key_passphrase or None,
|
private_key_passphrase=key_passphrase or None,
|
||||||
|
password=str(service.secrets.get("password") or "") or None,
|
||||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||||
timeout=int(config.get("timeout_seconds") or 30),
|
timeout=int(config.get("timeout_seconds") or 30),
|
||||||
)
|
)
|
||||||
@@ -88,7 +89,7 @@ def run_saved_task(
|
|||||||
*,
|
*,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
) -> TaskRunResult:
|
) -> TaskRunResult:
|
||||||
"""Run a saved task on an ssh_tasks service instance and log the run.
|
"""Run a saved task on an remote_machine service instance and log the run.
|
||||||
|
|
||||||
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
|
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
|
||||||
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
|
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/widgets
|
dir: backend/src/media_library_viewer_api/widgets
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API.
|
Provides widget data adapters, built-in widget definitions, and stats-provider abstractions for fetching and normalizing dashboard data from various external services.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
@@ -11,13 +11,15 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
|||||||
## files
|
## files
|
||||||
- __init__.py
|
- __init__.py
|
||||||
- builtin.py
|
- builtin.py
|
||||||
|
- jellyseerr_stats.py
|
||||||
- prometheus_range.py
|
- prometheus_range.py
|
||||||
- sources.py
|
- sources.py
|
||||||
|
- stats_provider.py
|
||||||
## links
|
## links
|
||||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||||
## workflows
|
## workflows
|
||||||
- change widgets behavior
|
- change widgets behavior
|
||||||
read: __init__.py, builtin.py, prometheus_range.py
|
read: __init__.py, builtin.py, jellyseerr_stats.py
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
|||||||
|
"""Jellyseerr stats provider — request counts + recent requests.
|
||||||
|
|
||||||
|
Jellyseerr is an optional companion of the Jellyfin service (config lives on
|
||||||
|
the Jellyfin instance as ``jellyseerr_url`` / ``jellyseerr_api_key``). This
|
||||||
|
provider is registered for ``service_type == "jellyfin"`` and returns the
|
||||||
|
headline request stats (total / pending / approved / declined / processing /
|
||||||
|
available) plus a recent-requests list.
|
||||||
|
|
||||||
|
To avoid several widgets + the Requests tab each hitting Jellyseerr, one
|
||||||
|
authenticated client is reused per service (lru_cache) and the stats result is
|
||||||
|
cached for a short TTL with a lock — the same pattern the qBittorrent client
|
||||||
|
uses to keep a single-threaded upstream from being hammered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||||
|
from media_library_viewer_api.widgets.stats_provider import (
|
||||||
|
StatsResult,
|
||||||
|
StatValue,
|
||||||
|
register_stats_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Short-TTL cache: multiple widgets + the tab collapse onto one Jellyseerr fetch.
|
||||||
|
JS_STATS_CACHE_TTL = 10.0
|
||||||
|
|
||||||
|
# (stat key, display label) — order is the overview/grid order.
|
||||||
|
_JELLYSEERR_STATS: list[tuple[str, str]] = [
|
||||||
|
("total", "Total"),
|
||||||
|
("pending", "Pending"),
|
||||||
|
("approved", "Approved"),
|
||||||
|
("declined", "Declined"),
|
||||||
|
("processing", "Processing"),
|
||||||
|
("available", "Available"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _jellyseer_client(cache_key: tuple[str, str, str]) -> JellyseerrClient:
|
||||||
|
"""Reuse one authenticated client per (service, url, api_key)."""
|
||||||
|
_service_id, base_url, api_key = cache_key
|
||||||
|
return JellyseerrClient(base_url, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyseerrStatsProvider:
|
||||||
|
"""StatsProvider backed by Jellyseerr /api/v1/request/count + /api/v1/request."""
|
||||||
|
|
||||||
|
def __init__(self, ttl: float = JS_STATS_CACHE_TTL) -> None:
|
||||||
|
self._ttl = ttl
|
||||||
|
self._cache: dict[str, tuple[float, StatsResult]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def fetch_stats(self, service: Any) -> StatsResult:
|
||||||
|
base_url = str(service.config.get("jellyseerr_url") or "")
|
||||||
|
# jellyseerr_api_key is migrating config -> secret; accept either during the transition.
|
||||||
|
api_key = str(
|
||||||
|
(service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or ""
|
||||||
|
)
|
||||||
|
if not base_url or not api_key:
|
||||||
|
return StatsResult(
|
||||||
|
stats=[],
|
||||||
|
detail="Jellyseerr is not configured for this Jellyfin instance "
|
||||||
|
"(set jellyseerr_url + jellyseerr_api_key on the Jellyfin service).",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
hit = self._cache.get(service.id)
|
||||||
|
if hit and (now - hit[0]) < self._ttl:
|
||||||
|
return hit[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _jellyseer_client((service.id, base_url, api_key))
|
||||||
|
counts = client.request_count()
|
||||||
|
recent = client.recent_requests(20)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Jellyseerr stats fetch failed for %s: %s", service.id, exc)
|
||||||
|
# Serve stale if we have it, else surface the error.
|
||||||
|
if hit:
|
||||||
|
return hit[1]
|
||||||
|
return StatsResult(stats=[], detail=f"Jellyseerr fetch failed: {exc}")
|
||||||
|
|
||||||
|
result = StatsResult(
|
||||||
|
stats=[StatValue(key=key, label=label, value=int(counts.get(key, 0))) for key, label in _JELLYSEERR_STATS],
|
||||||
|
recent=recent,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._cache[service.id] = (time.time(), result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
register_stats_provider("jellyfin", JellyseerrStatsProvider())
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_jellyseer_requests(service: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Return Jellyseerr requests for the Requests tab table.
|
||||||
|
|
||||||
|
Reuses the per-service cached client (shared with the stats widgets) so the
|
||||||
|
tab and widgets don't each open a new session.
|
||||||
|
"""
|
||||||
|
base_url = str(service.config.get("jellyseerr_url") or "")
|
||||||
|
api_key = str((service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or "")
|
||||||
|
if not base_url or not api_key:
|
||||||
|
return []
|
||||||
|
client = _jellyseer_client((service.id, base_url, api_key))
|
||||||
|
return client.open_requests()
|
||||||
@@ -21,10 +21,18 @@ from typing import Any
|
|||||||
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
||||||
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
||||||
WINDOW_PRESETS: dict[str, int] = {
|
WINDOW_PRESETS: dict[str, int] = {
|
||||||
|
"5m": 300,
|
||||||
|
"15m": 900,
|
||||||
|
"30m": 1_800,
|
||||||
"1h": 3_600,
|
"1h": 3_600,
|
||||||
|
"3h": 10_800,
|
||||||
"6h": 21_600,
|
"6h": 21_600,
|
||||||
|
"12h": 43_200,
|
||||||
"24h": 86_400,
|
"24h": 86_400,
|
||||||
|
"2d": 172_800,
|
||||||
"7d": 604_800,
|
"7d": 604_800,
|
||||||
|
"14d": 1_209_600,
|
||||||
|
"30d": 2_592_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
||||||
@@ -36,13 +44,22 @@ def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
|||||||
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
||||||
|
|
||||||
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
||||||
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
|
sub-15s resolutions on high-cardinality queries. The 5m and 15m presets
|
||||||
the resulting point count to land in the 100–300 band; with
|
therefore return 20 and 60 points respectively; all longer presets stay
|
||||||
``target_points=200`` every preset yields 200 points.
|
in the target 100–300 point band.
|
||||||
"""
|
"""
|
||||||
return max(15, round(window_seconds / target_points))
|
return max(15, round(window_seconds / target_points))
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_label(label: str, seen: dict[str, int]) -> str:
|
||||||
|
"""Apply `` (n)`` suffix on collision. Mutates and reads from ``seen`` dict."""
|
||||||
|
if label in seen:
|
||||||
|
seen[label] += 1
|
||||||
|
return f"{label} ({seen[label]})"
|
||||||
|
seen[label] = 0
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
||||||
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
||||||
@@ -62,12 +79,7 @@ def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
metric = entry.get("metric") or {}
|
metric = entry.get("metric") or {}
|
||||||
values = entry.get("values") or []
|
values = entry.get("values") or []
|
||||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
|
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
|
||||||
label = " ".join(parts) if parts else "value"
|
label = _dedup_label(" ".join(parts) if parts else "value", seen)
|
||||||
if label in seen:
|
|
||||||
seen[label] += 1
|
|
||||||
label = f"{label} ({seen[label]})"
|
|
||||||
else:
|
|
||||||
seen[label] = 0
|
|
||||||
points: list[dict[str, Any]] = []
|
points: list[dict[str, Any]] = []
|
||||||
for ts, raw in values:
|
for ts, raw in values:
|
||||||
t = _safe_int(ts)
|
t = _safe_int(ts)
|
||||||
@@ -79,6 +91,52 @@ def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
return series
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Turn a Grafana ``/api/ds/query`` response into the ``{label, points}`` series shape.
|
||||||
|
|
||||||
|
Parses ``results.<refId>.frames[]`` where each frame has:
|
||||||
|
- ``data.values``: ``[[timestamps...], [values...]]``
|
||||||
|
- ``schema.fields``: ``[{name, labels?, config?: {displayName?}}, ...]``
|
||||||
|
|
||||||
|
Label rule (same as ``normalize_prometheus_matrix``, shared via ``_dedup_label``):
|
||||||
|
1. Prefer ``config.displayName`` (explicitly set in Grafana).
|
||||||
|
2. Else use Prometheus metric labels (sorted ``k=v``, excluding ``__``-prefixed).
|
||||||
|
3. Else fall back to the field name, or ``"value"``.
|
||||||
|
4. Dedup collisions with `` (n)`` suffix.
|
||||||
|
"""
|
||||||
|
series: list[dict[str, Any]] = []
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
results = raw.get("results", {})
|
||||||
|
for _ref_id, ref_data in results.items():
|
||||||
|
for frame in ref_data.get("frames", []):
|
||||||
|
values = frame.get("data", {}).get("values", [])
|
||||||
|
if len(values) < 2:
|
||||||
|
continue
|
||||||
|
timestamps = values[0]
|
||||||
|
vals = values[1]
|
||||||
|
# Derive a meaningful series label from the frame metadata.
|
||||||
|
fields = frame.get("schema", {}).get("fields", [])
|
||||||
|
value_field = fields[-1] if fields else {}
|
||||||
|
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||||
|
frame_labels = value_field.get("labels") or {}
|
||||||
|
if display_name:
|
||||||
|
label = str(display_name)
|
||||||
|
elif frame_labels:
|
||||||
|
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not str(k).startswith("__")]
|
||||||
|
label = " ".join(parts) if parts else "value"
|
||||||
|
else:
|
||||||
|
label = str(value_field.get("name", "value"))
|
||||||
|
label = _dedup_label(label, seen)
|
||||||
|
points = []
|
||||||
|
for t, v in zip(timestamps, vals):
|
||||||
|
safe_t = _safe_int(t)
|
||||||
|
if safe_t is None:
|
||||||
|
continue
|
||||||
|
points.append({"t": safe_t, "v": _safe_float(v)})
|
||||||
|
series.append({"label": label, "points": points})
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(raw: Any) -> float | None:
|
def _safe_float(raw: Any) -> float | None:
|
||||||
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
||||||
if raw in _NON_NUMERIC:
|
if raw in _NON_NUMERIC:
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||||
from media_library_viewer_api.domain.dashboard import (
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
@@ -28,15 +30,25 @@ from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
|||||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||||
|
from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — registers the Jellyseerr stats provider
|
||||||
from media_library_viewer_api.widgets.prometheus_range import (
|
from media_library_viewer_api.widgets.prometheus_range import (
|
||||||
WINDOW_PRESETS,
|
WINDOW_PRESETS,
|
||||||
normalize_prometheus_matrix,
|
normalize_grafana_frames,
|
||||||
|
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
|
||||||
step_for_window,
|
step_for_window,
|
||||||
)
|
)
|
||||||
|
from media_library_viewer_api.widgets.stats_provider import get_stats_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServiceRecord:
|
class ServiceRecord:
|
||||||
"""Runtime view of a service instance with decrypted secrets."""
|
"""Runtime view of a service instance with decrypted secrets."""
|
||||||
@@ -104,113 +116,118 @@ class StaticWidgetSource:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class PrometheusWidgetSource:
|
class MetricSource:
|
||||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
"""Run PromQL queries through a Grafana gateway (``/api/ds/query``)."""
|
||||||
|
|
||||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"error": "Prometheus widget is missing its service"}
|
return {"error": "Prometheus widget is missing its service"}
|
||||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||||
|
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||||
if widget_kind == "chart":
|
if widget_kind == "chart":
|
||||||
return await self._fetch_chart(base_url, timeout, config)
|
return await self._fetch_chart(grafana_url, api_key, datasource_uid, timeout, config)
|
||||||
if widget_kind == "gauge":
|
if widget_kind == "gauge":
|
||||||
return await self._fetch_gauge(base_url, timeout, config)
|
return await self._fetch_gauge(grafana_url, api_key, datasource_uid, timeout, config)
|
||||||
if widget_kind == "mean":
|
if widget_kind == "mean":
|
||||||
return await self._fetch_mean(base_url, timeout, config)
|
return await self._fetch_mean(grafana_url, api_key, datasource_uid, timeout, config)
|
||||||
# Default: instant-query metric path (unchanged).
|
# Default: instant-query metric path.
|
||||||
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
|
return await self._fetch_metric(grafana_url, api_key, datasource_uid, timeout, config)
|
||||||
return raw
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("prometheus adapter failed")
|
logger.exception("prometheus adapter failed")
|
||||||
return {"error": f"Prometheus query failed: {exc}"}
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
|
||||||
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
|
async def _gateway_query(
|
||||||
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
|
self,
|
||||||
|
grafana_url: str,
|
||||||
|
api_key: str,
|
||||||
|
datasource_uid: str,
|
||||||
|
timeout: int,
|
||||||
|
promql: str,
|
||||||
|
window_seconds: int | None = None,
|
||||||
|
max_data_points: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""POST ``{grafana_url}/api/ds/query``; return raw Grafana JSON or ``{error}``.
|
||||||
|
|
||||||
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
|
- ``window_seconds=None`` → instant mapping (``from=now-1m, maxDataPoints=1``).
|
||||||
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
|
- ``window_seconds=<N>`` → range query (``from=now-Ns``, step derived).
|
||||||
per SC-103).
|
|
||||||
"""
|
"""
|
||||||
step = step_for_window(window)
|
if not grafana_url:
|
||||||
end = int(time.time())
|
return {"error": "grafana_url is required"}
|
||||||
start = end - window
|
if not api_key:
|
||||||
try:
|
return {"error": "grafana_api_key is required"}
|
||||||
response = await asyncio.wait_for(
|
step = step_for_window(window_seconds) if window_seconds else 15
|
||||||
asyncio.to_thread(
|
interval_ms = step * 1000
|
||||||
requests.get,
|
body = {
|
||||||
f"{base_url}/api/v1/query_range",
|
"queries": [
|
||||||
params={"query": promql, "start": start, "end": end, "step": step},
|
{
|
||||||
timeout=timeout,
|
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||||
),
|
"expr": promql,
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalMs": interval_ms,
|
||||||
|
"maxDataPoints": 1 if window_seconds is None else max_data_points,
|
||||||
|
"refId": "A",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"from": f"now-{window_seconds or 60}s" if window_seconds else "now-1m",
|
||||||
|
"to": "now",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_post() -> dict[str, Any]:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{grafana_url}/api/ds/query",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
payload = response.json()
|
return resp.json()
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {"error": "Prometheus query timed out"}
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
logger.exception("prometheus range query failed")
|
|
||||||
return {"error": f"Prometheus query failed: {exc}"}
|
|
||||||
result = payload.get("data", {}).get("result", [])
|
|
||||||
return {"matrix": result}
|
|
||||||
|
|
||||||
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
|
|
||||||
"""Run a Prometheus ``/api/v1/query`` instant query.
|
|
||||||
|
|
||||||
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
|
|
||||||
``{"result": data}`` on success or ``{"error": str}`` (never raises,
|
|
||||||
per SC-103).
|
|
||||||
"""
|
|
||||||
if not promql:
|
|
||||||
return {"error": "promql is required"}
|
|
||||||
try:
|
try:
|
||||||
response = await asyncio.wait_for(
|
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||||
asyncio.to_thread(
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
requests.get,
|
return {"error": "Grafana query timed out"}
|
||||||
f"{base_url}/api/v1/query",
|
|
||||||
params={"query": promql},
|
|
||||||
timeout=timeout,
|
|
||||||
),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {"error": "Prometheus query timed out"}
|
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.exception("prometheus instant query failed")
|
logger.exception("grafana gateway query failed")
|
||||||
return {"error": f"Prometheus query failed: {exc}"}
|
return {"error": f"Grafana query failed: {exc}"}
|
||||||
return {"result": payload.get("data", {})}
|
|
||||||
|
|
||||||
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
async def _fetch_chart(
|
||||||
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
|
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Range query → ``{series}`` for the chart widget (GM-106)."""
|
||||||
promql = config.get("promql")
|
promql = config.get("promql")
|
||||||
if not promql:
|
if not promql:
|
||||||
return {"error": "promql is required"}
|
return {"error": "promql is required"}
|
||||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||||
raw = await self._range_query(base_url, timeout, promql, window)
|
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||||
if "error" in raw:
|
if "error" in raw:
|
||||||
return raw
|
return raw
|
||||||
return {"series": normalize_prometheus_matrix(raw["matrix"])}
|
return {"series": normalize_grafana_frames(raw)}
|
||||||
|
|
||||||
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
async def _fetch_gauge(
|
||||||
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
|
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Instant query → scalar for the gauge widget (GM-107).
|
||||||
|
|
||||||
Scalar-only: a multi-series query returns an error (SC-111). Threshold
|
Scalar-only: a multi-series query returns an error. Threshold config is
|
||||||
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
|
passed through for the frontend renderer.
|
||||||
through for the frontend renderer.
|
|
||||||
"""
|
"""
|
||||||
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
|
promql = config.get("promql") or ""
|
||||||
|
if not promql:
|
||||||
|
return {"error": "promql is required"}
|
||||||
|
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||||
if "error" in raw:
|
if "error" in raw:
|
||||||
return raw
|
return raw
|
||||||
result = raw["result"].get("result", [])
|
series = normalize_grafana_frames(raw)
|
||||||
if len(result) != 1:
|
if len(series) != 1:
|
||||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||||
try:
|
points = series[0]["points"]
|
||||||
value = float(result[0]["value"][1])
|
if not points:
|
||||||
except (KeyError, IndexError, ValueError, TypeError):
|
return {"error": "Gauge query returned no scalar value"}
|
||||||
|
value = points[-1]["v"]
|
||||||
|
if value is None:
|
||||||
return {"error": "Gauge query returned no scalar value"}
|
return {"error": "Gauge query returned no scalar value"}
|
||||||
return {
|
return {
|
||||||
"value": value,
|
"value": value,
|
||||||
@@ -221,36 +238,46 @@ class PrometheusWidgetSource:
|
|||||||
"unit": config.get("unit"),
|
"unit": config.get("unit"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
async def _fetch_mean(
|
||||||
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
|
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Range query → client-side mean for the mean widget (GM-108).
|
||||||
|
|
||||||
Runs ``query_range`` over the configured window preset, averages all
|
Runs a gateway range query over the configured window preset, averages
|
||||||
non-null numeric samples of the single series, and returns a scalar.
|
all non-null numeric samples of the single series, and returns a scalar.
|
||||||
Scalar-only: a multi-series query returns an error (SC-114).
|
Scalar-only: a multi-series query returns an error.
|
||||||
"""
|
"""
|
||||||
promql = config.get("promql")
|
promql = config.get("promql")
|
||||||
if not promql:
|
if not promql:
|
||||||
return {"error": "promql is required"}
|
return {"error": "promql is required"}
|
||||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||||
raw = await self._range_query(base_url, timeout, promql, window)
|
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||||
if "error" in raw:
|
if "error" in raw:
|
||||||
return raw
|
return raw
|
||||||
result = raw["matrix"]
|
series = normalize_grafana_frames(raw)
|
||||||
if len(result) != 1:
|
if len(series) != 1:
|
||||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||||
points = result[0].get("values") or []
|
nums = [p["v"] for p in series[0]["points"] if p["v"] is not None]
|
||||||
nums: list[float] = []
|
|
||||||
for _, v in points:
|
|
||||||
if v in (None, "NaN", "+Inf", "-Inf"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
nums.append(float(v))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if not nums:
|
if not nums:
|
||||||
return {"error": "Mean query returned no numeric samples in the window"}
|
return {"error": "Mean query returned no numeric samples in the window"}
|
||||||
mean = sum(nums) / len(nums)
|
return {"value": sum(nums) / len(nums), "unit": config.get("unit")}
|
||||||
return {"value": mean, "unit": config.get("unit")}
|
|
||||||
|
async def _fetch_metric(
|
||||||
|
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Instant query → ``{result}`` for the metric widget (GM-109).
|
||||||
|
|
||||||
|
Returns ``{result: [{label, points}]}`` — the normalized series shape.
|
||||||
|
The frontend ``PrometheusMetricWidget`` renders the last point of each
|
||||||
|
series.
|
||||||
|
"""
|
||||||
|
promql = config.get("promql") or ""
|
||||||
|
if not promql:
|
||||||
|
return {"error": "promql is required"}
|
||||||
|
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||||
|
if "error" in raw:
|
||||||
|
return raw
|
||||||
|
return {"result": normalize_grafana_frames(raw)}
|
||||||
|
|
||||||
|
|
||||||
class AlertmanagerWidgetSource:
|
class AlertmanagerWidgetSource:
|
||||||
@@ -261,7 +288,7 @@ class AlertmanagerWidgetSource:
|
|||||||
if service is None:
|
if service is None:
|
||||||
return {"error": "Alertmanager widget is missing its service"}
|
return {"error": "Alertmanager widget is missing its service"}
|
||||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
timeout = int(service.config.get("timeout_seconds") or 5)
|
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||||
severity_filter = config.get("severity_filter") or None
|
severity_filter = config.get("severity_filter") or None
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
api_key = str(service.secrets.get("api_key") or "")
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
@@ -280,7 +307,7 @@ class AlertmanagerWidgetSource:
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
alerts = payload.get("data", []) if isinstance(payload, dict) else []
|
alerts = payload.get("data", []) if isinstance(payload, dict) else []
|
||||||
return summarize_alerts(alerts, severity_filter=severity_filter)
|
return summarize_alerts(alerts, severity_filter=severity_filter)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
return {"error": "Widget data fetch timed out"}
|
return {"error": "Widget data fetch timed out"}
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.exception("alertmanager adapter failed")
|
logger.exception("alertmanager adapter failed")
|
||||||
@@ -290,6 +317,36 @@ class AlertmanagerWidgetSource:
|
|||||||
return {"error": f"Alertmanager query failed: {exc}"}
|
return {"error": f"Alertmanager query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikWidgetSource:
|
||||||
|
"""Fetch bounded, display-safe Authentik directory metadata."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "Authentik widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "")
|
||||||
|
api_token = str(service.secrets.get("api_token") or "")
|
||||||
|
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
|
||||||
|
limit = max(1, min(_safe_int(config.get("limit") or 10, 10), 50))
|
||||||
|
client = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(AuthentikClient, base_url, api_token, timeout), timeout=timeout
|
||||||
|
)
|
||||||
|
if widget_kind == "access_summary":
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.access_summaries, page=1, page_size=limit), timeout=timeout
|
||||||
|
)
|
||||||
|
if widget_kind == "groups":
|
||||||
|
return await asyncio.wait_for(asyncio.to_thread(client.groups, limit=limit), timeout=timeout)
|
||||||
|
if widget_kind == "applications":
|
||||||
|
return await asyncio.wait_for(asyncio.to_thread(client.applications, limit=limit), timeout=timeout)
|
||||||
|
return {"error": f"Unknown Authentik widget kind: {widget_kind}"}
|
||||||
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
|
return {"error": "Authentik data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("authentik adapter failed")
|
||||||
|
return {"error": f"Authentik data fetch failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
class JellyfinWidgetSource:
|
class JellyfinWidgetSource:
|
||||||
"""Fetch Jellyfin sessions and map them to activity rows."""
|
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||||
|
|
||||||
@@ -300,7 +357,7 @@ class JellyfinWidgetSource:
|
|||||||
return {"error": "Jellyfin widget is missing its service"}
|
return {"error": "Jellyfin widget is missing its service"}
|
||||||
base_url = str(service.config.get("base_url") or "")
|
base_url = str(service.config.get("base_url") or "")
|
||||||
api_key = str(service.secrets.get("api_key") or "")
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||||
client = await asyncio.wait_for(
|
client = await asyncio.wait_for(
|
||||||
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
|
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -315,7 +372,7 @@ class JellyfinWidgetSource:
|
|||||||
]
|
]
|
||||||
rows = _map_sessions_to_activity_rows(sessions)
|
rows = _map_sessions_to_activity_rows(sessions)
|
||||||
return {"sessions": rows}
|
return {"sessions": rows}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
return {"error": "Widget data fetch timed out"}
|
return {"error": "Widget data fetch timed out"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("jellyfin adapter failed")
|
logger.exception("jellyfin adapter failed")
|
||||||
@@ -346,7 +403,7 @@ class SshTaskWidgetSource:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
|
return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
_record_timeout(service, config, timeout)
|
_record_timeout(service, config, timeout)
|
||||||
return {"error": "Widget data fetch timed out"}
|
return {"error": "Widget data fetch timed out"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -370,6 +427,50 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
|||||||
logger.exception("failed to record ssh task timeout")
|
logger.exception("failed to record ssh task timeout")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> QbittorrentClient:
|
||||||
|
"""Build (or reuse) a qBittorrent client for a service.
|
||||||
|
|
||||||
|
Cached per (service_id, base_url, username, password, timeout) so the
|
||||||
|
authenticated session/SID cookie persists across widget fetches. A
|
||||||
|
credentials or URL change produces a new cache key, so stale clients are
|
||||||
|
not reused after reconfiguration. Mirrors the Jellyfin client cache in
|
||||||
|
dependencies._jellyfin_client_for.
|
||||||
|
"""
|
||||||
|
_service_id, base_url, username, password, timeout = cache_key
|
||||||
|
return QbittorrentClient(base_url, username, password, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
_QBITTORRENT_DOWNLOAD_STATES = frozenset(
|
||||||
|
{"downloading", "forceddl", "stalleddl", "queueddl", "metadl", "forcedmetadl", "allocating", "checkingdl"}
|
||||||
|
)
|
||||||
|
_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup", "queuedup", "checkingup"})
|
||||||
|
_QBITTORRENT_OTHER_ACTIVE_STATES = frozenset({"checkingresumedata", "moving"})
|
||||||
|
|
||||||
|
|
||||||
|
def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
|
||||||
|
"""Return the transfer direction for active qBittorrent states or speeds."""
|
||||||
|
state = str(torrent.get("state") or "").lower()
|
||||||
|
if state in _QBITTORRENT_DOWNLOAD_STATES:
|
||||||
|
return "downloading"
|
||||||
|
if state in _QBITTORRENT_UPLOAD_STATES:
|
||||||
|
return "uploading"
|
||||||
|
if _safe_int(torrent.get("dlspeed")) > 0:
|
||||||
|
return "downloading"
|
||||||
|
if _safe_int(torrent.get("upspeed")) > 0:
|
||||||
|
return "uploading"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _qbit_torrent_transfer_direction(torrent: dict[str, Any]) -> str | None:
|
||||||
|
"""Return a direction only while qBittorrent reports nonzero throughput."""
|
||||||
|
if _safe_int(torrent.get("dlspeed")) > 0:
|
||||||
|
return "downloading"
|
||||||
|
if _safe_int(torrent.get("upspeed")) > 0:
|
||||||
|
return "uploading"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentWidgetSource:
|
class QbittorrentWidgetSource:
|
||||||
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
||||||
|
|
||||||
@@ -377,55 +478,84 @@ class QbittorrentWidgetSource:
|
|||||||
try:
|
try:
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"error": "qBittorrent widget is missing its service"}
|
return {"error": "qBittorrent widget is missing its service"}
|
||||||
|
|
||||||
|
if widget_kind == "speed":
|
||||||
|
configured_window = config.get("window_seconds")
|
||||||
|
if configured_window == "all":
|
||||||
|
samples = QbittorrentSampleStore().window(service.id)
|
||||||
|
else:
|
||||||
|
window_seconds = _safe_int(
|
||||||
|
configured_window or service.config.get("sample_retention_seconds") or 1_800
|
||||||
|
)
|
||||||
|
window_seconds = max(60, min(window_seconds, 86_400))
|
||||||
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
|
samples = QbittorrentSampleStore().window(service.id, since_ts=since_ts)
|
||||||
|
series = [
|
||||||
|
{
|
||||||
|
"label": "download",
|
||||||
|
"points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "upload",
|
||||||
|
"points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {"series": series}
|
||||||
|
|
||||||
base_url = str(service.config.get("base_url") or "")
|
base_url = str(service.config.get("base_url") or "")
|
||||||
username = str(service.secrets.get("username") or "")
|
username = str(service.secrets.get("username") or "")
|
||||||
password = str(service.secrets.get("password") or "")
|
password = str(service.secrets.get("password") or "")
|
||||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||||
if not base_url or not username or not password:
|
if not base_url or not username or not password:
|
||||||
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
||||||
|
|
||||||
client = QbittorrentClient(base_url, username, password, timeout)
|
# Reuse one authenticated client per service so the SID cookie
|
||||||
|
# persists across fetches and we don't re-login on every widget
|
||||||
|
# poll. qBittorrent verifies passwords with slow PBKDF2 hashing,
|
||||||
|
# so logging in on every fetch (3 widgets x frequent polls)
|
||||||
|
# saturates its web thread pool and the reverse proxy returns 504
|
||||||
|
# gateway timeouts. The client re-logins itself on a 403.
|
||||||
|
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
|
||||||
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
||||||
server_state = data.get("server_state", {})
|
|
||||||
torrents = data.get("torrents", {})
|
torrents = data.get("torrents", {})
|
||||||
|
|
||||||
if widget_kind == "totals":
|
if widget_kind == "totals":
|
||||||
by_state: dict[str, int] = {}
|
by_state: dict[str, int] = {}
|
||||||
for t in torrents.values():
|
by_direction = {"downloading": 0, "uploading": 0}
|
||||||
state = str(t.get("state", "unknown"))
|
for torrent in torrents.values():
|
||||||
|
state = str(torrent.get("state") or "unknown")
|
||||||
by_state[state] = by_state.get(state, 0) + 1
|
by_state[state] = by_state.get(state, 0) + 1
|
||||||
return {"total": len(torrents), "by_state": by_state}
|
direction = _qbit_torrent_direction(torrent)
|
||||||
|
if direction:
|
||||||
|
by_direction[direction] += 1
|
||||||
|
return {
|
||||||
|
"total": len(torrents),
|
||||||
|
"by_state": by_state,
|
||||||
|
"by_direction": by_direction,
|
||||||
|
}
|
||||||
|
|
||||||
if widget_kind == "active":
|
if widget_kind == "active":
|
||||||
active = [
|
active = []
|
||||||
{
|
for torrent in torrents.values():
|
||||||
"name": t.get("name"),
|
direction = _qbit_torrent_transfer_direction(torrent)
|
||||||
"state": t.get("state"),
|
if not direction:
|
||||||
"size": t.get("size"),
|
continue
|
||||||
"progress": t.get("progress"),
|
active.append(
|
||||||
"dl_speed": t.get("dlspeed"),
|
{
|
||||||
"up_speed": t.get("upspeed"),
|
"name": torrent.get("name"),
|
||||||
}
|
"state": torrent.get("state"),
|
||||||
for t in torrents.values()
|
"direction": direction,
|
||||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
"size": torrent.get("size"),
|
||||||
]
|
"progress": torrent.get("progress"),
|
||||||
|
"ratio": torrent.get("ratio"),
|
||||||
|
"dl_speed": torrent.get("dlspeed"),
|
||||||
|
"up_speed": torrent.get("upspeed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"torrents": active}
|
return {"torrents": active}
|
||||||
|
|
||||||
if widget_kind == "speed":
|
|
||||||
dl = int(server_state.get("dl_info_speed", 0))
|
|
||||||
up = int(server_state.get("up_info_speed", 0))
|
|
||||||
ts = int(time.time())
|
|
||||||
store = QbittorrentSampleStore()
|
|
||||||
store.append(service.id, ts, dl, up)
|
|
||||||
samples = store.window(service.id)
|
|
||||||
series = [
|
|
||||||
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
|
|
||||||
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
|
|
||||||
]
|
|
||||||
return {"series": series}
|
|
||||||
|
|
||||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
return {"error": "qBittorrent data fetch timed out"}
|
return {"error": "qBittorrent data fetch timed out"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("qbittorrent adapter failed")
|
logger.exception("qbittorrent adapter failed")
|
||||||
@@ -437,11 +567,12 @@ class QbittorrentWidgetSource:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
"prometheus": PrometheusWidgetSource(),
|
"prometheus": MetricSource(),
|
||||||
"qbittorrent": QbittorrentWidgetSource(),
|
"qbittorrent": QbittorrentWidgetSource(),
|
||||||
"alertmanager": AlertmanagerWidgetSource(),
|
"alertmanager": AlertmanagerWidgetSource(),
|
||||||
"jellyfin": JellyfinWidgetSource(),
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
"ssh_tasks": SshTaskWidgetSource(),
|
"authentik": AuthentikWidgetSource(),
|
||||||
|
"remote_machine": SshTaskWidgetSource(),
|
||||||
}
|
}
|
||||||
|
|
||||||
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
|
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
@@ -456,3 +587,47 @@ def get_service_adapter(service_type: str) -> WidgetSource | None:
|
|||||||
|
|
||||||
def get_builtin_adapter(kind: str) -> WidgetSource | None:
|
def get_builtin_adapter(kind: str) -> WidgetSource | None:
|
||||||
return BUILTIN_ADAPTERS.get(kind)
|
return BUILTIN_ADAPTERS.get(kind)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsWidgetSource:
|
||||||
|
"""Generic source for ``stat`` and ``stats_overview`` widgets.
|
||||||
|
|
||||||
|
Dispatches to the service type's registered :class:`StatsProvider`, so any
|
||||||
|
stats-provider service gets these two widget kinds for free. The widgets
|
||||||
|
router routes widget_kind in {"stat", "stats_overview"} here regardless of
|
||||||
|
service type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "Stats widget is missing its service"}
|
||||||
|
provider = get_stats_provider(service.service_type)
|
||||||
|
if provider is None:
|
||||||
|
return {"error": f"No stats provider for service type '{service.service_type}'"}
|
||||||
|
timeout = _safe_int(service.config.get("timeout_seconds") or 30, 30)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(asyncio.to_thread(provider.fetch_stats, service), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError as _timeout_error:
|
||||||
|
return {"error": "Stats fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("stats provider failed service=%s", service.id)
|
||||||
|
return {"error": f"Stats fetch failed: {exc}"}
|
||||||
|
if result.detail and not result.stats:
|
||||||
|
return {"error": result.detail}
|
||||||
|
if widget_kind == "stats_overview":
|
||||||
|
return {
|
||||||
|
"stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats],
|
||||||
|
"recent": result.recent,
|
||||||
|
}
|
||||||
|
stat_key = str(config.get("stat") or "")
|
||||||
|
match = next((s for s in result.stats if s.key == stat_key), None)
|
||||||
|
if match is None:
|
||||||
|
return {"error": f"Unknown stat '{stat_key}'"}
|
||||||
|
return {"key": match.key, "label": match.label, "value": match.value}
|
||||||
|
|
||||||
|
|
||||||
|
_STATS_ADAPTER = StatsWidgetSource()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats_adapter() -> WidgetSource | None:
|
||||||
|
return _STATS_ADAPTER
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Generic stats-provider abstraction.
|
||||||
|
|
||||||
|
A service type that exposes a set of named numeric metrics (Jellyseerr request
|
||||||
|
stats today; Sonarr/Radarr later) registers a :class:`StatsProvider`. The
|
||||||
|
widget layer renders the provider's output as either a single-stat widget (a
|
||||||
|
selector picks one metric) or a stats-overview grid. Keeping this behind a
|
||||||
|
small interface means future stats services reuse the same widgets + tab
|
||||||
|
without per-service widget kinds.
|
||||||
|
|
||||||
|
Providers are synchronous (they do blocking HTTP) and are run in a thread by
|
||||||
|
the widget source / router. A provider should cache/de-duplicate fetches so
|
||||||
|
that several widgets + the tab don't each hit the upstream service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StatValue:
|
||||||
|
"""One named metric."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
value: int | float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatsResult:
|
||||||
|
"""Normalized output of a stats provider."""
|
||||||
|
|
||||||
|
stats: list[StatValue]
|
||||||
|
recent: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
# Optional human note (e.g. "not configured"); surfaced as an error when
|
||||||
|
# there are no stats.
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class StatsProvider(Protocol):
|
||||||
|
"""Return the current stats for a service instance.
|
||||||
|
|
||||||
|
``service`` is a duck-typed record with ``id``, ``service_type``,
|
||||||
|
``config`` and ``secrets`` (see widgets.sources.ServiceRecord).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fetch_stats(self, service: Any) -> StatsResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
# Registry keyed by service_type. A service type with no provider simply has no
|
||||||
|
# stat widgets available.
|
||||||
|
STATS_PROVIDERS: dict[str, StatsProvider] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_stats_provider(service_type: str, provider: StatsProvider) -> None:
|
||||||
|
STATS_PROVIDERS[service_type] = provider
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats_provider(service_type: str) -> StatsProvider | None:
|
||||||
|
return STATS_PROVIDERS.get(service_type)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/workers
|
dir: backend/src/media_library_viewer_api/workers
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
|
Background task workers that handle long-running media indexing operations external to the main request/response cycle.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ dir: backend/src/media_library_viewer_api/workers
|
|||||||
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
|
Background task workers that handle long-running media indexing operations external to the main request/response cycle.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Marks the directory as a Python package for worker entrypoints used in background task processing.
|
- __init__.py | Marks the directory as a Python package for worker entrypoints used in background task processing.
|
||||||
- media_index_worker.py | This file acts as a standalone subprocess worker that asynchronously builds and updates a media index from a Jellyfin server, allowing the main API to remain responsive. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:_resolve_jellyfin(service_id: str) → tuple[Any, str], call:get_settings_store, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:int, call:JellyfinClient, call:client.users, call:client.libraries, call:next, call:u.get, call:logger.info, call:logger.warning, raise:RuntimeError, func:run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:_resolve_jellyfin, call:client.libraries, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies
|
- media_index_worker.py | Subprocess worker that builds a media index from a Jellyfin server, reporting progress and supporting cooperative cancellation via metadata in a database. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:_resolve_jellyfin(service_id: str) → tuple[Any, str], call:get_settings_store, call:_service_record, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:int, call:max, call:float, call:JellyfinClient, call:str(service.get("config", {}).get("user_id") or "").strip, call:client.resolve_user_id, raise:RuntimeError, func:run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:_resolve_jellyfin, call:client.libraries, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies
|
||||||
## arch
|
## arch
|
||||||
Standalone subprocess worker pattern that decouples long-running data synchronization tasks from the main API process.
|
Subprocess-based worker pattern with cooperative cancellation via database metadata and progress reporting, keeping heavy I/O isolated from the API server process.
|
||||||
## tags
|
## tags
|
||||||
call:, metadata, set, index, jellyfin, settings, media, media_library_viewer_api
|
call:, metadata, set, index, jellyfin, settings, media, progress
|
||||||
## symbols
|
## symbols
|
||||||
- _set_build_metadata
|
- _set_build_metadata
|
||||||
- _cancel_requested
|
- _cancel_requested
|
||||||
|
|||||||
@@ -105,39 +105,18 @@ def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
|
|||||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
if not base_url or not api_key:
|
if not base_url or not api_key:
|
||||||
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||||
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
|
timeout = int(service.get("config", {}).get("timeout_seconds", 60))
|
||||||
client = JellyfinClient(base_url, api_key, timeout)
|
# The build is a background operation and can afford a patient read timeout.
|
||||||
user_id = str(service.get("config", {}).get("user_id") or "")
|
# The UI-grade config timeout_seconds (default 60) governs the widget paths;
|
||||||
if not user_id:
|
# the worker uses a larger floor so slow /Items pages on large libraries
|
||||||
users = client.users()
|
# don't ReadTimeout mid-build.
|
||||||
if not users:
|
read_timeout = max(float(timeout), 180.0)
|
||||||
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
client = JellyfinClient(base_url, api_key, read_timeout)
|
||||||
user_id = users[0]["Id"]
|
# The config field accepts either the internal Jellyfin Id or a username
|
||||||
else:
|
# (e.g. 'admin'). Jellyfin's /Users/{id}/... endpoints reject usernames
|
||||||
# Try the configured user_id directly. It might be the internal
|
# with HTTP 400, so always resolve to the internal Id before use.
|
||||||
# Jellyfin Id (a long hash) — in that case libraries() succeeds
|
user_id = str(service.get("config", {}).get("user_id") or "").strip() or None
|
||||||
# without an extra users() round-trip. Only if it fails do we
|
user_id = client.resolve_user_id(user_id)
|
||||||
# resolve it via the users API (the config field accepts usernames
|
|
||||||
# like 'admin' too, but Jellyfin's API rejects them on /Users/<id>).
|
|
||||||
try:
|
|
||||||
client.libraries(user_id)
|
|
||||||
except Exception:
|
|
||||||
users = client.users()
|
|
||||||
match = next((u for u in users if str(u.get("Name", "")) == user_id), None)
|
|
||||||
if match:
|
|
||||||
resolved = match["Id"]
|
|
||||||
logger.info(
|
|
||||||
"Resolved username '%s' to Jellyfin Id '%s'",
|
|
||||||
user_id,
|
|
||||||
resolved,
|
|
||||||
)
|
|
||||||
user_id = resolved
|
|
||||||
elif users:
|
|
||||||
user_id = users[0]["Id"]
|
|
||||||
logger.warning(
|
|
||||||
"user_id '%s' not found; falling back to first user",
|
|
||||||
service.get("config", {}).get("user_id"),
|
|
||||||
)
|
|
||||||
return client, user_id
|
return client, user_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+184
-82
@@ -5,10 +5,12 @@ without requiring real remote connections.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from media_library_viewer_api.clients.ssh import CommandResult
|
from media_library_viewer_api.clients.ssh import CommandResult
|
||||||
@@ -255,8 +257,7 @@ class TestSettingsReset:
|
|||||||
assert payload["status"] == "reset"
|
assert payload["status"] == "reset"
|
||||||
assert not media_db.exists()
|
assert not media_db.exists()
|
||||||
assert not media_wal.exists()
|
assert not media_wal.exists()
|
||||||
assert store.get_machine("local") is None
|
assert store.list_services("remote_machine") == []
|
||||||
assert len(store.list_machines()) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# --- Files ---
|
# --- Files ---
|
||||||
@@ -467,35 +468,6 @@ class TestJobs:
|
|||||||
# --- Monitoring ---
|
# --- Monitoring ---
|
||||||
|
|
||||||
|
|
||||||
class TestMonitoring:
|
|
||||||
def test_prometheus_targets_empty(self, test_client):
|
|
||||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == []
|
|
||||||
|
|
||||||
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.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"
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveServiceRecord:
|
class TestResolveServiceRecord:
|
||||||
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
||||||
|
|
||||||
@@ -567,46 +539,6 @@ class TestResolveServiceRecord:
|
|||||||
assert resolve_service_record(store, "alertmanager", None) is None
|
assert resolve_service_record(store, "alertmanager", None) is None
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsMachines:
|
|
||||||
def test_machine_appears_in_prometheus_targets(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",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
targets = test_client.get("/api/monitoring/prometheus-targets").json()
|
|
||||||
assert len(targets) == 1
|
|
||||||
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
|
||||||
|
|
||||||
def test_delete_machine_removed_from_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",
|
|
||||||
"node_exporter_enabled": True,
|
|
||||||
"node_exporter_port": 9200,
|
|
||||||
"node_exporter_scrape_host": "1.2.3.4",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert test_client.get("/api/monitoring/prometheus-targets").json() == []
|
|
||||||
|
|
||||||
|
|
||||||
def _am_service(name="Alertmanager", **config):
|
def _am_service(name="Alertmanager", **config):
|
||||||
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
||||||
cfg.update(config)
|
cfg.update(config)
|
||||||
@@ -751,11 +683,15 @@ class TestPrometheusStatus:
|
|||||||
|
|
||||||
def test_prometheus_status_when_unreachable(self, test_client):
|
def test_prometheus_status_when_unreachable(self, test_client):
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
id="p1",
|
||||||
|
service_type="prometheus",
|
||||||
|
name="Prometheus",
|
||||||
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.post", side_effect=__import__("requests").ConnectionError("refused")),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/prometheus-status")
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -763,22 +699,188 @@ class TestPrometheusStatus:
|
|||||||
assert data["up"] is False
|
assert data["up"] is False
|
||||||
assert data["error"] == "prometheus_unreachable"
|
assert data["error"] == "prometheus_unreachable"
|
||||||
|
|
||||||
def test_prometheus_status_returns_version(self, test_client):
|
def test_prometheus_status_returns_ok(self, test_client):
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
id="p1",
|
||||||
|
service_type="prometheus",
|
||||||
|
name="Prometheus",
|
||||||
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
health = MagicMock()
|
gateway_resp = MagicMock()
|
||||||
health.raise_for_status = MagicMock()
|
gateway_resp.raise_for_status = MagicMock()
|
||||||
build_info = MagicMock()
|
|
||||||
build_info.raise_for_status = MagicMock()
|
|
||||||
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
patch(f"{_MON}.requests.post", return_value=gateway_resp),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/prometheus-status")
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["up"] is True
|
assert data["up"] is True
|
||||||
assert data["version"] == "2.55.1"
|
assert data["version"] == "ok"
|
||||||
assert data["service_id"] == "p1"
|
assert data["service_id"] == "p1"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [401, 403])
|
||||||
|
def test_prometheus_status_returns_auth_failure_message(self, test_client, status_code):
|
||||||
|
# GM-110: a 401/403 from the Grafana gateway must surface as an
|
||||||
|
# auth-related error, not a crash and not a generic gateway error.
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="p1",
|
||||||
|
service_type="prometheus",
|
||||||
|
name="Prometheus",
|
||||||
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "bad-key"},
|
||||||
|
)
|
||||||
|
auth_error = requests.HTTPError(
|
||||||
|
f"{status_code} Client Error",
|
||||||
|
response=MagicMock(status_code=status_code),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.post", side_effect=auth_error),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert not data["up"]
|
||||||
|
assert data["error"] == "auth_failed"
|
||||||
|
assert data["service_id"] == "p1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrometheusStartupValidation:
|
||||||
|
"""GM-113: startup warns (never crashes) about old-shape prometheus services."""
|
||||||
|
|
||||||
|
def test_old_shape_prometheus_service_logs_migration_warning(self, tmp_path, caplog):
|
||||||
|
from media_library_viewer_api.main import _validate_prometheus_gateway_config
|
||||||
|
|
||||||
|
# Seed a prometheus service persisted with the OLD config shape: a
|
||||||
|
# ``base_url`` and no ``grafana_url`` (pre-gateway migration).
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "prometheus",
|
||||||
|
"name": "Legacy Prometheus",
|
||||||
|
"config": {"base_url": "https://prometheus.example.com"},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("media_library_viewer_api.main.get_settings_store", return_value=store),
|
||||||
|
caplog.at_level(logging.WARNING, logger="media_library_viewer_api.main"),
|
||||||
|
):
|
||||||
|
# Must not raise even though the service uses the deprecated shape.
|
||||||
|
_validate_prometheus_gateway_config()
|
||||||
|
|
||||||
|
# Best-effort validator logs a migration hint referencing grafana_url.
|
||||||
|
assert "grafana_url" in caplog.text
|
||||||
|
assert any(record.levelno == logging.WARNING for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Service credential tester endpoint (CT-101..CT-113) ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceTestEndpoint:
|
||||||
|
"""Tests for POST /api/services/test — dispatch, validation-first, no-persistence, no-secret-logs."""
|
||||||
|
|
||||||
|
def test_backups_returns_no_test_needed(self, test_client: TestClient) -> None:
|
||||||
|
"""backups has test_callable=None → returns ok=true with 'No test' detail."""
|
||||||
|
response = test_client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"service_type": "backups",
|
||||||
|
"name": "test",
|
||||||
|
"config": {"ingestion_label": "default"},
|
||||||
|
"secrets": {},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "No" in body["detail"]
|
||||||
|
|
||||||
|
def test_validation_first_rejects_malformed_config(self, test_client: TestClient) -> None:
|
||||||
|
"""Malformed config (schema-less base_url) → 422, no test_callable called."""
|
||||||
|
response = test_client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"name": "test",
|
||||||
|
"config": {"base_url": "localhost:8080"}, # missing http://
|
||||||
|
"secrets": {"username": "u", "password": "p"},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_no_persistence_after_test(self, test_client: TestClient, tmp_path) -> None:
|
||||||
|
"""Calling /test does not create a service row."""
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
before = len(store.list_services())
|
||||||
|
test_client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"service_type": "backups",
|
||||||
|
"name": "test",
|
||||||
|
"config": {"ingestion_label": "default"},
|
||||||
|
"secrets": {},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
after = len(store.list_services())
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
def test_secrets_not_logged(self, test_client: TestClient, caplog) -> None:
|
||||||
|
"""Secret values in the request body never reach any log line.
|
||||||
|
|
||||||
|
Unlike the trivial empty-secrets case, this drives the full endpoint
|
||||||
|
path (validate -> dispatch to the real test_callable -> success log)
|
||||||
|
with real-looking secret payloads. The per-type test_callables are
|
||||||
|
mocked at the network boundary so they succeed, proving the endpoint
|
||||||
|
does not log the secret values even though they are in the request body.
|
||||||
|
"""
|
||||||
|
api_key_secret = "glc_somethingverysecret"
|
||||||
|
password_secret = "SUPER-SECRET-PW-12345"
|
||||||
|
|
||||||
|
prom_response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
|
||||||
|
qbit_client = MagicMock()
|
||||||
|
qbit_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
|
||||||
|
|
||||||
|
with (
|
||||||
|
caplog.at_level(logging.DEBUG),
|
||||||
|
patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=prom_response),
|
||||||
|
patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=qbit_client),
|
||||||
|
):
|
||||||
|
prom_resp = test_client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"service_type": "prometheus",
|
||||||
|
"name": "test",
|
||||||
|
"config": {"grafana_url": "http://grafana:3000"},
|
||||||
|
"secrets": {"grafana_api_key": api_key_secret},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
qbit_resp = test_client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"name": "test",
|
||||||
|
"config": {"base_url": "http://qb:8080"},
|
||||||
|
"secrets": {"username": "u", "password": password_secret},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both requests must run the endpoint fully (validate + dispatch + success).
|
||||||
|
assert prom_resp.status_code == 200
|
||||||
|
assert prom_resp.json()["ok"] is True
|
||||||
|
assert qbit_resp.status_code == 200
|
||||||
|
assert qbit_resp.json()["ok"] is True
|
||||||
|
|
||||||
|
# Neither the full secret values nor meaningful fragments may leak into logs.
|
||||||
|
leaked = [
|
||||||
|
fragment for fragment in (api_key_secret, password_secret, "verysecret", "SUPER") if fragment in caplog.text
|
||||||
|
]
|
||||||
|
assert not leaked, f"secret fragments leaked into logs: {leaked!r}"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ TEST_KEY = Fernet.generate_key().decode()
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
|
||||||
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||||
reset_encryption_key_cache()
|
reset_encryption_key_cache()
|
||||||
@@ -28,7 +29,7 @@ def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def store(tmp_path: Path) -> SettingsStore:
|
def store(tmp_path: Path) -> Generator[SettingsStore, None, None]:
|
||||||
s = SettingsStore(tmp_path / "settings.sqlite")
|
s = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
s.ensure_defaults()
|
s.ensure_defaults()
|
||||||
app.dependency_overrides[get_settings_store] = lambda: s
|
app.dependency_overrides[get_settings_store] = lambda: s
|
||||||
@@ -181,3 +182,103 @@ class TestAuthentikUsersEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["items"] == []
|
assert data["items"] == []
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentikAccessMetadata:
|
||||||
|
@patch.object(AuthentikClient, "get")
|
||||||
|
def test_groups_and_applications_paginate_and_whitelist_fields(self, mock_get: MagicMock) -> None:
|
||||||
|
def payload(path: str, **params: object) -> dict[str, object]:
|
||||||
|
if path == "/core/groups/":
|
||||||
|
if params["page"] == 1:
|
||||||
|
return {"pagination": {"count": 2}, "results": [{"pk": 1, "name": "Admins"}]}
|
||||||
|
return {"pagination": {"count": 2}, "results": [{"id": "g2", "display_name": "Readers"}]}
|
||||||
|
return {
|
||||||
|
"pagination": {"count": 1},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"pk": 3,
|
||||||
|
"name": "Portal",
|
||||||
|
"slug": "portal",
|
||||||
|
"meta_launch_url": "https://portal.example.com",
|
||||||
|
"provider": {"client_secret": "must-not-leak"},
|
||||||
|
"policy_engine_mode": "any",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_get.side_effect = payload
|
||||||
|
auth = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||||
|
assert auth.groups(limit=2)["items"] == [{"id": "1", "name": "Admins"}, {"id": "g2", "name": "Readers"}]
|
||||||
|
application = auth.applications(limit=1)["items"][0]
|
||||||
|
assert application == {
|
||||||
|
"id": "3",
|
||||||
|
"name": "Portal",
|
||||||
|
"slug": "portal",
|
||||||
|
"launch_url": "https://portal.example.com",
|
||||||
|
}
|
||||||
|
assert "provider" not in application
|
||||||
|
|
||||||
|
@patch.object(AuthentikClient, "get")
|
||||||
|
def test_access_summary_uses_group_references_without_user_detail_calls(self, mock_get: MagicMock) -> None:
|
||||||
|
def payload(path: str, **params: object) -> dict[str, object]:
|
||||||
|
if path == "/core/users/":
|
||||||
|
return {
|
||||||
|
"pagination": {"count": 1},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"pk": 7,
|
||||||
|
"username": "alice",
|
||||||
|
"name": "Alice",
|
||||||
|
"groups": [1, {"id": "missing"}],
|
||||||
|
"is_superuser": True,
|
||||||
|
"is_staff": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert path == "/core/groups/"
|
||||||
|
return {"pagination": {"count": 1}, "results": [{"pk": 1, "name": "Admins"}]}
|
||||||
|
|
||||||
|
mock_get.side_effect = payload
|
||||||
|
result = AuthentikClient(base_url="https://auth.example.com", api_token="t").access_summaries()
|
||||||
|
assert result["items"][0]["groups"] == [
|
||||||
|
{"id": "1", "name": "Admins", "known": True},
|
||||||
|
{"id": "missing", "name": "Unknown group (missing)", "known": False},
|
||||||
|
]
|
||||||
|
assert bool(result["items"][0]["is_superuser"])
|
||||||
|
assert all(call.args[0] in {"/core/users/", "/core/groups/"} for call in mock_get.call_args_list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentikAccessEndpoints:
|
||||||
|
def test_not_configured_access_collections_return_empty_envelopes(self, store: SettingsStore) -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
for path in ("access-summary", "groups", "applications"):
|
||||||
|
response = client.get(f"/api/services/authentik/missing/{path}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["items"] == []
|
||||||
|
assert response.json()["error"] == "Authentik service not configured"
|
||||||
|
|
||||||
|
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
||||||
|
def test_access_summary_endpoint_returns_normalized_data(
|
||||||
|
self, mock_client_cls: MagicMock, store: SettingsStore
|
||||||
|
) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.access_summaries.return_value = {
|
||||||
|
"items": [{"id": "1", "groups": []}],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 25,
|
||||||
|
}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
service = store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "authentik",
|
||||||
|
"name": "Main",
|
||||||
|
"config": {"base_url": "https://auth.example.com"},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
secret_values={"api_token": "secret-token"},
|
||||||
|
)
|
||||||
|
response = TestClient(app).get(f"/api/services/authentik/{service['id']}/access-summary?page_size=25")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["items"] == [{"id": "1", "groups": []}]
|
||||||
|
mock_client.access_summaries.assert_called_once_with(search=None, page=1, page_size=25)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from media_library_viewer_api.main import app
|
from media_library_viewer_api.main import app
|
||||||
@@ -33,6 +34,84 @@ def test_dashboard_backups():
|
|||||||
auth_module._API_KEY = None
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-instance service scoping (PI-110, PI-111, PI-119)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
"""Fresh SettingsStore with schema initialized."""
|
||||||
|
db_path = tmp_path / "test_settings.sqlite"
|
||||||
|
s = SettingsStore(db_path)
|
||||||
|
s.init_schema()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupServiceScoping:
|
||||||
|
"""Verify service_id filtering on list_backup_jobs/runs/alerts."""
|
||||||
|
|
||||||
|
def test_list_backup_jobs_filtered_by_service(self, store: SettingsStore):
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
assert len(store.list_backup_jobs(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_jobs(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_jobs_unfiltered_returns_all(self, store: SettingsStore):
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
assert len(store.list_backup_jobs()) == 2
|
||||||
|
assert len(store.list_backup_jobs(service_id="")) == 2
|
||||||
|
|
||||||
|
def test_list_backup_runs_filtered_by_service(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
assert len(store.list_backup_runs(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_runs(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_alerts_filtered_by_service(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_alert({"job_id": job_a["id"], "alert_type": "test", "severity": "warning"})
|
||||||
|
store.create_backup_alert({"job_id": job_b["id"], "alert_type": "test", "severity": "warning"})
|
||||||
|
assert len(store.list_backup_alerts(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_alerts(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_runs_unfiltered_returns_all(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
assert len(store.list_backup_runs()) == 2
|
||||||
|
|
||||||
|
def test_endpoint_threads_service_id_to_store(self, store: SettingsStore):
|
||||||
|
"""GET /api/backups/jobs?service_id=svc-a filters via the endpoint."""
|
||||||
|
import media_library_viewer_api.auth as auth_module
|
||||||
|
from media_library_viewer_api.services import settings_store
|
||||||
|
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
|
||||||
|
original_store = settings_store._store
|
||||||
|
settings_store._store = store
|
||||||
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/backups/jobs", params={"service_id": "svc-a"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "job-a"
|
||||||
|
finally:
|
||||||
|
settings_store._store = original_store
|
||||||
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
|
||||||
def test_post_backup_report():
|
def test_post_backup_report():
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test_settings.sqlite"
|
db_path = Path(tmpdir) / "test_settings.sqlite"
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Tests for the service credential tester (CT-101..CT-113, CT-119)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.alertmanager import test_connection as am_test
|
||||||
|
from media_library_viewer_api.integrations.authentik import test_connection as ak_test
|
||||||
|
from media_library_viewer_api.integrations.base import translate_connection_error
|
||||||
|
from media_library_viewer_api.integrations.jellyfin import test_connection as jf_test
|
||||||
|
from media_library_viewer_api.integrations.nextcloud import test_connection as nc_test
|
||||||
|
from media_library_viewer_api.integrations.prometheus import test_connection as prom_test
|
||||||
|
from media_library_viewer_api.integrations.qbittorrent import test_connection as qbit_test
|
||||||
|
from media_library_viewer_api.integrations.remote_machine import test_connection as ssh_test
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# translate_connection_error (CT-119)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateConnectionError:
|
||||||
|
def test_http_401_maps_to_auth_message(self) -> None:
|
||||||
|
resp = SimpleNamespace(status_code=401)
|
||||||
|
exc = requests.HTTPError(response=resp)
|
||||||
|
result = translate_connection_error(exc)
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
def test_http_403_maps_to_auth_message(self) -> None:
|
||||||
|
resp = SimpleNamespace(status_code=403)
|
||||||
|
exc = requests.HTTPError(response=resp)
|
||||||
|
result = translate_connection_error(exc)
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
|
||||||
|
exc = requests.ConnectionError("getaddrinfo failed")
|
||||||
|
result = translate_connection_error(exc)
|
||||||
|
assert not result.ok
|
||||||
|
assert "Host not found" in result.detail
|
||||||
|
|
||||||
|
def test_timeout_maps_to_timed_out(self) -> None:
|
||||||
|
exc = requests.Timeout("timed out")
|
||||||
|
result = translate_connection_error(exc)
|
||||||
|
assert not result.ok
|
||||||
|
assert "timed out" in result.detail.lower()
|
||||||
|
|
||||||
|
def test_generic_fallback_includes_context(self) -> None:
|
||||||
|
exc = ValueError("something weird happened")
|
||||||
|
result = translate_connection_error(exc, context="qBittorrent")
|
||||||
|
assert not result.ok
|
||||||
|
assert "qBittorrent" in result.detail
|
||||||
|
assert "something weird happened" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# qbittorrent (CT-104)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbittorrentTestConnection:
|
||||||
|
def test_success_returns_version(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
|
||||||
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||||
|
result = qbit_test(
|
||||||
|
{"base_url": "http://qb:8080", "timeout_seconds": 5},
|
||||||
|
{"username": "u", "password": "p"},
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert result.evidence == "v4.6.0"
|
||||||
|
|
||||||
|
def test_login_failed_translates_to_auth_message(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
# Mirrors the real _login auth-failure message for a "Fails." body.
|
||||||
|
mock_client.maindata.side_effect = RuntimeError(
|
||||||
|
"qBittorrent login failed (HTTP 200): invalid username or password"
|
||||||
|
)
|
||||||
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||||
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
# A 504 from the reverse proxy must NOT be reported as "Authentication
|
||||||
|
# failed" — that misled users into re-entering correct credentials.
|
||||||
|
mock_client.maindata.side_effect = RuntimeError(
|
||||||
|
"qBittorrent is unreachable: reverse proxy returned HTTP 504 for http://qb:8080/api/v2/auth/login."
|
||||||
|
)
|
||||||
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||||
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" not in result.detail
|
||||||
|
assert "504" in result.detail
|
||||||
|
|
||||||
|
def test_connection_error_translates(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
|
||||||
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||||
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# prometheus (CT-105)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrometheusTestConnection:
|
||||||
|
def test_success_returns_gateway_evidence(self) -> None:
|
||||||
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
|
||||||
|
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
|
||||||
|
result = prom_test(
|
||||||
|
{"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
{"grafana_api_key": "tok"},
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert "Gateway" in (result.evidence or "")
|
||||||
|
|
||||||
|
def test_missing_url_returns_error_without_network(self) -> None:
|
||||||
|
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "URL" in result.detail
|
||||||
|
|
||||||
|
def test_missing_api_key_returns_error_without_network(self) -> None:
|
||||||
|
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "API key" in result.detail
|
||||||
|
|
||||||
|
def test_http_401_translates_to_auth(self) -> None:
|
||||||
|
exc = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
||||||
|
payload = SimpleNamespace(raise_for_status=MagicMock(side_effect=exc))
|
||||||
|
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
|
||||||
|
result = prom_test(
|
||||||
|
{"grafana_url": "http://grafana:3000"},
|
||||||
|
{"grafana_api_key": "wrong"},
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# alertmanager (CT-106)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlertmanagerTestConnection:
|
||||||
|
def test_success_returns_version(self) -> None:
|
||||||
|
payload = SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
json=lambda: {"versionInfo": {"version": "0.27.0"}},
|
||||||
|
)
|
||||||
|
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
|
||||||
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||||
|
assert result.ok
|
||||||
|
assert result.evidence == "0.27.0"
|
||||||
|
|
||||||
|
def test_connection_refused_translates(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"media_library_viewer_api.integrations.alertmanager.requests.get",
|
||||||
|
side_effect=requests.ConnectionError("refused"),
|
||||||
|
):
|
||||||
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# jellyfin (CT-107)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestJellyfinTestConnection:
|
||||||
|
def test_success_returns_user_count(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
|
||||||
|
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
||||||
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "k"}, MagicMock())
|
||||||
|
assert result.ok
|
||||||
|
assert "2 users" == result.evidence
|
||||||
|
|
||||||
|
def test_http_401_translates_to_auth(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.users.side_effect = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
||||||
|
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
||||||
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "wrong"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# authentik (CT-108)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentikTestConnection:
|
||||||
|
def test_success_returns_user_count(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.users.return_value = {"total": 5, "items": []}
|
||||||
|
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
||||||
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||||
|
assert result.ok
|
||||||
|
assert "5 users" == result.evidence
|
||||||
|
|
||||||
|
def test_connection_error_translates(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.users.side_effect = requests.ConnectionError("refused")
|
||||||
|
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
||||||
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ssh_tasks (CT-109)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSshTasksTestConnection:
|
||||||
|
def test_success_returns_connected_evidence(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||||
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {"passphrase": ""}, MagicMock())
|
||||||
|
assert result.ok
|
||||||
|
assert "Connected to srv:22" == result.evidence
|
||||||
|
|
||||||
|
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.side_effect = Exception("SSH authentication failed")
|
||||||
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||||
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "SSH authentication failed" in result.detail
|
||||||
|
|
||||||
|
def test_protocol_banner_translates(self) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.side_effect = Exception("protocol banner error")
|
||||||
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||||
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "SSH banner" in result.detail
|
||||||
|
|
||||||
|
def test_missing_host_returns_value_error(self) -> None:
|
||||||
|
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nextcloud (CT-110)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextcloudTestConnection:
|
||||||
|
def test_success_returns_version(self) -> None:
|
||||||
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"version": "29.0.0"})
|
||||||
|
with patch("media_library_viewer_api.integrations.nextcloud.requests.get", return_value=payload):
|
||||||
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||||
|
assert result.ok
|
||||||
|
assert result.evidence == "29.0.0"
|
||||||
|
|
||||||
|
def test_connection_error_translates(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"media_library_viewer_api.integrations.nextcloud.requests.get",
|
||||||
|
side_effect=requests.ConnectionError("refused"),
|
||||||
|
):
|
||||||
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||||
|
assert not result.ok
|
||||||
|
assert "Connection refused" in result.detail
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tests for the shared HTTP timeout helper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.http_timeout import (
|
||||||
|
DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
http_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpTimeout:
|
||||||
|
def test_http_timeout_returns_tuple(self) -> None:
|
||||||
|
result = http_timeout(30)
|
||||||
|
assert result == (DEFAULT_CONNECT_TIMEOUT, 30.0)
|
||||||
|
|
||||||
|
def test_http_timeout_default_when_none(self) -> None:
|
||||||
|
result = http_timeout(None)
|
||||||
|
assert result == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT)
|
||||||
|
|
||||||
|
def test_http_timeout_default_when_zero(self) -> None:
|
||||||
|
result = http_timeout(0)
|
||||||
|
assert result == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT)
|
||||||
|
|
||||||
|
def test_http_timeout_custom_connect(self) -> None:
|
||||||
|
result = http_timeout(30, connect_timeout=10)
|
||||||
|
assert result == (10.0, 30.0)
|
||||||
|
|
||||||
|
def test_http_timeout_default_when_negative(self) -> None:
|
||||||
|
result = http_timeout(-5)
|
||||||
|
assert result == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT)
|
||||||
|
|
||||||
|
def test_http_timeout_default_when_garbage_string(self) -> None:
|
||||||
|
result = http_timeout("garbage") # type: ignore[arg-type]
|
||||||
|
assert result == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT)
|
||||||
|
|
||||||
|
def test_http_timeout_accepts_int(self) -> None:
|
||||||
|
result = http_timeout(45)
|
||||||
|
assert result == (5.0, 45.0)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Unit tests for JellyfinClient user-id resolution.
|
||||||
|
|
||||||
|
Jellyfin's ``/Users/{id}/...`` endpoints require the internal user Id (a hash),
|
||||||
|
not the username. The service ``user_id`` config field accepts either form, so
|
||||||
|
``resolve_user_id`` must turn a username like ``'admin'`` into the real Id before
|
||||||
|
any user-scoped call. See clients/jellyfin.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveUserIdTests(unittest.TestCase):
|
||||||
|
def _client(self) -> JellyfinClient:
|
||||||
|
return JellyfinClient("https://jf.example.com", "api-key")
|
||||||
|
|
||||||
|
def test_returns_identifier_when_it_matches_an_internal_id(self) -> None:
|
||||||
|
users = [{"Id": "a1b2", "Name": "admin"}, {"Id": "c3d4", "Name": "friend"}]
|
||||||
|
with patch.object(JellyfinClient, "users", return_value=users):
|
||||||
|
self.assertEqual(self._client().resolve_user_id("c3d4"), "c3d4")
|
||||||
|
|
||||||
|
def test_resolves_username_to_internal_id(self) -> None:
|
||||||
|
"""Regression: a configured username 'admin' must resolve to the internal Id.
|
||||||
|
|
||||||
|
Hitting /Users/admin/Views directly returns HTTP 400
|
||||||
|
('The value 'admin' is not valid.'), so the username form must be resolved.
|
||||||
|
"""
|
||||||
|
users = [{"Id": "a1b2c3internal", "Name": "admin"}, {"Id": "zzz", "Name": "other"}]
|
||||||
|
with patch.object(JellyfinClient, "users", return_value=users):
|
||||||
|
self.assertEqual(self._client().resolve_user_id("admin"), "a1b2c3internal")
|
||||||
|
|
||||||
|
def test_falls_back_to_first_user_when_identifier_unknown(self) -> None:
|
||||||
|
users = [{"Id": "first", "Name": "admin"}, {"Id": "second", "Name": "x"}]
|
||||||
|
with patch.object(JellyfinClient, "users", return_value=users):
|
||||||
|
self.assertEqual(self._client().resolve_user_id("nobody"), "first")
|
||||||
|
|
||||||
|
def test_falls_back_to_first_user_when_identifier_none(self) -> None:
|
||||||
|
users = [{"Id": "first", "Name": "admin"}]
|
||||||
|
with patch.object(JellyfinClient, "users", return_value=users):
|
||||||
|
self.assertEqual(self._client().resolve_user_id(None), "first")
|
||||||
|
|
||||||
|
def test_raises_when_no_users_visible(self) -> None:
|
||||||
|
with patch.object(JellyfinClient, "users", return_value=[]):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self._client().resolve_user_id("admin")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Tests for the Jellyseerr stats provider + generic stat widget source."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from media_library_viewer_api.widgets.jellyseerr_stats import JellyseerrStatsProvider
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, StatsWidgetSource
|
||||||
|
from media_library_viewer_api.widgets.stats_provider import (
|
||||||
|
StatValue,
|
||||||
|
get_stats_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(
|
||||||
|
*,
|
||||||
|
jellyseerr_url: str = "https://js.example.com",
|
||||||
|
jellyseerr_api_key: str = "key",
|
||||||
|
) -> ServiceRecord:
|
||||||
|
return ServiceRecord(
|
||||||
|
id="svc-jf",
|
||||||
|
service_type="jellyfin",
|
||||||
|
name="Jellyfin",
|
||||||
|
config={"jellyseerr_url": jellyseerr_url, "jellyseerr_api_key": jellyseerr_api_key},
|
||||||
|
secrets={},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_COUNTS = {"total": 5, "pending": 2, "approved": 1, "declined": 0, "processing": 1, "available": 1}
|
||||||
|
_RECENT = [{"id": 1, "name": "Inception", "status": "pending", "media_status": "available"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_registered_for_jellyfin():
|
||||||
|
assert isinstance(get_stats_provider("jellyfin"), JellyseerrStatsProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_returns_normalized_stats_and_recent():
|
||||||
|
provider = JellyseerrStatsProvider(ttl=0)
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.request_count.return_value = _COUNTS
|
||||||
|
fake.recent_requests.return_value = _RECENT
|
||||||
|
with patch("media_library_viewer_api.widgets.jellyseerr_stats._jellyseer_client", return_value=fake):
|
||||||
|
result = provider.fetch_stats(_service())
|
||||||
|
assert [s.key for s in result.stats] == [
|
||||||
|
"total",
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"declined",
|
||||||
|
"processing",
|
||||||
|
"available",
|
||||||
|
]
|
||||||
|
by_key = {s.key: s.value for s in result.stats}
|
||||||
|
assert by_key["pending"] == 2 and by_key["total"] == 5
|
||||||
|
assert result.recent == _RECENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_not_configured_returns_detail():
|
||||||
|
provider = JellyseerrStatsProvider()
|
||||||
|
result = provider.fetch_stats(_service(jellyseerr_url="", jellyseerr_api_key=""))
|
||||||
|
assert result.stats == []
|
||||||
|
assert "not configured" in (result.detail or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_caches_within_ttl():
|
||||||
|
provider = JellyseerrStatsProvider(ttl=10)
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.request_count.return_value = _COUNTS
|
||||||
|
fake.recent_requests.return_value = _RECENT
|
||||||
|
with patch("media_library_viewer_api.widgets.jellyseerr_stats._jellyseer_client", return_value=fake):
|
||||||
|
provider.fetch_stats(_service())
|
||||||
|
provider.fetch_stats(_service()) # served from cache
|
||||||
|
assert fake.request_count.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stat_widget_returns_selected_value():
|
||||||
|
src = StatsWidgetSource()
|
||||||
|
result = MagicMock()
|
||||||
|
result.detail = None
|
||||||
|
result.stats = [StatValue("total", "Total", 5), StatValue("pending", "Pending", 2)]
|
||||||
|
result.recent = []
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.fetch_stats.return_value = result
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider):
|
||||||
|
data = asyncio.run(src.fetch(_service(), "stat", {"stat": "pending"}))
|
||||||
|
assert data == {"key": "pending", "label": "Pending", "value": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_overview_widget_returns_all_stats_and_recent():
|
||||||
|
src = StatsWidgetSource()
|
||||||
|
result = MagicMock()
|
||||||
|
result.detail = None
|
||||||
|
result.stats = [StatValue("total", "Total", 5), StatValue("pending", "Pending", 2)]
|
||||||
|
result.recent = _RECENT
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.fetch_stats.return_value = result
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider):
|
||||||
|
data = asyncio.run(src.fetch(_service(), "stats_overview", {}))
|
||||||
|
assert data["stats"] == [
|
||||||
|
{"key": "total", "label": "Total", "value": 5},
|
||||||
|
{"key": "pending", "label": "Pending", "value": 2},
|
||||||
|
]
|
||||||
|
assert data["recent"] == _RECENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_stat_widget_unknown_stat_returns_error():
|
||||||
|
src = StatsWidgetSource()
|
||||||
|
result = MagicMock()
|
||||||
|
result.detail = None
|
||||||
|
result.stats = [StatValue("total", "Total", 5)]
|
||||||
|
result.recent = []
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.fetch_stats.return_value = result
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider):
|
||||||
|
data = asyncio.run(src.fetch(_service(), "stat", {"stat": "nope"}))
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_jellyseer_client_open_requests_resolves_titles():
|
||||||
|
"""open_requests() fetches pending+approved via filter and resolves titles."""
|
||||||
|
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||||
|
|
||||||
|
c = JellyseerrClient("https://js.example.com", "key")
|
||||||
|
c.session = MagicMock()
|
||||||
|
|
||||||
|
def mock_get(url, **kw):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.text = ""
|
||||||
|
params = kw.get("params", {})
|
||||||
|
if "/movie/" in url or "/tv/" in url:
|
||||||
|
resp.json.return_value = {"title": "Inception"} # title resolution
|
||||||
|
elif params.get("filter") == "pending":
|
||||||
|
resp.json.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": 1,
|
||||||
|
"status": 1,
|
||||||
|
"media": {"tmdbId": 123, "status": 5},
|
||||||
|
"createdAt": 1_700_000_000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
resp.json.return_value = {"results": []}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
c.session.get.side_effect = mock_get
|
||||||
|
|
||||||
|
out = c.open_requests()
|
||||||
|
|
||||||
|
assert len(out) == 1
|
||||||
|
r = out[0]
|
||||||
|
assert r["id"] == 7
|
||||||
|
assert r["type"] == "movie"
|
||||||
|
assert r["name"] == "Inception" # resolved via /movie/123
|
||||||
|
assert r["status"] == "pending"
|
||||||
|
assert r["media_status"] == "available"
|
||||||
|
assert r["created_at"] == 1_700_000_000
|
||||||
|
|
||||||
|
# Title is cached: a second call doesn't re-fetch /movie/123.
|
||||||
|
movie_calls_before = sum(1 for call in c.session.get.call_args_list if "/movie/" in call.args[0])
|
||||||
|
assert movie_calls_before == 1
|
||||||
|
c.open_requests() # second poll
|
||||||
|
movie_calls_after = sum(1 for call in c.session.get.call_args_list if "/movie/" in call.args[0])
|
||||||
|
assert movie_calls_after == 1 # cached, no new /movie call
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_jellyseer_requests_not_configured_returns_empty():
|
||||||
|
"""No jellyseerr config -> empty list (tab shows 'No requests')."""
|
||||||
|
from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests
|
||||||
|
|
||||||
|
service = ServiceRecord(id="s", service_type="jellyfin", name="JF", config={}, secrets={})
|
||||||
|
assert fetch_jellyseer_requests(service) == []
|
||||||
@@ -6,22 +6,41 @@ import pytest
|
|||||||
|
|
||||||
from media_library_viewer_api.widgets.prometheus_range import (
|
from media_library_viewer_api.widgets.prometheus_range import (
|
||||||
WINDOW_PRESETS,
|
WINDOW_PRESETS,
|
||||||
|
_dedup_label,
|
||||||
|
normalize_grafana_frames,
|
||||||
normalize_prometheus_matrix,
|
normalize_prometheus_matrix,
|
||||||
step_for_window,
|
step_for_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestStepForWindow:
|
class TestStepForWindow:
|
||||||
"""SC-104: every preset must yield 100–300 points."""
|
"""SC-104: presets preserve usable resolution without sub-15s steps."""
|
||||||
|
|
||||||
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
||||||
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
|
def test_presets_yield_supported_point_counts(self, preset: str) -> None:
|
||||||
window = WINDOW_PRESETS[preset]
|
window = WINDOW_PRESETS[preset]
|
||||||
step = step_for_window(window)
|
step = step_for_window(window)
|
||||||
# Clamped minimum.
|
# The Prometheus-safe 15-second floor limits the two short presets to
|
||||||
|
# 20 and 60 points; all longer windows stay in the 100–300 target band.
|
||||||
assert step >= 15
|
assert step >= 15
|
||||||
point_count = window // step
|
point_count = window // step
|
||||||
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
assert min(100, window // 15) <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
||||||
|
|
||||||
|
def test_window_presets_cover_the_shared_chart_windows(self) -> None:
|
||||||
|
assert WINDOW_PRESETS == {
|
||||||
|
"5m": 300,
|
||||||
|
"15m": 900,
|
||||||
|
"30m": 1_800,
|
||||||
|
"1h": 3_600,
|
||||||
|
"3h": 10_800,
|
||||||
|
"6h": 21_600,
|
||||||
|
"12h": 43_200,
|
||||||
|
"24h": 86_400,
|
||||||
|
"2d": 172_800,
|
||||||
|
"7d": 604_800,
|
||||||
|
"14d": 1_209_600,
|
||||||
|
"30d": 2_592_000,
|
||||||
|
}
|
||||||
|
|
||||||
def test_floor_of_fifteen_seconds(self) -> None:
|
def test_floor_of_fifteen_seconds(self) -> None:
|
||||||
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
||||||
@@ -104,3 +123,152 @@ class TestNormalizePrometheusMatrix:
|
|||||||
{"t": 1, "v": 3.5},
|
{"t": 1, "v": 3.5},
|
||||||
{"t": 3, "v": None},
|
{"t": 3, "v": None},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDedupLabel:
|
||||||
|
"""The shared label-dedup helper used by both normalizers (GM-104)."""
|
||||||
|
|
||||||
|
def test_first_use_returns_label_unchanged(self) -> None:
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
assert _dedup_label("value", seen) == "value"
|
||||||
|
assert seen == {"value": 0}
|
||||||
|
|
||||||
|
def test_collision_appends_suffix(self) -> None:
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
assert _dedup_label("job=x", seen) == "job=x"
|
||||||
|
assert _dedup_label("job=x", seen) == "job=x (1)"
|
||||||
|
assert _dedup_label("job=x", seen) == "job=x (2)"
|
||||||
|
|
||||||
|
def test_different_labels_dont_collide(self) -> None:
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
assert _dedup_label("a", seen) == "a"
|
||||||
|
assert _dedup_label("b", seen) == "b"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeGrafanaFrames:
|
||||||
|
"""GM-104: frames normalizer recovered from 65bae95 + shared dedup."""
|
||||||
|
|
||||||
|
def test_empty_response(self) -> None:
|
||||||
|
assert normalize_grafana_frames({"results": {}}) == []
|
||||||
|
assert normalize_grafana_frames({}) == []
|
||||||
|
|
||||||
|
def test_single_frame_with_values(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[1000, 2000], [1.5, 2.5]]},
|
||||||
|
"schema": {"fields": [{"name": "Time"}, {"name": "Value"}]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["label"] == "Value"
|
||||||
|
assert out[0]["points"] == [
|
||||||
|
{"t": 1000, "v": 1.5},
|
||||||
|
{"t": 2000, "v": 2.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_display_name_takes_priority(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[100, 200], [0.75, 0.80]]},
|
||||||
|
"schema": {
|
||||||
|
"fields": [
|
||||||
|
{"name": "Time"},
|
||||||
|
{
|
||||||
|
"name": "Value",
|
||||||
|
"labels": {"instance": "host:9100"},
|
||||||
|
"config": {"displayName": "CPU Usage"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
assert out[0]["label"] == "CPU Usage"
|
||||||
|
|
||||||
|
def test_labels_fallback_when_no_display_name(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[100], [1.0]]},
|
||||||
|
"schema": {
|
||||||
|
"fields": [
|
||||||
|
{"name": "Time"},
|
||||||
|
{
|
||||||
|
"name": "Value",
|
||||||
|
"labels": {"__name__": "up", "instance": "h:9100"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
assert out[0]["label"] == "instance=h:9100"
|
||||||
|
|
||||||
|
def test_falls_back_to_value_when_no_metadata(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[100], [1.0]]},
|
||||||
|
"schema": {"fields": [{"name": "Time"}, {}]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
assert out[0]["label"] == "value"
|
||||||
|
|
||||||
|
def test_dedup_collisions(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[100], [1.0]]},
|
||||||
|
"schema": {"fields": [{}, {"name": "Value"}]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {"values": [[100], [2.0]]},
|
||||||
|
"schema": {"fields": [{}, {"name": "Value"}]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
labels = [s["label"] for s in out]
|
||||||
|
assert labels == ["Value", "Value (1)"]
|
||||||
|
|
||||||
|
def test_skips_frames_with_insufficient_values(self) -> None:
|
||||||
|
raw = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{"data": {"values": [[100]]}, "schema": {"fields": []}},
|
||||||
|
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {}]}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = normalize_grafana_frames(raw)
|
||||||
|
assert len(out) == 1
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
resp.text = text
|
resp.text = text
|
||||||
resp.raise_for_status.return_value = None
|
resp.raise_for_status.return_value = None
|
||||||
resp.status_code = 200
|
resp.status_code = 200
|
||||||
|
resp.headers = {}
|
||||||
|
resp.cookies = {}
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
|
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
|
||||||
@@ -115,6 +117,102 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
|
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
|
||||||
self.assertEqual(len(result["torrents"]), 2)
|
self.assertEqual(len(result["torrents"]), 2)
|
||||||
|
|
||||||
|
def test_maindata_uses_rid_and_merges_partial_update(self) -> None:
|
||||||
|
"""First call is a full fetch (no rid); later calls send rid and merge the diff."""
|
||||||
|
self.client._logged_in = True
|
||||||
|
self.client._maindata_ttl = 0 # force a real fetch each call
|
||||||
|
full = {
|
||||||
|
"rid": 10,
|
||||||
|
"full_update": True,
|
||||||
|
"server_state": {"dl_info_speed": 100},
|
||||||
|
"torrents": {
|
||||||
|
"a": {
|
||||||
|
"name": "A",
|
||||||
|
"state": "downloading",
|
||||||
|
"size": 1_024,
|
||||||
|
"progress": 0.5,
|
||||||
|
"dlspeed": 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
partial = {
|
||||||
|
"rid": 11,
|
||||||
|
"full_update": False,
|
||||||
|
"server_state": {"dl_info_speed": 200},
|
||||||
|
"torrents": {"a": {"dlspeed": 200}},
|
||||||
|
}
|
||||||
|
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
||||||
|
|
||||||
|
r1 = self.client.maindata()
|
||||||
|
self.assertNotIn("rid", self.session.get.call_args_list[0].kwargs["params"])
|
||||||
|
self.assertEqual(r1["server_state"]["dl_info_speed"], 100)
|
||||||
|
self.assertEqual(r1["torrents"]["a"]["state"], "downloading")
|
||||||
|
|
||||||
|
r2 = self.client.maindata()
|
||||||
|
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
||||||
|
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["dlspeed"], 200)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["name"], "A")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["state"], "downloading")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["size"], 1_024)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["progress"], 0.5)
|
||||||
|
|
||||||
|
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
||||||
|
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
||||||
|
self.client._logged_in = True
|
||||||
|
payload = {
|
||||||
|
"rid": 1,
|
||||||
|
"full_update": True,
|
||||||
|
"server_state": {"dl_info_speed": 5},
|
||||||
|
"torrents": {"a": {"state": "downloading"}},
|
||||||
|
}
|
||||||
|
self.session.get.return_value = self._get_response(payload)
|
||||||
|
|
||||||
|
self.client.maindata()
|
||||||
|
r2 = self.client.maindata() # served from cache — no extra HTTP
|
||||||
|
|
||||||
|
self.assertEqual(self.session.get.call_count, 1)
|
||||||
|
self.assertEqual(r2["server_state"]["dl_info_speed"], 5)
|
||||||
|
|
||||||
|
def test_maindata_backoff_after_failure_does_not_pile_on(self) -> None:
|
||||||
|
"""A failed fetch arms backoff; the next call skips the network entirely."""
|
||||||
|
self.client._logged_in = True
|
||||||
|
self.client._maindata_ttl = 0
|
||||||
|
bad = MagicMock()
|
||||||
|
bad.status_code = 503
|
||||||
|
bad.raise_for_status.side_effect = requests.HTTPError("503 Server Error")
|
||||||
|
bad.text = ""
|
||||||
|
self.session.get.return_value = bad
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError): # no snapshot yet -> raises + arms backoff
|
||||||
|
self.client.maindata()
|
||||||
|
self.assertEqual(self.session.get.call_count, 1)
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError): # within backoff -> no new HTTP
|
||||||
|
self.client.maindata()
|
||||||
|
self.assertEqual(self.session.get.call_count, 1)
|
||||||
|
|
||||||
|
def test_maindata_serves_stale_snapshot_during_backoff(self) -> None:
|
||||||
|
"""After a good fetch, a later failure serves stale data instead of erroring."""
|
||||||
|
self.client._logged_in = True
|
||||||
|
self.client._maindata_ttl = 0
|
||||||
|
good = {
|
||||||
|
"rid": 1,
|
||||||
|
"full_update": True,
|
||||||
|
"server_state": {"dl_info_speed": 7},
|
||||||
|
"torrents": {"a": {"state": "downloading"}},
|
||||||
|
}
|
||||||
|
bad = MagicMock()
|
||||||
|
bad.status_code = 503
|
||||||
|
bad.raise_for_status.side_effect = requests.HTTPError("503")
|
||||||
|
bad.text = ""
|
||||||
|
self.session.get.side_effect = [self._get_response(good), bad]
|
||||||
|
|
||||||
|
r1 = self.client.maindata()
|
||||||
|
self.assertEqual(r1["server_state"]["dl_info_speed"], 7)
|
||||||
|
r2 = self.client.maindata() # fetch fails -> serves stale snapshot, no raise
|
||||||
|
self.assertEqual(r2["server_state"]["dl_info_speed"], 7)
|
||||||
|
|
||||||
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
|
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
|
||||||
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
|
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
|
||||||
"""A network error during login propagates as requests exception."""
|
"""A network error during login propagates as requests exception."""
|
||||||
@@ -129,6 +227,95 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
with self.assertRaises(requests.ConnectionError):
|
with self.assertRaises(requests.ConnectionError):
|
||||||
client._login()
|
client._login()
|
||||||
|
|
||||||
|
def test_qbittorrent_client_uses_tuple_timeout(self) -> None:
|
||||||
|
"""The client passes a (connect, read) tuple to requests, not an int."""
|
||||||
|
# self.client was constructed with timeout=5 in setUp() and has a mocked session.
|
||||||
|
assert isinstance(self.client.timeout, tuple)
|
||||||
|
assert len(self.client.timeout) == 2
|
||||||
|
assert self.client.timeout[0] == 5.0 # connect timeout
|
||||||
|
assert self.client.timeout[1] == 5.0 # read timeout (what we passed)
|
||||||
|
|
||||||
|
# Verify it's actually passed to requests as-is.
|
||||||
|
self.session.post.return_value = self._login_response()
|
||||||
|
self.client._login()
|
||||||
|
call_kwargs = self.session.post.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs["timeout"], (5.0, 5.0))
|
||||||
|
self.assertNotIsInstance(call_kwargs["timeout"], int)
|
||||||
|
|
||||||
|
def test_login_fails_message_names_bad_credentials(self) -> None:
|
||||||
|
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
||||||
|
self.session.post.return_value = self._login_response("Fails.")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.client._login()
|
||||||
|
self.assertIn("invalid username or password", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_login_accepts_sid_cookie_when_body_mangled(self) -> None:
|
||||||
|
"""A reverse proxy that strips the body but forwards SID still logs in."""
|
||||||
|
resp = self._login_response("") # empty body — the reported symptom
|
||||||
|
resp.cookies = {"SID": "abc123"}
|
||||||
|
self.session.post.return_value = resp
|
||||||
|
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
self.assertTrue(self.client._logged_in)
|
||||||
|
|
||||||
|
def test_login_accepts_sid_cookie_in_header_on_204(self) -> None:
|
||||||
|
"""204 + Set-Cookie SID with no body (no jar entry) is a valid login.
|
||||||
|
|
||||||
|
Reproduces the reported case: qBittorrent (or its reverse proxy) returns
|
||||||
|
204 No Content with an SID cookie, and requests doesn't always populate
|
||||||
|
the cookie jar from such a header, so the cookie must be detected from
|
||||||
|
the raw Set-Cookie header.
|
||||||
|
"""
|
||||||
|
resp = self._login_response("")
|
||||||
|
resp.status_code = 204
|
||||||
|
resp.cookies = {} # NOT in the parsed jar
|
||||||
|
resp.headers = {"Set-Cookie": "SID=abc123; HttpOnly; path=/"}
|
||||||
|
self.session.post.return_value = resp
|
||||||
|
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
self.assertTrue(self.client._logged_in)
|
||||||
|
|
||||||
|
def test_login_accepts_qbt_sid_cookie_newer_versions(self) -> None:
|
||||||
|
"""Newer qBittorrent names the session cookie QBT_SID_<port>; recognize it."""
|
||||||
|
resp = self._login_response("")
|
||||||
|
resp.status_code = 204
|
||||||
|
resp.cookies = {"QBT_SID_5080": "abc123"}
|
||||||
|
self.session.post.return_value = resp
|
||||||
|
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
self.assertTrue(self.client._logged_in)
|
||||||
|
|
||||||
|
def test_login_empty_body_without_cookie_is_diagnostic(self) -> None:
|
||||||
|
"""Empty 200 body with no SID surfaces a URL/proxy diagnostic hint."""
|
||||||
|
resp = self._login_response("")
|
||||||
|
resp.cookies = {} # no SID cookie forwarded
|
||||||
|
self.session.post.return_value = resp
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
message = str(ctx.exception)
|
||||||
|
self.assertIn("HTTP 200", message)
|
||||||
|
self.assertIn("base_url", message)
|
||||||
|
|
||||||
|
def test_login_gateway_error_is_diagnostic(self) -> None:
|
||||||
|
"""A 502/503/504 from the reverse proxy surfaces a clear gateway message."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = 504
|
||||||
|
resp.text = ""
|
||||||
|
resp.cookies = {}
|
||||||
|
self.session.post.return_value = resp
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
message = str(ctx.exception)
|
||||||
|
self.assertIn("HTTP 504", message)
|
||||||
|
self.assertIn("reverse proxy", message.lower())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets, reset_encryption_key_cache
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_legacy_ssh_machine_and_ssh_task_service(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
db_path = tmp_path / "settings.sqlite"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE monitoring_machines (
|
||||||
|
id TEXT PRIMARY KEY, name TEXT, mode TEXT, enabled INTEGER,
|
||||||
|
config_json TEXT, created_at INTEGER, updated_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE services (
|
||||||
|
id TEXT PRIMARY KEY, service_type TEXT, name TEXT, config_json TEXT,
|
||||||
|
secrets_json TEXT, enabled INTEGER, created_at INTEGER, updated_at INTEGER
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_machines VALUES (?, ?, 'ssh', 1, ?, 10, 11)",
|
||||||
|
(
|
||||||
|
"remote-1",
|
||||||
|
"Storage",
|
||||||
|
'{"host":"storage","port":2222,"username":"ops","ssh_private_key":"PRIVATE","ssh_private_key_passphrase":"phrase","password":"pw"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO monitoring_machines VALUES (?, ?, 'local', 1, '{}', 10, 11)", ("local", "This machine"))
|
||||||
|
conn.execute("INSERT INTO services VALUES ('task-service', 'ssh_tasks', 'Tasks', '{}', '{}', 1, 1, 1)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
store = SettingsStore(db_path)
|
||||||
|
store.init_schema()
|
||||||
|
remote = store.get_service("remote-1")
|
||||||
|
assert remote and remote["service_type"] == "remote_machine"
|
||||||
|
assert remote["config"] == {
|
||||||
|
"host": "storage",
|
||||||
|
"port": 2222,
|
||||||
|
"username": "ops",
|
||||||
|
"ssh_key_id": "legacy-key-remote-1",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
assert decrypt_secrets(remote["secrets"]) == {"passphrase": "phrase", "password": "pw"}
|
||||||
|
assert store.get_ssh_key("legacy-key-remote-1")["private_key"] == "PRIVATE"
|
||||||
|
assert store.get_service("task-service")["service_type"] == "remote_machine"
|
||||||
|
with store.connect() as check:
|
||||||
|
assert (
|
||||||
|
check.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_machines'").fetchone()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
store.init_schema()
|
||||||
|
assert store.get_service("remote-1")["id"] == "remote-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_task_default_service_id_to_service_id(tmp_path):
|
||||||
|
db_path = tmp_path / "settings.sqlite"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE saved_tasks (
|
||||||
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, task_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, enabled INTEGER NOT NULL, default_service_id TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO saved_tasks VALUES ('task-1', 'Check', 'shell', 'true', 1, 'remote-1', '', 1, 1)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
store = SettingsStore(db_path)
|
||||||
|
store.init_schema()
|
||||||
|
assert store.get_task("task-1")["service_id"] == "remote-1"
|
||||||
|
with store.connect() as check:
|
||||||
|
columns = {row[1] for row in check.execute("PRAGMA table_info(saved_tasks)")}
|
||||||
|
assert "service_id" in columns
|
||||||
|
assert "default_service_id" not in columns
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Unit tests for typed scheduled actions and scheduler persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.main import app
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import (
|
||||||
|
QBITTORRENT_CONCERN,
|
||||||
|
QbittorrentSampleStore,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.scheduler import ( # type: ignore[reportMissingImports]
|
||||||
|
Scheduler,
|
||||||
|
SchedulerBusyError,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.scheduler_actions import ( # type: ignore[reportMissingImports]
|
||||||
|
QBITTORRENT_SPEED_ACTION,
|
||||||
|
ActionResult,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.scheduler_store import ( # type: ignore[reportMissingImports]
|
||||||
|
SCHEDULER_CONCERN,
|
||||||
|
SchedulerRunStore,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||||
|
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scheduler_client(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
app.dependency_overrides[get_settings_store] = lambda: store
|
||||||
|
with patch("media_library_viewer_api.auth.get_settings", return_value=SimpleNamespace(auth_enabled=False)):
|
||||||
|
yield TestClient(app), store
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _service(service_id: str = "svc-1") -> ServiceRecord:
|
||||||
|
return ServiceRecord(
|
||||||
|
id=service_id,
|
||||||
|
service_type="qbittorrent",
|
||||||
|
name="qbit",
|
||||||
|
config={
|
||||||
|
"poll_interval_seconds": 15,
|
||||||
|
"sample_retention_seconds": 1_800,
|
||||||
|
"sample_max_rows": 1200,
|
||||||
|
},
|
||||||
|
secrets={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_routes_expose_status_history_and_disabled_manual_run(scheduler_client):
|
||||||
|
client, store = scheduler_client
|
||||||
|
service = store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"name": "qbit",
|
||||||
|
"config": {"base_url": "http://qbit:8080", "polling_enabled": False},
|
||||||
|
"secrets": {},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
status = client.get(f"/api/scheduler/services/{service['id']}/status")
|
||||||
|
assert status.status_code == 200
|
||||||
|
assert not status.json()["enabled"]
|
||||||
|
|
||||||
|
runs = client.get(f"/api/scheduler/services/{service['id']}/runs")
|
||||||
|
assert runs.status_code == 200
|
||||||
|
assert runs.json()["items"] == []
|
||||||
|
|
||||||
|
manual = client.post(f"/api/scheduler/services/{service['id']}/run")
|
||||||
|
assert manual.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_samples_all_values_reads_all_retained_samples(scheduler_client):
|
||||||
|
client, store = scheduler_client
|
||||||
|
service = store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"name": "qbit",
|
||||||
|
"config": {"base_url": "http://qbit:8080"},
|
||||||
|
"secrets": {},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
retained = [{"ts": 10, "dl_speed": 20, "up_speed": 30}]
|
||||||
|
with patch("media_library_viewer_api.routers.scheduler.QbittorrentSampleStore") as store_cls:
|
||||||
|
store_cls.return_value.window.return_value = retained
|
||||||
|
response = client.get(f"/api/scheduler/services/{service['id']}/samples?all_values=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"service_id": service["id"],
|
||||||
|
"window_seconds": None,
|
||||||
|
"all_values": True,
|
||||||
|
"samples": retained,
|
||||||
|
}
|
||||||
|
store_cls.return_value.window.assert_called_once_with(service["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_store_applies_time_and_row_limits(tmp_path):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(QBITTORRENT_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
store = QbittorrentSampleStore(harness)
|
||||||
|
|
||||||
|
now = round(time.time())
|
||||||
|
for index in range(70):
|
||||||
|
store.append(
|
||||||
|
"svc-1",
|
||||||
|
now - 20 + index,
|
||||||
|
index,
|
||||||
|
index,
|
||||||
|
retention_seconds=60,
|
||||||
|
max_rows=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
samples = store.window("svc-1")
|
||||||
|
assert len(samples) == 60
|
||||||
|
assert samples[0]["ts"] == now - 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_records_success_and_manual_run_resets_backoff(tmp_path, monkeypatch):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(SCHEDULER_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
scheduler = Scheduler()
|
||||||
|
scheduler._run_store = SchedulerRunStore(harness)
|
||||||
|
service = _service()
|
||||||
|
|
||||||
|
class Action:
|
||||||
|
def run(self, value):
|
||||||
|
assert value.id == "svc-1"
|
||||||
|
return ActionResult(data={"ok": True})
|
||||||
|
|
||||||
|
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=Action()):
|
||||||
|
result = scheduler._execute(service, "manual")
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
fake_store = cast(
|
||||||
|
SettingsStore,
|
||||||
|
SimpleNamespace(get_service=lambda service_id: {"id": service_id, "service_type": "qbittorrent", "config": {}}),
|
||||||
|
)
|
||||||
|
status = scheduler.status("svc-1", store=fake_store)
|
||||||
|
assert status["consecutive_failures"] == 0
|
||||||
|
assert status["backoff_until"] is None
|
||||||
|
runs, total = scheduler._run_store.list_runs("svc-1", QBITTORRENT_SPEED_ACTION)
|
||||||
|
assert total == 1
|
||||||
|
assert runs[0]["trigger"] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_rejects_overlapping_manual_runs(tmp_path):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(SCHEDULER_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
scheduler = Scheduler()
|
||||||
|
scheduler._run_store = SchedulerRunStore(harness)
|
||||||
|
service = _service()
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
class SlowAction:
|
||||||
|
def run(self, value):
|
||||||
|
started.set()
|
||||||
|
release.wait(2)
|
||||||
|
return ActionResult(data={})
|
||||||
|
|
||||||
|
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=SlowAction()):
|
||||||
|
worker = threading.Thread(target=scheduler._execute, args=(service, "manual"))
|
||||||
|
worker.start()
|
||||||
|
assert started.wait(1)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
scheduler._execute(service, "manual")
|
||||||
|
except SchedulerBusyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected overlapping run to be rejected")
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
worker.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_status_marks_never_run_service_stale():
|
||||||
|
scheduler = Scheduler()
|
||||||
|
store = cast(
|
||||||
|
SettingsStore,
|
||||||
|
SimpleNamespace(
|
||||||
|
get_service=lambda service_id: {
|
||||||
|
"id": service_id,
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"enabled": True,
|
||||||
|
"config": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status = scheduler.status("svc-1", store=store)
|
||||||
|
assert bool(status["is_stale"])
|
||||||
|
assert status["poll_interval_seconds"] == 15
|
||||||
+164
-46
@@ -28,6 +28,13 @@ from media_library_viewer_api.services.secrets import (
|
|||||||
)
|
)
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def _definition(service_type: str):
|
||||||
|
definition = get_service_definition(service_type)
|
||||||
|
assert definition
|
||||||
|
return definition
|
||||||
|
|
||||||
|
|
||||||
TEST_KEY = Fernet.generate_key().decode()
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +70,7 @@ def test_registry_contains_eight_service_types():
|
|||||||
"alertmanager",
|
"alertmanager",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"ssh_tasks",
|
"remote_machine",
|
||||||
"backups",
|
"backups",
|
||||||
"authentik",
|
"authentik",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
@@ -73,14 +80,17 @@ def test_registry_contains_eight_service_types():
|
|||||||
def test_jellyseerr_absorbed_into_jellyfin():
|
def test_jellyseerr_absorbed_into_jellyfin():
|
||||||
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
|
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
|
||||||
assert "jellyseerr" not in SERVICE_DEFINITIONS
|
assert "jellyseerr" not in SERVICE_DEFINITIONS
|
||||||
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
|
jellyfin = _definition("jellyfin")
|
||||||
|
jellyfin_config = jellyfin.config_schema["properties"]
|
||||||
assert "jellyseerr_url" in jellyfin_config
|
assert "jellyseerr_url" in jellyfin_config
|
||||||
assert "jellyseerr_api_key" in jellyfin_config
|
# jellyseerr_api_key moved from config to a secret field.
|
||||||
|
assert "jellyseerr_api_key" not in jellyfin_config
|
||||||
|
assert "jellyseerr_api_key" in {sf.key for sf in jellyfin.secret_fields}
|
||||||
|
|
||||||
|
|
||||||
def test_backups_service_definition():
|
def test_backups_service_definition():
|
||||||
definition = get_service_definition("backups")
|
definition = _definition("backups")
|
||||||
assert definition is not None
|
assert definition
|
||||||
assert definition.secret_fields == []
|
assert definition.secret_fields == []
|
||||||
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
|
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
|
||||||
schema = definition.config_schema
|
schema = definition.config_schema
|
||||||
@@ -88,36 +98,75 @@ def test_backups_service_definition():
|
|||||||
|
|
||||||
|
|
||||||
def test_authentik_service_definition():
|
def test_authentik_service_definition():
|
||||||
definition = get_service_definition("authentik")
|
definition = _definition("authentik")
|
||||||
assert definition is not None
|
assert definition
|
||||||
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
|
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
|
||||||
assert definition.secret_fields[0].required is True
|
assert definition.secret_fields[0].required
|
||||||
assert definition.widget_kinds == []
|
assert {widget.kind for widget in definition.widget_kinds} == {
|
||||||
|
"access_summary",
|
||||||
|
"groups",
|
||||||
|
"applications",
|
||||||
|
}
|
||||||
schema = definition.config_schema
|
schema = definition.config_schema
|
||||||
assert "base_url" in schema["properties"]
|
assert "base_url" in schema["properties"]
|
||||||
assert "timeout_seconds" in schema["properties"]
|
assert "timeout_seconds" in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
def test_definitions_declare_widget_kinds():
|
def test_definitions_declare_widget_kinds():
|
||||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
assert {wk.kind for wk in _definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
||||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
assert {wk.kind for wk in _definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
assert {wk.kind for wk in _definition("jellyfin").widget_kinds} == {
|
||||||
assert get_service_definition("nextcloud").widget_kinds == []
|
"activity",
|
||||||
assert get_service_definition("authentik").widget_kinds == []
|
"now_playing",
|
||||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
"stat",
|
||||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
"stats_overview",
|
||||||
|
}
|
||||||
|
assert _definition("nextcloud").widget_kinds == []
|
||||||
|
assert {widget.kind for widget in _definition("authentik").widget_kinds} == {
|
||||||
|
"access_summary",
|
||||||
|
"groups",
|
||||||
|
"applications",
|
||||||
|
}
|
||||||
|
assert {wk.kind for wk in _definition("backups").widget_kinds} == {"summary"}
|
||||||
|
assert {wk.kind for wk in _definition("remote_machine").widget_kinds} == {"task_output"}
|
||||||
|
|
||||||
|
|
||||||
def test_widget_kind_lookup():
|
def test_widget_kind_lookup():
|
||||||
assert get_widget_kind("prometheus", "metric") is not None
|
assert get_widget_kind("prometheus", "metric")
|
||||||
assert get_widget_kind("prometheus", "missing") is None
|
assert not get_widget_kind("prometheus", "missing")
|
||||||
assert get_widget_kind("unknown", "metric") is None
|
assert not get_widget_kind("unknown", "metric")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_widget_kinds_expose_unit_and_scale_options():
|
||||||
|
"""Graph widgets share unit/scale options so axes/tooltips can be scaled."""
|
||||||
|
units = ["none", "bytes", "bytes_per_sec", "bits_per_sec", "bits", "percent", "seconds"]
|
||||||
|
scales = ["auto", "k", "m", "g", "t"]
|
||||||
|
|
||||||
|
prom_chart = get_widget_kind("prometheus", "chart")
|
||||||
|
assert prom_chart
|
||||||
|
prom_props = prom_chart.config_schema["properties"]
|
||||||
|
assert prom_props["unit"]["enum"] == units
|
||||||
|
assert prom_props["scale"]["enum"] == scales
|
||||||
|
|
||||||
|
qbit_speed = get_widget_kind("qbittorrent", "speed")
|
||||||
|
assert qbit_speed
|
||||||
|
qbit_props = qbit_speed.config_schema["properties"]
|
||||||
|
assert qbit_props["unit"]["enum"] == units
|
||||||
|
assert qbit_props["scale"]["enum"] == scales
|
||||||
|
# qBittorrent speed data is bytes/sec by default.
|
||||||
|
assert qbit_speed.default_config["unit"] == "bytes_per_sec"
|
||||||
|
# totals/active are not graphs and stay option-less.
|
||||||
|
qbit_totals = get_widget_kind("qbittorrent", "totals")
|
||||||
|
qbit_active = get_widget_kind("qbittorrent", "active")
|
||||||
|
assert qbit_totals and qbit_active
|
||||||
|
assert "unit" not in qbit_totals.config_schema["properties"]
|
||||||
|
assert "unit" not in qbit_active.config_schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
def test_service_config_schema_is_json_schema():
|
def test_service_config_schema_is_json_schema():
|
||||||
schema = get_service_definition("prometheus").config_schema
|
schema = _definition("prometheus").config_schema
|
||||||
assert schema["type"] == "object"
|
assert schema["type"] == "object"
|
||||||
assert "base_url" in schema["properties"]
|
assert "grafana_url" in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -175,14 +224,14 @@ def test_list_service_types(client):
|
|||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"ssh_tasks",
|
"remote_machine",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||||
response = client.get("/api/services/types")
|
response = client.get("/api/services/types")
|
||||||
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
||||||
assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
|
assert [sf["key"] for sf in prom["secret_fields"]] == ["grafana_api_key"]
|
||||||
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
||||||
|
|
||||||
|
|
||||||
@@ -195,8 +244,8 @@ def _prometheus_payload(**overrides):
|
|||||||
payload = {
|
payload = {
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "Production Prometheus",
|
"name": "Production Prometheus",
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
|
||||||
"secrets": {"api_key": "secret-token"},
|
"secrets": {"grafana_api_key": "secret-token"},
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
payload.update(overrides)
|
payload.update(overrides)
|
||||||
@@ -208,10 +257,10 @@ def test_create_and_list_service(client):
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
created = response.json()
|
created = response.json()
|
||||||
assert created["service_type"] == "prometheus"
|
assert created["service_type"] == "prometheus"
|
||||||
assert created["config"]["base_url"] == "https://prometheus.example.com"
|
assert created["config"]["grafana_url"] == "https://grafana.example.com"
|
||||||
# Plaintext secrets are never returned.
|
# Plaintext secrets are never returned.
|
||||||
assert "secrets" not in created
|
assert "secrets" not in created
|
||||||
assert created["secrets_set"] == {"api_key": True}
|
assert created["secrets_set"] == {"grafana_api_key": True}
|
||||||
|
|
||||||
response = client.get("/api/services/instances")
|
response = client.get("/api/services/instances")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -242,11 +291,11 @@ def test_update_service_preserves_unsent_secrets(client):
|
|||||||
json={
|
json={
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "Renamed Prometheus",
|
"name": "Renamed Prometheus",
|
||||||
"config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
|
"config": {"grafana_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||||
},
|
},
|
||||||
).json()
|
).json()
|
||||||
assert updated["name"] == "Renamed Prometheus"
|
assert updated["name"] == "Renamed Prometheus"
|
||||||
assert updated["secrets_set"] == {"api_key": True}
|
assert updated["secrets_set"] == {"grafana_api_key": True}
|
||||||
|
|
||||||
|
|
||||||
def test_update_service_can_clear_secret(client):
|
def test_update_service_can_clear_secret(client):
|
||||||
@@ -256,11 +305,44 @@ def test_update_service_can_clear_secret(client):
|
|||||||
json={
|
json={
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "Production Prometheus",
|
"name": "Production Prometheus",
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com"},
|
||||||
"secrets": {"api_key": ""},
|
"secrets": {"grafana_api_key": ""},
|
||||||
},
|
},
|
||||||
).json()
|
).json()
|
||||||
assert updated["secrets_set"] == {"api_key": False}
|
assert updated["secrets_set"] == {"grafana_api_key": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_test_uses_stored_secrets_when_not_reentered(client):
|
||||||
|
"""Testing an existing service merges stored secrets for blank fields.
|
||||||
|
|
||||||
|
The editor leaves secret fields blank ("leave blank to keep current"); the
|
||||||
|
test must authenticate with the stored secret rather than failing on empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||||
|
assert created["secrets_set"] == {"grafana_api_key": True}
|
||||||
|
|
||||||
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
|
||||||
|
with patch(
|
||||||
|
"media_library_viewer_api.integrations.prometheus.requests.post",
|
||||||
|
return_value=payload,
|
||||||
|
) as mock_post:
|
||||||
|
res = client.post(
|
||||||
|
"/api/services/test",
|
||||||
|
json={
|
||||||
|
"id": created["id"],
|
||||||
|
"service_type": "prometheus",
|
||||||
|
"name": "Production Prometheus",
|
||||||
|
"config": {"grafana_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {}, # not re-entered
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["ok"]
|
||||||
|
# The stored grafana_api_key was used for the request (not empty).
|
||||||
|
headers = mock_post.call_args.kwargs["headers"]
|
||||||
|
assert headers["Authorization"] == "Bearer secret-token"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_service_type_rejected(client):
|
def test_unknown_service_type_rejected(client):
|
||||||
@@ -274,7 +356,7 @@ def test_unknown_service_type_rejected(client):
|
|||||||
def test_invalid_config_rejected(client):
|
def test_invalid_config_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/services/instances",
|
"/api/services/instances",
|
||||||
json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
|
json={"service_type": "prometheus", "name": "x", "config": {"grafana_url": ""}},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
# Force a real validation error via bad type.
|
# Force a real validation error via bad type.
|
||||||
@@ -290,16 +372,16 @@ def test_invalid_config_rejected(client):
|
|||||||
)
|
)
|
||||||
def test_service_base_url_requires_http_schema(bad_url):
|
def test_service_base_url_requires_http_schema(bad_url):
|
||||||
"""Every service base_url must include an http:// or https:// schema."""
|
"""Every service base_url must include an http:// or https:// schema."""
|
||||||
model = get_service_definition("prometheus").config_model
|
model = _definition("prometheus").config_model
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
|
@pytest.mark.parametrize("service_type", ["alertmanager", "jellyfin", "authentik", "nextcloud"])
|
||||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||||
model = get_service_definition(service_type).config_model
|
model = _definition(service_type).config_model
|
||||||
instance = model.model_validate({"base_url": "https://example.com"})
|
instance = model.model_validate({"base_url": "https://example.com"})
|
||||||
assert instance.base_url == "https://example.com"
|
assert getattr(instance, "base_url") == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_secret_field_rejected(client):
|
def test_unknown_secret_field_rejected(client):
|
||||||
@@ -308,7 +390,7 @@ def test_unknown_secret_field_rejected(client):
|
|||||||
json={
|
json={
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "x",
|
"name": "x",
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com"},
|
||||||
"secrets": {"password": "leak"},
|
"secrets": {"password": "leak"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -321,7 +403,7 @@ def test_credential_key_in_config_rejected(client):
|
|||||||
json={
|
json={
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "x",
|
"name": "x",
|
||||||
"config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
|
"config": {"grafana_url": "https://grafana.example.com", "api_key": "leak"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
@@ -369,7 +451,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
|||||||
"""
|
"""
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
service = store.upsert_service(
|
service = store.upsert_service(
|
||||||
{"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
|
{"service_type": "prometheus", "name": "Prometheus", "config": {"grafana_url": "u"}, "enabled": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure the service_id column exists and seed a referencing widget.
|
# Ensure the service_id column exists and seed a referencing widget.
|
||||||
@@ -387,13 +469,14 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_service(service["id"])
|
store.delete_service(service["id"])
|
||||||
assert store.get_service(service["id"]) is None
|
assert not store.get_service(service["id"])
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
remaining = conn.execute(
|
remaining = conn.execute(
|
||||||
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
|
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
|
||||||
(service["id"],),
|
(service["id"],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert int(remaining[0]) == 0
|
assert remaining is not None
|
||||||
|
assert remaining[0] == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -475,7 +558,7 @@ def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypat
|
|||||||
def test_record_and_list_service_task_runs(client):
|
def test_record_and_list_service_task_runs(client):
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
service = store.upsert_service(
|
service = store.upsert_service(
|
||||||
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
|
{"service_type": "remote_machine", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||||
)
|
)
|
||||||
store.record_service_task_run(
|
store.record_service_task_run(
|
||||||
{
|
{
|
||||||
@@ -527,10 +610,45 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
|
|||||||
# Jellyseerr row is gone.
|
# Jellyseerr row is gone.
|
||||||
assert store.list_services("jellyseerr") == []
|
assert store.list_services("jellyseerr") == []
|
||||||
|
|
||||||
# Jellyfin config gained the absorbed fields.
|
# Jellyfin config gained jellyseerr_url; the api key is now a secret.
|
||||||
migrated = store.get_service(jellyfin["id"])
|
migrated = store.get_service(jellyfin["id"])
|
||||||
|
assert migrated
|
||||||
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
|
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
|
||||||
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
|
assert "jellyseerr_api_key" not in migrated["config"]
|
||||||
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
|
||||||
|
# The existing Jellyfin api_key is preserved (not double-encrypted).
|
||||||
|
assert decrypt_value(migrated["secrets"]["api_key"]) == "jf-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
|
||||||
|
"""A pre-existing plaintext jellyseerr_api_key in config moves to a secret."""
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
jellyfin = store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "jellyfin",
|
||||||
|
"name": "Main Jellyfin",
|
||||||
|
"config": {
|
||||||
|
"base_url": "https://jellyfin.example.com",
|
||||||
|
"jellyseerr_url": "https://jellyseerr.example.com",
|
||||||
|
"jellyseerr_api_key": "plaintext-key", # legacy plaintext in config
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
secret_values={"api_key": "jf-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
store.ensure_defaults() # runs the config->secret migration
|
||||||
|
|
||||||
|
migrated = store.get_service(jellyfin["id"])
|
||||||
|
assert migrated
|
||||||
|
assert "jellyseerr_api_key" not in migrated["config"]
|
||||||
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||||
|
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
|
||||||
|
store.ensure_defaults()
|
||||||
|
migrated = store.get_service(jellyfin["id"])
|
||||||
|
assert migrated
|
||||||
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||||
|
|
||||||
|
|
||||||
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
|
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Tests for Prometheus Node Exporter target discovery."""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@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) == []
|
|
||||||
+246
-131
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -14,6 +16,7 @@ from media_library_viewer_api.main import app
|
|||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.sources import (
|
from media_library_viewer_api.widgets.sources import (
|
||||||
AlertmanagerWidgetSource,
|
AlertmanagerWidgetSource,
|
||||||
|
AuthentikWidgetSource,
|
||||||
BackupsWidgetSource,
|
BackupsWidgetSource,
|
||||||
JellyfinWidgetSource,
|
JellyfinWidgetSource,
|
||||||
ServiceRecord,
|
ServiceRecord,
|
||||||
@@ -21,6 +24,7 @@ from media_library_viewer_api.widgets.sources import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
TEST_KEY = Fernet.generate_key().decode()
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
PROMQL_REQUIRED_ERROR = "promql is required"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -29,6 +33,22 @@ def _encryption_key(monkeypatch):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_qbittorrent_client_cache():
|
||||||
|
"""Reset the per-service qBittorrent client cache between tests.
|
||||||
|
|
||||||
|
QbittorrentWidgetSource reuses one authenticated client per service
|
||||||
|
(lru_cache) so the SID cookie persists across fetches. Without clearing,
|
||||||
|
a mock client cached by one test would leak into later tests that patch
|
||||||
|
QbittorrentClient differently.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.widgets.sources import _qbittorrent_client
|
||||||
|
|
||||||
|
_qbittorrent_client.cache_clear()
|
||||||
|
yield
|
||||||
|
_qbittorrent_client.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path):
|
||||||
"""FastAPI test client with a fresh settings store and auth disabled."""
|
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||||
@@ -42,7 +62,7 @@ def client(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
||||||
config = {"base_url": "https://prometheus.example.com"}
|
config = {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"}
|
||||||
config.update(config_overrides)
|
config.update(config_overrides)
|
||||||
return client.post(
|
return client.post(
|
||||||
"/api/services/instances",
|
"/api/services/instances",
|
||||||
@@ -302,7 +322,7 @@ def test_fetch_widget_service_disabled(client):
|
|||||||
json={
|
json={
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": service["name"],
|
"name": service["name"],
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -397,6 +417,31 @@ async def test_static_adapter():
|
|||||||
assert result == {"text": "hi"}
|
assert result == {"text": "hi"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authentik_adapter_returns_bounded_access_summaries():
|
||||||
|
client = MagicMock()
|
||||||
|
client.access_summaries.return_value = {"items": [{"id": "u1", "groups": []}], "total": 1}
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="auth",
|
||||||
|
service_type="authentik",
|
||||||
|
name="Auth",
|
||||||
|
config={"base_url": "https://auth.example.com", "timeout_seconds": 5},
|
||||||
|
secrets={"api_token": "token"},
|
||||||
|
)
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.AuthentikClient", return_value=client):
|
||||||
|
result = await AuthentikWidgetSource().fetch(service, "access_summary", {"limit": 100})
|
||||||
|
assert result["items"] == [{"id": "u1", "groups": []}]
|
||||||
|
client.access_summaries.assert_called_once_with(page=1, page_size=50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentik_definition_declares_read_only_widget_kinds():
|
||||||
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||||
|
|
||||||
|
definition = get_service_definition("authentik")
|
||||||
|
assert definition is not None
|
||||||
|
assert {kind.kind for kind in definition.widget_kinds} == {"access_summary", "groups", "applications"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backups_adapter(client):
|
async def test_backups_adapter(client):
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
@@ -418,18 +463,18 @@ async def test_ssh_task_adapter_missing_service():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ssh_task_adapter_records_history_on_run(client):
|
async def test_ssh_task_adapter_records_history_on_run(client):
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
# Save a task and an ssh_tasks service instance.
|
# Save a task and an remote_machine service instance.
|
||||||
task = store.upsert_task(
|
task = store.upsert_task(
|
||||||
{
|
{
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"task_type": "shell",
|
"task_type": "shell",
|
||||||
"content": "echo hi",
|
"content": "echo hi",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"default_service_id": "",
|
"service_id": "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
service = store.upsert_service(
|
service = store.upsert_service(
|
||||||
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
{"service_type": "remote_machine", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
||||||
@@ -439,7 +484,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
|||||||
|
|
||||||
adapter = SshTaskWidgetSource()
|
adapter = SshTaskWidgetSource()
|
||||||
service_record = ServiceRecord(
|
service_record = ServiceRecord(
|
||||||
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
|
id=service["id"], service_type="remote_machine", name="box", config={"host": "h", "username": "u"}
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
||||||
@@ -462,6 +507,7 @@ def test_jellyfin_definition_has_now_playing_widget():
|
|||||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||||
|
|
||||||
definition = get_service_definition("jellyfin")
|
definition = get_service_definition("jellyfin")
|
||||||
|
assert definition is not None
|
||||||
kinds = {wk.kind for wk in definition.widget_kinds}
|
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||||
assert "now_playing" in kinds
|
assert "now_playing" in kinds
|
||||||
assert "activity" in kinds
|
assert "activity" in kinds
|
||||||
@@ -469,39 +515,45 @@ def test_jellyfin_definition_has_now_playing_widget():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_chart_adapter_runs_range_query():
|
async def test_prometheus_chart_adapter_runs_range_query():
|
||||||
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
|
"""GM-106: chart kind hits /api/ds/query and returns {series}."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: {
|
||||||
"data": {
|
"results": {
|
||||||
"result": [
|
"A": {
|
||||||
{
|
"frames": [
|
||||||
"metric": {"__name__": "up", "instance": "h:9100"},
|
{
|
||||||
"values": [[100, "1"], [130, "1"]],
|
"data": {"values": [[100, 130], [1.0, 1.0]]},
|
||||||
}
|
"schema": {
|
||||||
]
|
"fields": [
|
||||||
|
{"name": "Time"},
|
||||||
|
{"name": "Value", "labels": {"__name__": "up", "instance": "h:9100"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
|
||||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||||
|
|
||||||
# query_range endpoint + window-derived start/end/step params.
|
call = mock_post.call_args
|
||||||
call = mock_get.call_args
|
assert call.args[0].endswith("/api/ds/query")
|
||||||
assert call.args[0].endswith("/api/v1/query_range")
|
body = call.kwargs["json"]
|
||||||
params = call.kwargs["params"]
|
assert body["queries"][0]["expr"] == "up"
|
||||||
assert params["query"] == "up"
|
assert body["queries"][0]["datasource"]["uid"] == "prometheus"
|
||||||
assert {"start", "end", "step"}.issubset(params)
|
|
||||||
# {series} shape with the shared normalization (label drops __name__).
|
|
||||||
assert "series" in result
|
assert "series" in result
|
||||||
assert result["series"][0]["label"] == "instance=h:9100"
|
assert result["series"][0]["label"] == "instance=h:9100"
|
||||||
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
|
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
|
||||||
@@ -509,26 +561,43 @@ async def test_prometheus_chart_adapter_runs_range_query():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_chart_adapter_requires_promql():
|
async def test_prometheus_chart_adapter_requires_promql():
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="prometheus",
|
||||||
|
name="p",
|
||||||
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
|
)
|
||||||
result = await adapter.fetch(service, "chart", {"promql": ""})
|
result = await adapter.fetch(service, "chart", {"promql": ""})
|
||||||
assert result == {"error": "promql is required"}
|
assert result == {"error": PROMQL_REQUIRED_ERROR}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_chart_adapter_degrades_on_http_error():
|
async def test_prometheus_chart_adapter_degrades_on_http_error():
|
||||||
"""SC-103: a connection error returns {error} rather than raising."""
|
"""GM-103: a connection error returns {error} rather than raising."""
|
||||||
import requests as req_mod
|
import requests as req_mod
|
||||||
|
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
|
id="s",
|
||||||
|
service_type="prometheus",
|
||||||
|
name="p",
|
||||||
|
config={
|
||||||
|
"grafana_url": "http://grafana:3000",
|
||||||
|
"datasource_uid": "prometheus",
|
||||||
|
"timeout_seconds": 2,
|
||||||
|
},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
|
with patch(
|
||||||
|
"media_library_viewer_api.widgets.sources.requests.post",
|
||||||
|
side_effect=req_mod.ConnectionError("refused"),
|
||||||
|
):
|
||||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||||
assert "error" in result
|
assert "error" in result
|
||||||
assert "failed" in result["error"].lower()
|
assert "failed" in result["error"].lower()
|
||||||
@@ -599,7 +668,7 @@ async def test_jellyfin_activity_shows_all_sessions():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def widget_ref_client(monkeypatch):
|
def widget_ref_client(monkeypatch, request):
|
||||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||||
monkeypatch.setenv(
|
monkeypatch.setenv(
|
||||||
"MANAGE_ENCRYPTION_KEY",
|
"MANAGE_ENCRYPTION_KEY",
|
||||||
@@ -619,8 +688,8 @@ def widget_ref_client(monkeypatch):
|
|||||||
|
|
||||||
app.dependency_overrides[get_settings_store] = get_store_override
|
app.dependency_overrides[get_settings_store] = get_store_override
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
yield client, store
|
request.addfinalizer(lambda: app.dependency_overrides.pop(get_settings_store, None))
|
||||||
app.dependency_overrides.pop(get_settings_store, None)
|
return client, store
|
||||||
|
|
||||||
|
|
||||||
def test_widget_reference_lifecycle(widget_ref_client):
|
def test_widget_reference_lifecycle(widget_ref_client):
|
||||||
@@ -632,7 +701,7 @@ def test_widget_reference_lifecycle(widget_ref_client):
|
|||||||
{
|
{
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "Prometheus",
|
"name": "Prometheus",
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
|
||||||
"secrets": {"api_key": "tok"},
|
"secrets": {"api_key": "tok"},
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
},
|
},
|
||||||
@@ -690,7 +759,7 @@ def test_widget_reference_detach(widget_ref_client):
|
|||||||
{
|
{
|
||||||
"service_type": "prometheus",
|
"service_type": "prometheus",
|
||||||
"name": "Prometheus",
|
"name": "Prometheus",
|
||||||
"config": {"base_url": "https://prometheus.example.com"},
|
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
|
||||||
"secrets": {"api_key": "tok"},
|
"secrets": {"api_key": "tok"},
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
},
|
},
|
||||||
@@ -794,46 +863,57 @@ def test_widget_reference_update_sort_order(widget_ref_client):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
|
# Prometheus gauge + mean adapter tests (GM-107..GM-108)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _grafana_single_frame(values, labels=None, display_name=None):
|
||||||
|
"""Build a Grafana /api/ds/query frames response for a single series."""
|
||||||
|
field: dict[str, Any] = {"name": "Value"}
|
||||||
|
if labels:
|
||||||
|
field["labels"] = labels
|
||||||
|
if display_name:
|
||||||
|
field["config"] = {"displayName": display_name}
|
||||||
|
return {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": values},
|
||||||
|
"schema": {"fields": [{"name": "Time"}, field]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_gauge_adapter_returns_scalar():
|
async def test_prometheus_gauge_adapter_returns_scalar():
|
||||||
"""SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
|
"""GM-107: gauge kind hits /api/ds/query and returns {value, thresholds}."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: _grafana_single_frame([[100], [0.75]]),
|
||||||
"data": {
|
|
||||||
"result": [
|
|
||||||
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
|
||||||
result = await adapter.fetch(
|
result = await adapter.fetch(
|
||||||
service,
|
service,
|
||||||
"gauge",
|
"gauge",
|
||||||
{
|
{"promql": "cpu_usage", "warn_at": 0.8, "crit_at": 0.95, "unit": "%"},
|
||||||
"promql": "cpu_usage",
|
|
||||||
"warn_at": 0.8,
|
|
||||||
"crit_at": 0.95,
|
|
||||||
"unit": "%",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
call = mock_get.call_args
|
call = mock_post.call_args
|
||||||
assert call.args[0].endswith("/api/v1/query")
|
assert call.args[0].endswith("/api/ds/query")
|
||||||
assert call.kwargs["params"]["query"] == "cpu_usage"
|
assert call.kwargs["json"]["queries"][0]["expr"] == "cpu_usage"
|
||||||
assert result["value"] == 0.75
|
assert result["value"] == 0.75
|
||||||
assert result["warn_at"] == 0.8
|
assert result["warn_at"] == 0.8
|
||||||
assert result["crit_at"] == 0.95
|
assert result["crit_at"] == 0.95
|
||||||
@@ -842,28 +922,31 @@ async def test_prometheus_gauge_adapter_returns_scalar():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_gauge_adapter_rejects_multi_series():
|
async def test_prometheus_gauge_adapter_rejects_multi_series():
|
||||||
"""SC-111: gauge must be scalar-only; multi-series returns error."""
|
"""GM-107: gauge must be scalar-only; multi-series returns error."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090"},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: {
|
||||||
"data": {
|
"results": {
|
||||||
"result": [
|
"A": {
|
||||||
{"metric": {"instance": "a"}, "value": [100, "1"]},
|
"frames": [
|
||||||
{"metric": {"instance": "b"}, "value": [100, "2"]},
|
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
|
||||||
]
|
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
|
||||||
result = await adapter.fetch(service, "gauge", {"promql": "up"})
|
result = await adapter.fetch(service, "gauge", {"promql": "up"})
|
||||||
assert "error" in result
|
assert "error" in result
|
||||||
assert "single-series" in result["error"].lower()
|
assert "single-series" in result["error"].lower()
|
||||||
@@ -871,45 +954,38 @@ async def test_prometheus_gauge_adapter_rejects_multi_series():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_gauge_adapter_requires_promql():
|
async def test_prometheus_gauge_adapter_requires_promql():
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090"},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
result = await adapter.fetch(service, "gauge", {"promql": ""})
|
result = await adapter.fetch(service, "gauge", {"promql": ""})
|
||||||
assert result == {"error": "promql is required"}
|
assert result == {"error": PROMQL_REQUIRED_ERROR}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_mean_adapter_computes_average():
|
async def test_prometheus_mean_adapter_computes_average():
|
||||||
"""SC-112: mean kind averages non-null values over the window."""
|
"""GM-108: mean kind averages non-null values over the window."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: _grafana_single_frame([[100, 130, 160], [1.0, 2.0, 3.0]]),
|
||||||
"data": {
|
|
||||||
"result": [
|
|
||||||
{
|
|
||||||
"metric": {"__name__": "cpu"},
|
|
||||||
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
|
||||||
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
|
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
|
||||||
assert result["value"] == 2.0
|
assert result["value"] == 2.0
|
||||||
assert result["unit"] is None
|
assert result["unit"] is None
|
||||||
@@ -917,28 +993,31 @@ async def test_prometheus_mean_adapter_computes_average():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_mean_adapter_rejects_multi_series():
|
async def test_prometheus_mean_adapter_rejects_multi_series():
|
||||||
"""SC-114: mean must be scalar-only; multi-series returns error."""
|
"""GM-108: mean must be scalar-only; multi-series returns error."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090"},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: {
|
||||||
"data": {
|
"results": {
|
||||||
"result": [
|
"A": {
|
||||||
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
|
"frames": [
|
||||||
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
|
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
|
||||||
]
|
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
|
||||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||||
assert "error" in result
|
assert "error" in result
|
||||||
assert "single-series" in result["error"].lower()
|
assert "single-series" in result["error"].lower()
|
||||||
@@ -946,48 +1025,49 @@ async def test_prometheus_mean_adapter_rejects_multi_series():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_mean_adapter_skips_nan_values():
|
async def test_prometheus_mean_adapter_skips_nan_values():
|
||||||
"""SC-112: NaN / Inf values are excluded from the mean computation."""
|
"""GM-108: NaN values are excluded from the mean computation."""
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090"},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
|
# Grafana frames shape with NaN — normalize_grafana_frames converts string "NaN" to None
|
||||||
payload = SimpleNamespace(
|
payload = SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {
|
json=lambda: {
|
||||||
"data": {
|
"results": {
|
||||||
"result": [
|
"A": {
|
||||||
{
|
"frames": [
|
||||||
"metric": {},
|
{"data": {"values": [[100, 130, 160], [2.0, "NaN", 4.0]]}, "schema": {"fields": [{}, {}]}}
|
||||||
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
|
]
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
|
||||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||||
# (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
|
|
||||||
assert result["value"] == 3.0
|
assert result["value"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prometheus_mean_adapter_requires_promql():
|
async def test_prometheus_mean_adapter_requires_promql():
|
||||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
from media_library_viewer_api.widgets.sources import MetricSource
|
||||||
|
|
||||||
adapter = PrometheusWidgetSource()
|
adapter = MetricSource()
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="s",
|
id="s",
|
||||||
service_type="prometheus",
|
service_type="prometheus",
|
||||||
name="p",
|
name="p",
|
||||||
config={"base_url": "http://p:9090"},
|
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
||||||
|
secrets={"grafana_api_key": "key"},
|
||||||
)
|
)
|
||||||
result = await adapter.fetch(service, "mean", {"promql": ""})
|
result = await adapter.fetch(service, "mean", {"promql": ""})
|
||||||
assert result == {"error": "promql is required"}
|
assert result == {"error": PROMQL_REQUIRED_ERROR}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1005,6 +1085,7 @@ def _fake_qbit_maindata():
|
|||||||
"state": "downloading",
|
"state": "downloading",
|
||||||
"size": 1000,
|
"size": 1000,
|
||||||
"progress": 0.5,
|
"progress": 0.5,
|
||||||
|
"ratio": 1.25,
|
||||||
"dlspeed": 500,
|
"dlspeed": 500,
|
||||||
"upspeed": 10,
|
"upspeed": 10,
|
||||||
},
|
},
|
||||||
@@ -1013,6 +1094,7 @@ def _fake_qbit_maindata():
|
|||||||
"state": "uploading",
|
"state": "uploading",
|
||||||
"size": 2000,
|
"size": 2000,
|
||||||
"progress": 1.0,
|
"progress": 1.0,
|
||||||
|
"ratio": 0.5,
|
||||||
"dlspeed": 0,
|
"dlspeed": 0,
|
||||||
"upspeed": 100,
|
"upspeed": 100,
|
||||||
},
|
},
|
||||||
@@ -1032,6 +1114,22 @@ def _fake_qbit_maindata():
|
|||||||
"dlspeed": 0,
|
"dlspeed": 0,
|
||||||
"upspeed": 0,
|
"upspeed": 0,
|
||||||
},
|
},
|
||||||
|
"h5": {
|
||||||
|
"name": "Forced download",
|
||||||
|
"state": "forcedDL",
|
||||||
|
"size": 5000,
|
||||||
|
"progress": 0.4,
|
||||||
|
"dlspeed": 0,
|
||||||
|
"upspeed": 0,
|
||||||
|
},
|
||||||
|
"h6": {
|
||||||
|
"name": "Stalled upload",
|
||||||
|
"state": "stalledUP",
|
||||||
|
"size": 6000,
|
||||||
|
"progress": 1.0,
|
||||||
|
"dlspeed": 0,
|
||||||
|
"upspeed": 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,16 +1151,17 @@ async def test_qbittorrent_totals_counts_all_torrents():
|
|||||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||||
result = await adapter.fetch(service, "totals", {})
|
result = await adapter.fetch(service, "totals", {})
|
||||||
|
|
||||||
assert result["total"] == 4
|
assert result["total"] == 6
|
||||||
assert result["by_state"]["downloading"] == 1
|
assert result["by_state"]["downloading"] == 1
|
||||||
assert result["by_state"]["uploading"] == 1
|
assert result["by_state"]["uploading"] == 1
|
||||||
assert result["by_state"]["queuedDL"] == 1
|
assert result["by_state"]["queuedDL"] == 1
|
||||||
assert result["by_state"]["pausedDL"] == 1
|
assert result["by_state"]["pausedDL"] == 1
|
||||||
|
assert result["by_direction"] == {"downloading": 3, "uploading": 2}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qbittorrent_active_filters_dl_ul_only():
|
async def test_qbittorrent_active_filters_current_transfers_only():
|
||||||
"""Active kind returns only downloading/uploading torrents (Q3)."""
|
"""Active kind returns only torrents with current download or upload throughput."""
|
||||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
|
|
||||||
adapter = QbittorrentWidgetSource()
|
adapter = QbittorrentWidgetSource()
|
||||||
@@ -1079,17 +1178,15 @@ async def test_qbittorrent_active_filters_dl_ul_only():
|
|||||||
|
|
||||||
active = result["torrents"]
|
active = result["torrents"]
|
||||||
assert len(active) == 2
|
assert len(active) == 2
|
||||||
names = [t["name"] for t in active]
|
names = [torrent["name"] for torrent in active]
|
||||||
assert "Movie.mkv" in names
|
assert names == ["Movie.mkv", "Show.mkv"]
|
||||||
assert "Show.mkv" in names
|
assert [torrent["ratio"] for torrent in active] == [1.25, 0.5]
|
||||||
# Queued and paused are excluded
|
assert all((torrent["dl_speed"] or 0) > 0 or (torrent["up_speed"] or 0) > 0 for torrent in active)
|
||||||
assert "Queued" not in names
|
|
||||||
assert "Paused" not in names
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
|
async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path):
|
||||||
"""Speed kind appends a sample and returns {series} with two labeled series."""
|
"""Speed kind reads persisted samples and never calls qBittorrent itself."""
|
||||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
|
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
|
||||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
@@ -1112,21 +1209,39 @@ async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
|
|||||||
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
|
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
|
||||||
):
|
):
|
||||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||||
# Wire the mock store to a real isolated store
|
# Wire the mock store to a real isolated store and seed headless data.
|
||||||
real_store = QbittorrentSampleStore(harness)
|
real_store = QbittorrentSampleStore(harness)
|
||||||
|
real_store.append("svc-speed", round(time.time()), 500000, 1000)
|
||||||
mock_store_cls.return_value = real_store
|
mock_store_cls.return_value = real_store
|
||||||
result = await adapter.fetch(service, "speed", {})
|
result = await adapter.fetch(service, "speed", {})
|
||||||
|
|
||||||
|
mock_client.assert_not_called()
|
||||||
|
|
||||||
assert "series" in result
|
assert "series" in result
|
||||||
labels = [s["label"] for s in result["series"]]
|
labels = [s["label"] for s in result["series"]]
|
||||||
assert labels == ["download", "upload"]
|
assert labels == ["download", "upload"]
|
||||||
# The sample just appended should be present
|
# The scheduler-supplied sample should be present.
|
||||||
dl_points = result["series"][0]["points"]
|
dl_points = result["series"][0]["points"]
|
||||||
assert len(dl_points) >= 1
|
assert len(dl_points) >= 1
|
||||||
# timestamps multiplied by 1000 for JS epoch
|
# timestamps multiplied by 1000 for JS epoch
|
||||||
assert dl_points[-1]["v"] == 500000
|
assert dl_points[-1]["v"] == 500000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qbittorrent_speed_all_values_reads_all_retained_samples():
|
||||||
|
"""The all-values speed setting intentionally omits the time cutoff."""
|
||||||
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
|
|
||||||
|
adapter = QbittorrentWidgetSource()
|
||||||
|
service = ServiceRecord(id="svc-speed", service_type="qbittorrent", name="qbit", config={}, secrets={})
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as store_cls:
|
||||||
|
store_cls.return_value.window.return_value = [{"ts": 10, "dl_speed": 20, "up_speed": 30}]
|
||||||
|
result = await adapter.fetch(service, "speed", {"window_seconds": "all"})
|
||||||
|
|
||||||
|
store_cls.return_value.window.assert_called_once_with("svc-speed")
|
||||||
|
assert result["series"][0]["points"] == [{"t": 10_000, "v": 20}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qbittorrent_adapter_missing_service():
|
async def test_qbittorrent_adapter_missing_service():
|
||||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
|
|||||||
+53
-11
@@ -46,6 +46,8 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
|
- All in-app time-series widgets should use the shared range-aware `LineSeriesChart` component so filtering and display formatting remain consistent across Prometheus and qBittorrent charts. A dashboard widget's configured window is its single source of range selection and the card renders the complete configured response; the standalone qBittorrent service-history page retains an interactive selector with **All values** for all retained samples.
|
||||||
|
|
||||||
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
||||||
wrapper (`components/ui/data-table.tsx`).
|
wrapper (`components/ui/data-table.tsx`).
|
||||||
- Parity is **visibility-only**: pagination, row selection, row click, and column
|
- Parity is **visibility-only**: pagination, row selection, row click, and column
|
||||||
@@ -132,10 +134,20 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
- The File Browser should persist its current directory and selected file across reloads and tab switches.
|
- The File Browser should persist its current directory and selected file across reloads and tab switches.
|
||||||
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
|
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
|
||||||
|
|
||||||
|
### Authentik Directory and Access Metadata
|
||||||
|
|
||||||
|
- Authentik is the read-only identity-directory source for its service page and dashboard widgets.
|
||||||
|
- Provide read-only user access summaries showing group membership and explicit staff/superuser status.
|
||||||
|
- Label the summary as **access metadata**, not complete effective authorization: conditional or expression-based Authentik policies are not evaluated by Manage.
|
||||||
|
- Provide read-only groups and applications lists. Application entries may include safe display metadata such as name, slug, launch URL, and policy-engine mode, but must never expose provider configuration, tokens, or raw policy data.
|
||||||
|
- The configured Authentik API token must have read access to users, groups, and applications.
|
||||||
|
- Authentik data reads must remain service-instance scoped and tolerate unavailable upstream services with an empty/error state.
|
||||||
|
- Authentik widgets are read-only and support bounded display limits for access summaries, groups, and applications.
|
||||||
|
|
||||||
### Remote Filesystem over SSH
|
### Remote Filesystem over SSH
|
||||||
|
|
||||||
- Connect to a remote media server via SSH.
|
- Connect to a remote media server via SSH.
|
||||||
- Use strict SSH host key behavior, but synthesize and persist the managed `known_hosts` file from configured SSH machines instead of requiring users to mount their own `known_hosts` file.
|
- Use strict SSH host key behavior, but synthesize and persist the managed `known_hosts` file from configured remote-machine services instead of requiring users to mount their own `known_hosts` file.
|
||||||
- Browse remote directories and files rooted at a configurable default media path.
|
- Browse remote directories and files rooted at a configurable default media path.
|
||||||
- File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`).
|
- File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`).
|
||||||
- Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths.
|
- Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths.
|
||||||
@@ -198,7 +210,7 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
- Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests.
|
- Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests.
|
||||||
- Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage.
|
- Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage.
|
||||||
- Provide Docker Compose deployment files at the repository root for production and local development. These deploy **only** the backend and frontend; Manage connects to *existing* Grafana/Prometheus/Alertmanager instances and never ships its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
- Provide Docker Compose deployment files at the repository root for production and local development. These deploy **only** the backend and frontend; Manage connects to *existing* Grafana/Prometheus/Alertmanager instances and never ships its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
||||||
- SSH private keys should be managed as reusable saved secrets in Settings, independent of any one machine, and SSH machines should select from that saved-key list.
|
- SSH private keys should be managed as reusable saved secrets in Settings, independent of any one machine, and remote machine services should select from that saved-key list.
|
||||||
- The web UI should allow both importing an existing private key and generating a new SSH keypair for that saved-key list.
|
- The web UI should allow both importing an existing private key and generating a new SSH keypair for that saved-key list.
|
||||||
- Saved SSH keys should display their derived public key, fingerprint, and machine usage count so administrators can audit them at a glance.
|
- Saved SSH keys should display their derived public key, fingerprint, and machine usage count so administrators can audit them at a glance.
|
||||||
- The app should support optional SSH private key passphrases alongside the stored key material.
|
- The app should support optional SSH private key passphrases alongside the stored key material.
|
||||||
@@ -285,9 +297,12 @@ values missing an `http://` or `https://` schema with a clear validation error
|
|||||||
passphrase; provides a task-output widget. Tasks stay in the global saved-task
|
passphrase; provides a task-output widget. Tasks stay in the global saved-task
|
||||||
registry; every run is recorded in `service_task_runs` as history.
|
registry; every run is recorded in `service_task_runs` as history.
|
||||||
|
|
||||||
Multiple instances per service type are supported. Services are managed from the
|
Multiple instances per service type are supported. Services are managed from
|
||||||
**Services** page (`/services`) and each instance has a detail page at
|
**Settings → Services**, which provides a list view for creating and editing
|
||||||
`/services/:serviceType/:serviceId`.
|
instances. Named dashboards are managed in their own **Settings → Dashboards**
|
||||||
|
tab. Each
|
||||||
|
instance retains its operational detail page at `/services/:serviceType/:serviceId`;
|
||||||
|
legacy `/services` navigation redirects to Settings.
|
||||||
|
|
||||||
### Built-in widgets
|
### Built-in widgets
|
||||||
|
|
||||||
@@ -296,6 +311,26 @@ Multiple instances per service type are supported. Services are managed from the
|
|||||||
|
|
||||||
These do not reference a service.
|
These do not reference a service.
|
||||||
|
|
||||||
|
### Backend Scheduled Actions and qBittorrent Polling
|
||||||
|
|
||||||
|
- The backend should collect qBittorrent speed samples independently of browser or dashboard presence.
|
||||||
|
- Scheduled work should use a typed, explicitly registered action system; arbitrary widgets, SSH commands, and user-provided code must not be executable through the scheduler.
|
||||||
|
- The first scheduled action is qBittorrent speed sampling. The initial deployment assumes one scheduler-capable backend worker; multiple replicas must not silently duplicate polls.
|
||||||
|
- qBittorrent polling should be opt-out by default for enabled service instances and configurable per service with a 15-second default interval bounded to 5–300 seconds.
|
||||||
|
- Sample retention should be configurable by duration and maximum rows, defaulting to 30 minutes and 1,200 rows, with duration bounded to 1–24 hours and the row cap enforced server-side.
|
||||||
|
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
|
||||||
|
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
|
||||||
|
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
||||||
|
- The qBittorrent client must merge incremental torrent patches with the prior
|
||||||
|
snapshot so active-transfer rows retain their name, size, progress, and state
|
||||||
|
when only throughput changes.
|
||||||
|
- Active-torrent entries must show each torrent's qBittorrent share ratio
|
||||||
|
(uploaded ÷ downloaded) alongside its size and completion progress.
|
||||||
|
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history.
|
||||||
|
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
|
||||||
|
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
|
||||||
|
- Persistent polling failures should be visible in the service UI and application metrics; a new notification channel is not required for the first release.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
||||||
@@ -374,7 +409,7 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
|||||||
- 2026-05-06: Monitoring became machine-based: a Settings tab now persists local/remote machine definitions, and the Monitoring tab renders a section per configured machine so API-host and remote targets are handled through the same UI model.
|
- 2026-05-06: Monitoring became machine-based: a Settings tab now persists local/remote machine definitions, and the Monitoring tab renders a section per configured machine so API-host and remote targets are handled through the same UI model.
|
||||||
- 2026-05-06: Compose files were switched away from `env_file` and now rely on environment-variable interpolation, so deployments can be driven entirely by shell exports or inline environment values.
|
- 2026-05-06: Compose files were switched away from `env_file` and now rely on environment-variable interpolation, so deployments can be driven entirely by shell exports or inline environment values.
|
||||||
- 2026-05-06: Monitoring endpoints now translate machine-specific transport/runtime failures into user-facing HTTP errors so a broken machine only affects its own section instead of taking down the whole Monitoring page.
|
- 2026-05-06: Monitoring endpoints now translate machine-specific transport/runtime failures into user-facing HTTP errors so a broken machine only affects its own section instead of taking down the whole Monitoring page.
|
||||||
- 2026-05-06: Documentation now includes explicit Compose interpolation examples plus a monitoring-machine configuration workflow showing how to add local and SSH machines in the Settings tab.
|
- 2026-05-06: Documentation now includes explicit Compose interpolation examples plus a monitoring-machine configuration workflow showing how to add local and remote machine services in the Settings tab.
|
||||||
- 2026-05-06: Monitoring machine action history was added so each machine section can display recent operation results, durations, and failures alongside the charts.
|
- 2026-05-06: Monitoring machine action history was added so each machine section can display recent operation results, durations, and failures alongside the charts.
|
||||||
- 2026-05-06: Monitoring history collection was shifted to a backend-scheduled poller that reads the defined machines over SSH/local shell and stores snapshots in SQLite, avoiding any remote agent or push requirement.
|
- 2026-05-06: Monitoring history collection was shifted to a backend-scheduled poller that reads the defined machines over SSH/local shell and stores snapshots in SQLite, avoiding any remote agent or push requirement.
|
||||||
- 2026-05-06: The dashboard monitoring section was converted from summary cards into a table of all configured machines, paired with backend poller status so the whole fleet can be reviewed at a glance.
|
- 2026-05-06: The dashboard monitoring section was converted from summary cards into a table of all configured machines, paired with backend poller status so the whole fleet can be reviewed at a glance.
|
||||||
@@ -395,18 +430,18 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
|||||||
- 2026-05-06: Application settings now include per-machine Jellyfin/Jellyseerr configuration and multi-select service roles so the UI can manage app hosts from the same machine registry.
|
- 2026-05-06: Application settings now include per-machine Jellyfin/Jellyseerr configuration and multi-select service roles so the UI can manage app hosts from the same machine registry.
|
||||||
- 2026-05-06: The app shell now uses an Applications top-level tab with a Jellyfin subtab for media/library work and a placeholder Nextcloud subtab for future expansion.
|
- 2026-05-06: The app shell now uses an Applications top-level tab with a Jellyfin subtab for media/library work and a placeholder Nextcloud subtab for future expansion.
|
||||||
- 2026-05-06: The settings model now treats Jellyfin/Jellyseerr as machine-level configuration instead of global env-only values, so app hosts can be edited alongside other machine services.
|
- 2026-05-06: The settings model now treats Jellyfin/Jellyseerr as machine-level configuration instead of global env-only values, so app hosts can be edited alongside other machine services.
|
||||||
- 2026-05-06: The backend now synthesizes a managed `known_hosts` file from configured SSH machines at startup, avoiding a mounted SSH directory while keeping strict host-key verification enabled.
|
- 2026-05-06: The backend now synthesizes a managed `known_hosts` file from configured remote-machine services at startup, avoiding a mounted SSH directory while keeping strict host-key verification enabled.
|
||||||
- 2026-05-06: SSH credentials were moved toward reusable saved key records in Settings, so machines can point at a shared SSH key instead of storing their own duplicate private key text.
|
- 2026-05-06: SSH credentials were moved toward reusable saved key records in Settings, so machines can point at a shared SSH key instead of storing their own duplicate private key text.
|
||||||
- 2026-05-06: The Settings page now includes an SSH key registry UI with create/edit/delete flows and a generate-key action so users can make a reusable key directly in the web interface.
|
- 2026-05-06: The Settings page now includes an SSH key registry UI with create/edit/delete flows and a generate-key action so users can make a reusable key directly in the web interface.
|
||||||
- 2026-05-06: Saved SSH keys now surface a derived public key, fingerprint, and per-key machine usage count in the Settings UI for easier auditing.
|
- 2026-05-06: Saved SSH keys now surface a derived public key, fingerprint, and per-key machine usage count in the Settings UI for easier auditing.
|
||||||
- 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote SSH machines.
|
- 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote remote machine services.
|
||||||
- 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases.
|
- 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases.
|
||||||
- 2026-05-06: The Actions page was redesigned into a compact tabbed workspace with a left tab rail of saved actions, and both new-action creation and editing now open in popups instead of inline forms.
|
- 2026-05-06: The Actions page was redesigned into a compact tabbed workspace with a left tab rail of saved actions, and both new-action creation and editing now open in popups instead of inline forms.
|
||||||
- 2026-05-07: Added a reusable dashboard shortcuts container with persisted records so the dashboard can link to external websites now and later support action/user shortcut types from the same model.
|
- 2026-05-07: Added a reusable dashboard shortcuts container with persisted records so the dashboard can link to external websites now and later support action/user shortcut types from the same model.
|
||||||
- 2026-05-07: Dashboard shortcuts gained an optional icon/preview field so cards can be visually differentiated while keeping future shortcut types extensible.
|
- 2026-05-07: Dashboard shortcuts gained an optional icon/preview field so cards can be visually differentiated while keeping future shortcut types extensible.
|
||||||
- 2026-05-07: The dashboard shortcut editor was tightened with compact type guidance and shorter helper text so the popup stays readable without wasting vertical space.
|
- 2026-05-07: The dashboard shortcut editor was tightened with compact type guidance and shorter helper text so the popup stays readable without wasting vertical space.
|
||||||
- 2026-05-07: SSH key records should persist and display the derived public key and fingerprint, not just the private key blob, so imports and generated keys are auditable without recomputation.
|
- 2026-05-07: SSH key records should persist and display the derived public key and fingerprint, not just the private key blob, so imports and generated keys are auditable without recomputation.
|
||||||
- 2026-05-07: SSH machine creation/editing should present a saved-key dropdown and warn when no SSH keys exist yet, instead of forcing manual key-id entry.
|
- 2026-05-07: remote machine service creation/editing should present a saved-key dropdown and warn when no SSH keys exist yet, instead of forcing manual key-id entry.
|
||||||
- 2026-05-07: Saved task runs should return structured failure output for local execution problems instead of surfacing a generic 500 error.
|
- 2026-05-07: Saved task runs should return structured failure output for local execution problems instead of surfacing a generic 500 error.
|
||||||
- 2026-05-07: SSH dependency resolution should keep its cached tuple shape aligned with the legacy and machine-specific SSH settings so SSH clients can be created without tuple-unpack crashes.
|
- 2026-05-07: SSH dependency resolution should keep its cached tuple shape aligned with the legacy and machine-specific SSH settings so SSH clients can be created without tuple-unpack crashes.
|
||||||
- 2026-05-07: Machine creation was adjusted so dialog edits are controlled by the parent form state, ensuring all entered fields are actually saved.
|
- 2026-05-07: Machine creation was adjusted so dialog edits are controlled by the parent form state, ensuring all entered fields are actually saved.
|
||||||
@@ -425,13 +460,13 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
|||||||
- 2026-05-06: The File Browser was reworked into Browser / Media info / Jobs subtabs.
|
- 2026-05-06: The File Browser was reworked into Browser / Media info / Jobs subtabs.
|
||||||
- 2026-05-06: The app shell received a small density pass that tightened container padding and tab widths to make the whole site feel more compact.
|
- 2026-05-06: The app shell received a small density pass that tightened container padding and tab widths to make the whole site feel more compact.
|
||||||
- 2026-05-06: The tab rails across Actions, Monitoring, Settings, and Files were restyled to be more enterprise-console-like with compact pills, clearer active states, and reduced visual noise.
|
- 2026-05-06: The tab rails across Actions, Monitoring, Settings, and Files were restyled to be more enterprise-console-like with compact pills, clearer active states, and reduced visual noise.
|
||||||
- 2026-05-06: Added an Actions tab for saved server tasks, with backend persistence, per-task run history, and support for shell/Python task types on either local or SSH machines.
|
- 2026-05-06: Added an Actions tab for saved server tasks, with backend persistence, per-task run history, and support for shell/Python task types on either local or remote machine services.
|
||||||
- 2026-05-06: Reusable dialog footers now keep cancel on the left and confirm on the right, and hover edit buttons now appear on the right edge of editable list rows in Actions and Settings.
|
- 2026-05-06: Reusable dialog footers now keep cancel on the left and confirm on the right, and hover edit buttons now appear on the right edge of editable list rows in Actions and Settings.
|
||||||
- 2026-05-06: Library stats, Jellyfin activity, and Monitoring overview now use shared section-container patterns so subcontainers stay consistent across the app.
|
- 2026-05-06: Library stats, Jellyfin activity, and Monitoring overview now use shared section-container patterns so subcontainers stay consistent across the app.
|
||||||
- 2026-05-07: The app versioning scheme should be hybrid: auto-detect package/build metadata when available, but allow explicit overrides for deployments that need fixed labels.
|
- 2026-05-07: The app versioning scheme should be hybrid: auto-detect package/build metadata when available, but allow explicit overrides for deployments that need fixed labels.
|
||||||
- 2026-05-07: The shell should display both frontend and backend version labels so deployed builds are easy to identify without opening a separate diagnostics screen.
|
- 2026-05-07: The shell should display both frontend and backend version labels so deployed builds are easy to identify without opening a separate diagnostics screen.
|
||||||
- 2026-05-07: SSH host verification should use trust-on-first-use for new machines by recording the first observed host key into the backend-managed known_hosts file, while still rejecting later key mismatches.
|
- 2026-05-07: SSH host verification should use trust-on-first-use for new machines by recording the first observed host key into the backend-managed known_hosts file, while still rejecting later key mismatches.
|
||||||
- 2026-05-07: The SSH machine editor should expose a validation button that tests banner/auth flow and records the host key before save so users get clear feedback when a host is unreachable.
|
- 2026-05-07: The remote machine service editor should expose a validation button that tests banner/auth flow and records the host key before save so users get clear feedback when a host is unreachable.
|
||||||
- 2026-05-07: Saving a monitoring-capable machine should validate the banner/auth flow, update the backend-managed known_hosts entry for the current host, and start the remote resource collector so charts populate without a separate manual step.
|
- 2026-05-07: Saving a monitoring-capable machine should validate the banner/auth flow, update the backend-managed known_hosts entry for the current host, and start the remote resource collector so charts populate without a separate manual step.
|
||||||
- 2026-05-07: Machine settings should visually separate Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes into clearly labeled sections.
|
- 2026-05-07: Machine settings should visually separate Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes into clearly labeled sections.
|
||||||
|
|
||||||
@@ -522,3 +557,10 @@ unchanged.
|
|||||||
|
|
||||||
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||||
and above, the desktop hover-reveal aesthetic is preserved.
|
and above, the desktop hover-reveal aesthetic is preserved.
|
||||||
|
|
||||||
|
### Remote-machine services
|
||||||
|
|
||||||
|
- Remote hosts are configured as enabled `remote_machine` services under Settings > Services, with host, port, username, saved SSH-key reference, timeout, and encrypted passphrase/password secrets.
|
||||||
|
- Files and Actions require an explicit remote-machine `service_id`; saved task output and task runs remain service-scoped.
|
||||||
|
- Legacy SSH task services and SSH machine records migrate into remote-machine services. The local legacy placeholder is not migrated; the saved SSH-key registry is preserved.
|
||||||
|
- Manage does not discover or configure Node Exporter targets. Prometheus and Alertmanager remain independently configured services.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Scheduled Actions Test Plan / QA Checklist
|
||||||
|
|
||||||
|
## Test objectives
|
||||||
|
|
||||||
|
Verify that qBittorrent speed collection is backend-owned, configurable per service, resilient to transient failures, observable, and safe when the frontend is not open.
|
||||||
|
|
||||||
|
## Backend unit tests
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- [ ] Existing qBittorrent configs receive polling defaults without changing secrets.
|
||||||
|
- [ ] `polling_enabled` accepts booleans and defaults to enabled.
|
||||||
|
- [ ] Poll intervals below 5 seconds or above 300 seconds are rejected.
|
||||||
|
- [ ] Retention below 60 seconds or above 86,400 seconds is rejected.
|
||||||
|
- [ ] Sample caps below the minimum or above 1,200 are rejected.
|
||||||
|
- [ ] Credential-looking fields remain rejected from service config.
|
||||||
|
|
||||||
|
### Sample storage
|
||||||
|
|
||||||
|
- [ ] Samples remain isolated by `service_id`.
|
||||||
|
- [ ] Timestamp retention removes rows older than the configured window.
|
||||||
|
- [ ] Row-cap retention removes the oldest rows when the cap is exceeded.
|
||||||
|
- [ ] Both retention rules are applied together.
|
||||||
|
- [ ] Samples are ordered oldest to newest for chart responses.
|
||||||
|
- [ ] Service cascade deletion removes samples.
|
||||||
|
- [ ] Empty and missing databases initialize safely.
|
||||||
|
|
||||||
|
### Scheduler core
|
||||||
|
|
||||||
|
- [ ] Only registered action keys can execute.
|
||||||
|
- [ ] Disabled services do not run.
|
||||||
|
- [ ] Enabled services run immediately after startup with deterministic staggering.
|
||||||
|
- [ ] A normal run waits the configured interval after completion.
|
||||||
|
- [ ] A slow run cannot overlap itself.
|
||||||
|
- [ ] A slow run does not create queued backlog entries.
|
||||||
|
- [ ] Stop interrupts the wait and joins the worker within the configured timeout.
|
||||||
|
- [ ] A configuration change is applied on the next reconciliation cycle.
|
||||||
|
- [ ] Disabling a service allows an in-flight run to finish, then prevents new runs.
|
||||||
|
- [ ] Deleting a service removes its schedule state and history.
|
||||||
|
|
||||||
|
### Backoff and manual runs
|
||||||
|
|
||||||
|
- [ ] A failed run is persisted with safe error text and increments failure state.
|
||||||
|
- [ ] Retry delay increases exponentially and respects the configured cap.
|
||||||
|
- [ ] Backoff does not create duplicate queued runs.
|
||||||
|
- [ ] A successful scheduled run clears failures and backoff.
|
||||||
|
- [ ] A successful manual run clears failures and backoff.
|
||||||
|
- [ ] A failed manual run follows normal failure persistence and retry behavior.
|
||||||
|
- [ ] Manual runs do not alter the configured interval.
|
||||||
|
|
||||||
|
### qBittorrent action and widget separation
|
||||||
|
|
||||||
|
- [ ] The scheduled action calls qBittorrent and appends exactly one sample per successful poll.
|
||||||
|
- [ ] The qBittorrent client cache is reused correctly per service.
|
||||||
|
- [ ] Timeouts become failed runs without blocking the scheduler indefinitely.
|
||||||
|
- [ ] The `speed` widget adapter reads samples but does not call qBittorrent.
|
||||||
|
- [ ] Multiple widgets or browser tabs do not multiply samples.
|
||||||
|
- [ ] A failed latest poll still returns last-known samples with stale status.
|
||||||
|
|
||||||
|
## Backend API tests
|
||||||
|
|
||||||
|
- [ ] Scheduler status requires normal API authentication.
|
||||||
|
- [ ] Status returns effective configuration, last attempt, last success, failure count, backoff, and stale state.
|
||||||
|
- [ ] Missing service returns 404 or the project’s established service error shape.
|
||||||
|
- [ ] Disabled service status is explicit and does not run an action.
|
||||||
|
- [ ] Run history is paginated and supports status/trigger filters.
|
||||||
|
- [ ] Run history is bounded by 30 days and 1,000 records per service/action.
|
||||||
|
- [ ] Manual-run endpoint returns a typed result and records the attempt.
|
||||||
|
- [ ] Samples endpoint accepts a display window and is read-only.
|
||||||
|
- [ ] Widget-data requests never cause a qBittorrent external call.
|
||||||
|
- [ ] Responses never expose usernames, passwords, API keys, headers, or raw payloads.
|
||||||
|
|
||||||
|
## Observability tests
|
||||||
|
|
||||||
|
- [ ] Run counters increment for success and failure.
|
||||||
|
- [ ] Duration metrics record completed attempts.
|
||||||
|
- [ ] Last-success gauges update only after successful sampling.
|
||||||
|
- [ ] Failure/stale gauges reset after recovery.
|
||||||
|
- [ ] Metric labels are bounded and contain no secrets or raw URLs.
|
||||||
|
- [ ] Structured logs include action/service/status context and sanitize errors.
|
||||||
|
|
||||||
|
## Frontend unit/component tests
|
||||||
|
|
||||||
|
- [ ] qBittorrent schedule fields render only for qBittorrent services.
|
||||||
|
- [ ] Invalid interval, retention, and cap values show validation feedback.
|
||||||
|
- [ ] Save preserves existing encrypted-secret behavior.
|
||||||
|
- [ ] Disabled polling clearly shows paused state.
|
||||||
|
- [ ] Status card renders healthy, running, backoff, stale, disabled, and never-run states.
|
||||||
|
- [ ] `Run now` shows pending state and disables duplicate clicks.
|
||||||
|
- [ ] Successful manual run refreshes status/history and clears backoff display.
|
||||||
|
- [ ] Failed manual run renders a safe error.
|
||||||
|
- [ ] Chart window selector requests samples without changing sampler settings.
|
||||||
|
- [ ] Stale warning appears while last-known speed data remains visible.
|
||||||
|
- [ ] Run history renders pagination, trigger, status, duration, timestamp, and error details.
|
||||||
|
- [ ] Empty history and no-data states are readable on mobile.
|
||||||
|
|
||||||
|
## Integration / lifespan tests
|
||||||
|
|
||||||
|
- [ ] Starting the FastAPI lifespan starts the scheduler exactly once.
|
||||||
|
- [ ] Repeated `start()` calls do not create duplicate workers.
|
||||||
|
- [ ] Lifespan shutdown stops the scheduler and does not leak a thread.
|
||||||
|
- [ ] A test app can override the scheduler/action registry cleanly.
|
||||||
|
- [ ] Existing mail queue and backup poller lifecycle behavior remains unchanged.
|
||||||
|
|
||||||
|
## Manual QA scenarios
|
||||||
|
|
||||||
|
### Headless collection
|
||||||
|
|
||||||
|
1. Configure an enabled qBittorrent service.
|
||||||
|
2. Start the backend without opening the frontend.
|
||||||
|
3. Wait for at least two intervals.
|
||||||
|
4. Query scheduler status and samples directly.
|
||||||
|
5. Confirm samples and successful run records exist.
|
||||||
|
|
||||||
|
### Duplicate prevention
|
||||||
|
|
||||||
|
1. Open the speed widget in multiple browser tabs.
|
||||||
|
2. Compare sample count growth to scheduler run count.
|
||||||
|
3. Confirm browser refreshes do not add samples or external qBittorrent calls.
|
||||||
|
|
||||||
|
### Outage and recovery
|
||||||
|
|
||||||
|
1. Make qBittorrent unreachable.
|
||||||
|
2. Confirm failures and increasing backoff appear in status/history.
|
||||||
|
3. Confirm last-known samples remain visible with a stale warning.
|
||||||
|
4. Restore qBittorrent.
|
||||||
|
5. Confirm the next successful scheduled or manual run clears backoff and stale state.
|
||||||
|
|
||||||
|
### Configuration reload
|
||||||
|
|
||||||
|
1. Change the interval and retention in the qBittorrent service editor.
|
||||||
|
2. Confirm the existing worker remains alive.
|
||||||
|
3. Confirm the new effective values appear after reconciliation.
|
||||||
|
4. Confirm pruning follows the new retention/cap.
|
||||||
|
|
||||||
|
### Service lifecycle
|
||||||
|
|
||||||
|
1. Disable a service and confirm no new runs are created while history remains.
|
||||||
|
2. Re-enable it and confirm polling resumes.
|
||||||
|
3. Delete it and confirm service-owned samples and run records are removed.
|
||||||
|
|
||||||
|
### Deployment constraint
|
||||||
|
|
||||||
|
1. Run the documented single-worker deployment.
|
||||||
|
2. Confirm one scheduler worker is active.
|
||||||
|
3. Verify the deployment documentation warns against multiple scheduler-capable replicas.
|
||||||
|
|
||||||
|
## Release gate
|
||||||
|
|
||||||
|
- [ ] Backend test suite passes.
|
||||||
|
- [ ] Frontend tests pass.
|
||||||
|
- [ ] Frontend lint passes with no new violations.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
|
- [ ] No secret appears in logs, API responses, metrics, or scheduler run records.
|
||||||
|
- [ ] Project maps are patched for new files and validated.
|
||||||
|
- [ ] Requirements and runbook documentation match the shipped behavior.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Typed Scheduler and qBittorrent Polling Implementation Plan
|
||||||
|
|
||||||
|
> This plan implements the approved design in `docs/superpowers/specs/2026-07-14-scheduled-actions-design.md`.
|
||||||
|
|
||||||
|
**Goal:** Move qBittorrent speed collection into a backend-owned typed scheduler while adding per-service controls, run history, stale-data status, and a manual run action.
|
||||||
|
|
||||||
|
**Scope:** qBittorrent speed polling only. The scheduler registry is extensible, but arbitrary widgets, SSH tasks, and other integrations remain out of scope.
|
||||||
|
|
||||||
|
## Delivery slices
|
||||||
|
|
||||||
|
### Slice 1 — Contracts and persistence
|
||||||
|
|
||||||
|
- [ ] Add validated qBittorrent config fields to `backend/src/media_library_viewer_api/integrations/qbittorrent.py`:
|
||||||
|
- `polling_enabled` default `true`;
|
||||||
|
- `poll_interval_seconds` default `15`, range `5..300`;
|
||||||
|
- `sample_retention_seconds` default `1800`, range `60..86400`;
|
||||||
|
- `sample_max_rows` default `1200`, range `60..1200`.
|
||||||
|
- [ ] Add migration/default normalization tests for existing qBittorrent records.
|
||||||
|
- [ ] Extend `QbittorrentSampleStore` to prune by retention timestamp and capped row count.
|
||||||
|
- [ ] Add a generic scheduler storage concern with run records, indexes, pruning, and cascade deletion by service ID.
|
||||||
|
- [ ] Add typed backend models for scheduler status, run records, samples, and manual-run responses.
|
||||||
|
|
||||||
|
**Acceptance:** Existing service records validate without edits; qBittorrent samples remain isolated by service; scheduler records cannot contain secrets; deletion removes service-owned samples and runs.
|
||||||
|
|
||||||
|
### Slice 2 — Typed scheduler core
|
||||||
|
|
||||||
|
- [ ] Create a scheduler action protocol and registry.
|
||||||
|
- [ ] Implement a single-worker, lifespan-managed scheduler coordinator with responsive stop behavior.
|
||||||
|
- [ ] Add qBittorrent speed sampling as the first registered action.
|
||||||
|
- [ ] Extract external polling from `QbittorrentWidgetSource` into a reusable sampler/action helper.
|
||||||
|
- [ ] Implement immediate startup execution with deterministic staggering.
|
||||||
|
- [ ] Implement fixed-delay, no-overlap execution and bounded exponential backoff.
|
||||||
|
- [ ] Reconcile enabled services/config changes on each cycle.
|
||||||
|
- [ ] Add safe structured logs and Prometheus metrics.
|
||||||
|
- [ ] Start/stop the scheduler in `main.py` alongside the existing mail queue and backup poller.
|
||||||
|
|
||||||
|
**Acceptance:** With no frontend open, enabled qBittorrent services append samples; one slow service cannot create overlapping runs or a backlog; shutdown joins the worker; a successful manual or scheduled run resets backoff.
|
||||||
|
|
||||||
|
### Slice 3 — Read-only APIs
|
||||||
|
|
||||||
|
- [ ] Add `backend/src/media_library_viewer_api/routers/scheduler.py`.
|
||||||
|
- [ ] Add status, paginated runs, manual-run, and read-only samples endpoints.
|
||||||
|
- [ ] Keep service configuration writes on the existing service-instance API.
|
||||||
|
- [ ] Change the qBittorrent speed widget adapter to read samples only.
|
||||||
|
- [ ] Add stale-data calculation and safe error truncation.
|
||||||
|
- [ ] Add API tests for disabled/missing services, stale data, pagination, manual runs, backoff, and authentication.
|
||||||
|
|
||||||
|
**Acceptance:** Opening or refreshing a speed widget never contacts qBittorrent and never appends a sample; API responses expose timestamps and status but no credentials.
|
||||||
|
|
||||||
|
### Slice 4 — Frontend controls and history
|
||||||
|
|
||||||
|
- [ ] Add scheduler TypeScript types, API functions, and React Query hooks.
|
||||||
|
- [ ] Add qBittorrent schedule controls to the existing schema-driven service editor.
|
||||||
|
- [ ] Add status/backoff/stale-data presentation and a `Run now` action.
|
||||||
|
- [ ] Add user-selectable chart windows.
|
||||||
|
- [ ] Add a paginated run-history table with safe error details.
|
||||||
|
- [ ] Keep UI refreshes separate from sampler cadence.
|
||||||
|
- [ ] Add frontend tests for validation, disabled state, stale warning, manual-run reset, chart-window selection, and run-history rendering.
|
||||||
|
|
||||||
|
**Acceptance:** Operators can configure, inspect, and manually trigger qBittorrent polling from the service surface without opening the dashboard; the chart remains useful during outages and identifies stale data.
|
||||||
|
|
||||||
|
### Slice 5 — Documentation and operational verification
|
||||||
|
|
||||||
|
- [ ] Update `docs/REQUIREMENTS.md` with scheduler requirements and the one-worker constraint.
|
||||||
|
- [ ] Update deployment/runbook documentation with scheduler startup, shutdown, and replica guidance.
|
||||||
|
- [ ] Add migration/recovery notes for sample and run-history retention.
|
||||||
|
- [ ] Run backend tests, frontend tests, lint, and build.
|
||||||
|
- [ ] Verify a headless collection scenario against a mocked qBittorrent service.
|
||||||
|
- [ ] Verify project-map artifacts after files are added.
|
||||||
|
|
||||||
|
## Suggested file map
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `backend/src/media_library_viewer_api/integrations/qbittorrent.py` | Schedule config schema and defaults |
|
||||||
|
| `backend/src/media_library_viewer_api/services/qbittorrent_store.py` | Duration/cap pruning and sample queries |
|
||||||
|
| `backend/src/media_library_viewer_api/services/service_data.py` | Register scheduler run concern |
|
||||||
|
| `backend/src/media_library_viewer_api/services/scheduler.py` | Worker lifecycle, reconciliation, timing, backoff |
|
||||||
|
| `backend/src/media_library_viewer_api/services/scheduler_actions.py` | Typed registry and qBittorrent action |
|
||||||
|
| `backend/src/media_library_viewer_api/services/scheduler_store.py` | Run-record persistence and pruning |
|
||||||
|
| `backend/src/media_library_viewer_api/models/scheduler.py` | Response/request models |
|
||||||
|
| `backend/src/media_library_viewer_api/routers/scheduler.py` | Status, history, samples, manual-run API |
|
||||||
|
| `backend/src/media_library_viewer_api/widgets/sources.py` | Make qBittorrent speed reads side-effect free |
|
||||||
|
| `backend/src/media_library_viewer_api/main.py` | Start/stop scheduler |
|
||||||
|
| `backend/tests/test_scheduler.py` | Scheduler lifecycle/timing/backoff tests |
|
||||||
|
| `backend/tests/test_scheduler_api.py` | Endpoint and auth tests |
|
||||||
|
| `backend/tests/test_service_data.py` | Migration/cascade coverage |
|
||||||
|
| `backend/tests/test_widgets.py` | Read-only qBittorrent widget coverage |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `frontend/src/types/scheduler.ts` | Scheduler status/run/sample types |
|
||||||
|
| `frontend/src/api/scheduler.ts` | Typed endpoint wrappers |
|
||||||
|
| `frontend/src/hooks/useScheduler.ts` | Queries and manual-run mutation |
|
||||||
|
| `frontend/src/pages/ServicesPage.tsx` | qBittorrent schedule controls/status surface |
|
||||||
|
| `frontend/src/pages/ServicePage.tsx` or qBittorrent service tab | Status, chart window, history surface |
|
||||||
|
| `frontend/src/widgets/QbittorrentSpeedWidget.tsx` | Read-only sample window and stale warning |
|
||||||
|
| `frontend/src/integrations/registry.ts` | Schedule metadata/config exposure if needed |
|
||||||
|
| `frontend/src/types/index.ts` | Shared exports |
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
- **Duplicate polling:** widget adapter becomes read-only; only scheduler action calls qBittorrent.
|
||||||
|
- **Multiple backend workers:** document and log the one-worker constraint; do not silently duplicate work.
|
||||||
|
- **Unbounded storage:** prune by both duration and row cap; test pruning under rapid polling.
|
||||||
|
- **Credential leakage:** reuse existing secret resolution and sanitize run errors/log fields.
|
||||||
|
- **Scheduler shutdown races:** use a stop event, per-action lock, and bounded joins; test lifespan shutdown.
|
||||||
|
- **Config changes during a run:** let the current run finish, then reconcile on the next cycle.
|
||||||
|
- **Stale but useful data:** return samples plus explicit stale status rather than blanking the chart.
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && PYTHONPATH=src pytest
|
||||||
|
cd frontend && npm test
|
||||||
|
cd frontend && npm run lint
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not begin implementation until the final module names, retry cap/jitter, and chart-window response shape are confirmed during the implementation pass.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Typed Scheduled Actions and qBittorrent Polling Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-14
|
||||||
|
**Status:** Proposed
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Manage currently collects qBittorrent speed samples as a side effect of a browser polling the widget-data endpoint. This design moves collection into a backend-owned typed scheduler so samples continue when no page is open, while keeping widget reads read-only.
|
||||||
|
|
||||||
|
The first scheduled action is qBittorrent speed polling. The scheduler is intentionally extensible but does not execute arbitrary widgets, SSH commands, or user-provided code.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Collect qBittorrent download/upload speed independently of browser presence.
|
||||||
|
- Configure polling per qBittorrent service instance.
|
||||||
|
- Preserve per-service SQLite isolation and existing cascade-delete behavior.
|
||||||
|
- Provide current status, stale-data state, run history, and a manual `Run now` action.
|
||||||
|
- Reuse the existing lifespan worker pattern and remain safe under the single-backend-worker deployment model.
|
||||||
|
- Expose metrics and structured logs without adding a new notification channel.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Distributed scheduling across replicas.
|
||||||
|
- External worker infrastructure or a task queue.
|
||||||
|
- Scheduling arbitrary saved SSH tasks.
|
||||||
|
- Moving every widget-backed integration to the scheduler in this release.
|
||||||
|
- A global scheduler administration page.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Area | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Architecture | Typed action registry with qBittorrent as the first action |
|
||||||
|
| Worker model | One lifespan-managed backend worker; deployment must run one scheduler-capable backend process |
|
||||||
|
| Activation | Enabled qBittorrent services poll by default; polling is opt-out |
|
||||||
|
| Defaults | 15-second interval; valid range 5–300 seconds |
|
||||||
|
| Samples | 30-minute default history; valid range 1–24 hours; configurable lower sample cap with a hard maximum of 1,200 rows |
|
||||||
|
| Timing | Immediate first run with per-service startup staggering; fixed delay after completion |
|
||||||
|
| Concurrency | No overlapping runs and no queued missed ticks per service |
|
||||||
|
| Failure | Keep enabled, record failure, retry with bounded exponential backoff |
|
||||||
|
| Manual run | Supported; successful manual run clears backoff |
|
||||||
|
| Widget data | Read-only; scheduler is the only qBittorrent sampler |
|
||||||
|
| Run history | Dedicated generic scheduled-action run records; retain 30 days or 1,000 runs per service/action |
|
||||||
|
| Service lifecycle | Disable pauses and retains history; delete purges service-owned data through cascade deletion |
|
||||||
|
| UI | Controls and status/history live with each qBittorrent service |
|
||||||
|
| Alerts | UI and metrics only in this release |
|
||||||
|
|
||||||
|
## Current and target flow
|
||||||
|
|
||||||
|
### Current
|
||||||
|
|
||||||
|
```text
|
||||||
|
React Query interval
|
||||||
|
-> GET /api/widgets/instances/{id}/data
|
||||||
|
-> QbittorrentWidgetSource.fetch()
|
||||||
|
-> qBittorrent API
|
||||||
|
-> append speed sample
|
||||||
|
-> return chart data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
```text
|
||||||
|
Backend lifespan
|
||||||
|
-> TypedScheduler
|
||||||
|
-> registered QbittorrentSpeedAction
|
||||||
|
-> qBittorrent API
|
||||||
|
-> QbittorrentSampleStore
|
||||||
|
-> SchedulerRunStore
|
||||||
|
|
||||||
|
React Query / service UI
|
||||||
|
-> scheduler status/history/sample endpoints
|
||||||
|
-> read-only SQLite queries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration model
|
||||||
|
|
||||||
|
The existing qBittorrent service config gains validated non-secret fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "https://qbit.example",
|
||||||
|
"timeout_seconds": 60,
|
||||||
|
"polling_enabled": true,
|
||||||
|
"poll_interval_seconds": 15,
|
||||||
|
"sample_retention_seconds": 1800,
|
||||||
|
"sample_max_rows": 1200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
- `polling_enabled`: boolean, default `true` for backward compatibility.
|
||||||
|
- `poll_interval_seconds`: integer from 5 through 300, default 15.
|
||||||
|
- `sample_retention_seconds`: integer from 60 through 86,400, default 1,800.
|
||||||
|
- `sample_max_rows`: integer from 60 through 1,200, default 1,200. The upper bound is a server safety limit, not merely a UI hint.
|
||||||
|
- Secrets remain exclusively in the existing encrypted secret fields.
|
||||||
|
|
||||||
|
The service type metadata must expose these fields so the existing schema-driven service editor renders them. Existing qBittorrent records receive defaults through normalization rather than a destructive migration.
|
||||||
|
|
||||||
|
## Scheduler architecture
|
||||||
|
|
||||||
|
### Registry and contracts
|
||||||
|
|
||||||
|
Add a small scheduler service with explicit action registration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ScheduledAction(Protocol):
|
||||||
|
action_key: str
|
||||||
|
async def run(self, service: ServiceRecord, context: ActionContext) -> ActionResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The registry maps an action key to its implementation and metadata. The first key is `qbittorrent.speed_sample`. The scheduler never evaluates arbitrary config as executable code.
|
||||||
|
|
||||||
|
A scheduler cycle should:
|
||||||
|
|
||||||
|
1. Read enabled service records.
|
||||||
|
2. Select services whose typed action is enabled.
|
||||||
|
3. Reconcile changed interval/enabled settings.
|
||||||
|
4. Run due actions serially per service.
|
||||||
|
5. Persist a run record and update in-memory status.
|
||||||
|
6. Wait using a stop event so shutdown is responsive.
|
||||||
|
|
||||||
|
A thread-based coordinator is appropriate for the first release because `BackupAlertPoller` already establishes the project’s lifespan-managed worker pattern and qBittorrent’s client is blocking. The action may use the existing authenticated client cache, but the sampler should be extracted from the widget adapter so collection and presentation are not coupled.
|
||||||
|
|
||||||
|
### Timing and backoff
|
||||||
|
|
||||||
|
- First eligible service run starts immediately after startup, with a small deterministic stagger based on service ordering.
|
||||||
|
- Normal scheduling uses fixed delay: the next due time is calculated after the previous attempt completes.
|
||||||
|
- A per-service action lock prevents overlap.
|
||||||
|
- A failed run uses bounded exponential backoff, capped below the configured interval’s operational maximum. Backoff must not enqueue missed runs.
|
||||||
|
- A successful scheduled or manual run resets consecutive failures and clears `backoff_until`.
|
||||||
|
- Config changes are observed during the next reconciliation cycle; an interval change affects the next due calculation.
|
||||||
|
- Disabling a service prevents new work and allows the current run to finish before the action becomes idle.
|
||||||
|
|
||||||
|
### Single-worker constraint
|
||||||
|
|
||||||
|
The initial design assumes one backend process owns scheduler execution. Running multiple Uvicorn workers or replicas would duplicate polls and run records. Startup logs and operational documentation must make this constraint explicit. A future distributed lease can be added without changing the action contract.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### Speed samples
|
||||||
|
|
||||||
|
Extend `QbittorrentSampleStore` to prune by both:
|
||||||
|
|
||||||
|
- `service_id` and `ts >= now - sample_retention_seconds`;
|
||||||
|
- most recent `sample_max_rows`, bounded by 1,200.
|
||||||
|
|
||||||
|
The existing `qbittorrent_speed_samples` table remains the source for chart data. Its API should accept a requested display window and return ordered samples. Deleting a service must continue to cascade into this concern.
|
||||||
|
|
||||||
|
### Scheduled-action runs
|
||||||
|
|
||||||
|
Add a generic scheduler storage concern, separate from `service_task_runs`, with fields equivalent to:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Run identifier |
|
||||||
|
| `service_id` | Owning service instance |
|
||||||
|
| `action_key` | Registered action key, e.g. `qbittorrent.speed_sample` |
|
||||||
|
| `trigger` | `schedule` or `manual` |
|
||||||
|
| `started_at` / `finished_at` | Attempt timing |
|
||||||
|
| `status` | `running`, `success`, `failure`, `backoff`, or `cancelled` |
|
||||||
|
| `duration_ms` | Elapsed time |
|
||||||
|
| `attempt` | Retry/backoff attempt number |
|
||||||
|
| `error` | Secret-safe error text, truncated |
|
||||||
|
| `created_at` | Record creation time |
|
||||||
|
|
||||||
|
Indexes should cover `(service_id, action_key, started_at DESC)` and `(status, started_at DESC)`. Prune records older than 30 days and enforce a maximum of 1,000 records per service/action.
|
||||||
|
|
||||||
|
No credentials, request headers, or raw qBittorrent payloads may be stored in run history.
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
Add a dedicated scheduler router. Exact response models should be typed and should not expose secrets.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/scheduler/services/{service_id}/status` | Current action state, last attempt/success, stale state, failures, backoff, and effective config |
|
||||||
|
| `GET /api/scheduler/services/{service_id}/runs` | Paginated run history with status/trigger filters |
|
||||||
|
| `POST /api/scheduler/services/{service_id}/run` | Run the registered qBittorrent action immediately; return a run/status response |
|
||||||
|
| `GET /api/scheduler/services/{service_id}/samples` | Read-only speed samples for a selected display window |
|
||||||
|
|
||||||
|
The existing `PUT /api/services/instances/{id}` remains the write path for schedule configuration. The widget-data endpoint must stop calling qBittorrent for the `speed` kind; it should read samples through the same store/query helper used by the scheduler API.
|
||||||
|
|
||||||
|
Manual runs must use the same action registry and persistence path as scheduled runs. A successful manual run clears backoff; a failed manual run records the failure and applies the same bounded retry state.
|
||||||
|
|
||||||
|
## Stale-data semantics
|
||||||
|
|
||||||
|
The status response should include `last_success_at`, `last_error`, `consecutive_failures`, `backoff_until`, and `is_stale`. Suggested initial stale rule:
|
||||||
|
|
||||||
|
```text
|
||||||
|
is_stale = no successful run
|
||||||
|
OR now - last_success_at > max(2 * effective_interval, 60 seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
The speed widget should retain and render the last known samples with a warning containing the last-success time and current error. It should not replace useful history with an empty state solely because the latest poll failed.
|
||||||
|
|
||||||
|
## Frontend design
|
||||||
|
|
||||||
|
The existing schema-driven qBittorrent service editor should gain a scheduling section containing:
|
||||||
|
|
||||||
|
- polling enabled switch;
|
||||||
|
- interval field with bounds/error text;
|
||||||
|
- sample retention duration;
|
||||||
|
- maximum sample rows;
|
||||||
|
- effective next-run and last-success summary;
|
||||||
|
- `Run now` button;
|
||||||
|
- current failure/backoff message.
|
||||||
|
|
||||||
|
A qBittorrent service detail/editor surface should also contain:
|
||||||
|
|
||||||
|
- stale-data banner;
|
||||||
|
- user-selectable chart windows (for example 5m, 30m, 1h, all retained);
|
||||||
|
- speed chart sourced from read-only sample data;
|
||||||
|
- paginated run-history table with trigger, status, duration, timestamp, and safe error detail;
|
||||||
|
- loading, empty, disabled, and failed states.
|
||||||
|
|
||||||
|
Add typed API functions, React Query hooks, and types under the existing `frontend/src/api`, `frontend/src/hooks`, and `frontend/src/types` patterns. Poll status/history at a slower UI cadence than the sampler; the UI must not drive collection.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Add secret-safe metrics using bounded labels:
|
||||||
|
|
||||||
|
- scheduled action runs total by action and status;
|
||||||
|
- scheduled action duration by action;
|
||||||
|
- last successful run timestamp by action/service;
|
||||||
|
- current consecutive failures or stale state by action/service.
|
||||||
|
|
||||||
|
Avoid labels containing URLs, usernames, API keys, raw errors, or unbounded widget IDs. Structured logs should include service ID, action key, trigger, status, duration, and request ID where available.
|
||||||
|
|
||||||
|
## Security and operational constraints
|
||||||
|
|
||||||
|
- Only registered action keys can execute.
|
||||||
|
- Service credentials are loaded through existing decryption helpers and are never returned or persisted in run records.
|
||||||
|
- Manual-run endpoints use existing JWT/API authentication.
|
||||||
|
- The scheduler must stop cleanly during lifespan shutdown and should not leave a new thread running after tests finish.
|
||||||
|
- The deployment documentation must state the one-worker scheduler constraint.
|
||||||
|
|
||||||
|
## Open implementation details
|
||||||
|
|
||||||
|
- Choose final module names and whether scheduler run storage belongs in a new service-data concern or a dedicated settings-store table.
|
||||||
|
- Define exact retry cap and jitter values.
|
||||||
|
- Decide whether the scheduler status endpoint returns one action or a list of registered actions.
|
||||||
|
- Finalize chart window/downsampling behavior for the 24-hour/1,200-row maximum.
|
||||||
+20
-10
@@ -5,6 +5,7 @@ import {
|
|||||||
NavLink,
|
NavLink,
|
||||||
useLocation,
|
useLocation,
|
||||||
Outlet,
|
Outlet,
|
||||||
|
Navigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
@@ -19,7 +20,6 @@ import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
|||||||
import { Settings } from "./pages/Settings";
|
import { Settings } from "./pages/Settings";
|
||||||
import { ServicePage } from "./pages/ServicePage";
|
import { ServicePage } from "./pages/ServicePage";
|
||||||
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||||
import { ServicesPage } from "./pages/ServicesPage";
|
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
@@ -27,7 +27,10 @@ import { usePersistentState } from "./hooks/usePersistentState";
|
|||||||
import { useIsMobile } from "./hooks/useIsMobile";
|
import { useIsMobile } from "./hooks/useIsMobile";
|
||||||
import { useServiceInstances } from "./hooks/useServices";
|
import { useServiceInstances } from "./hooks/useServices";
|
||||||
import { useDashboards } from "./hooks/useDashboards";
|
import { useDashboards } from "./hooks/useDashboards";
|
||||||
import { configuredNavEntries } from "./integrations/navEntries";
|
import {
|
||||||
|
configuredNavEntries,
|
||||||
|
remoteMachineNavEntries,
|
||||||
|
} from "./integrations/navEntries";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -51,7 +54,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Boxes,
|
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -98,10 +100,13 @@ function useNavItems() {
|
|||||||
const configuredTypes = new Set(
|
const configuredTypes = new Set(
|
||||||
services.filter((s) => s.enabled).map((s) => s.service_type),
|
services.filter((s) => s.enabled).map((s) => s.service_type),
|
||||||
);
|
);
|
||||||
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
|
const serviceEntries = [
|
||||||
path: e.path,
|
...configuredNavEntries(configuredTypes),
|
||||||
label: e.label,
|
...remoteMachineNavEntries(services),
|
||||||
icon: e.icon,
|
].map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
label: entry.label,
|
||||||
|
icon: entry.icon,
|
||||||
}));
|
}));
|
||||||
const dashboardEntries = dashboards.map((d) => ({
|
const dashboardEntries = dashboards.map((d) => ({
|
||||||
path: `/d/${d.slug}`,
|
path: `/d/${d.slug}`,
|
||||||
@@ -112,7 +117,6 @@ function useNavItems() {
|
|||||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
...dashboardEntries,
|
...dashboardEntries,
|
||||||
...serviceEntries,
|
...serviceEntries,
|
||||||
{ path: "/services", label: "Services", icon: Boxes },
|
|
||||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||||
];
|
];
|
||||||
}, [services, dashboards]);
|
}, [services, dashboards]);
|
||||||
@@ -469,7 +473,10 @@ function AppInner() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route
|
||||||
|
path="/services"
|
||||||
|
element={<Navigate to="/settings?tab=services" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/services/:serviceType"
|
path="/services/:serviceType"
|
||||||
element={<ServiceTypePage />}
|
element={<ServiceTypePage />}
|
||||||
@@ -497,7 +504,10 @@ function AppInner() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route
|
||||||
|
path="/services"
|
||||||
|
element={<Navigate to="/settings?tab=services" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/services/:serviceType"
|
path="/services/:serviceType"
|
||||||
element={<ServiceTypePage />}
|
element={<ServiceTypePage />}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend/src/api
|
dir: frontend/src/api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend API client layer that centralizes all HTTP communication with backend services using typed, token-authenticated request functions.
|
Frontend API client layer that centralizes typed HTTP requests to backend services and external integrations.
|
||||||
## parent
|
## parent
|
||||||
index: frontend/src/.pi-map.index.md
|
index: frontend/src/.pi-map.index.md
|
||||||
map: frontend/src/.pi-map.md
|
map: frontend/src/.pi-map.md
|
||||||
@@ -13,6 +13,7 @@ map: frontend/src/.pi-map.md
|
|||||||
- backups.ts
|
- backups.ts
|
||||||
- client.ts
|
- client.ts
|
||||||
- dashboards.ts
|
- dashboards.ts
|
||||||
|
- jellyseerr.ts
|
||||||
- services.ts
|
- services.ts
|
||||||
- shared.ts
|
- shared.ts
|
||||||
- widgets.ts
|
- widgets.ts
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ dir: frontend/src/api
|
|||||||
index: frontend/src/api/.pi-map.index.md
|
index: frontend/src/api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend API client layer that centralizes all HTTP communication with backend services using typed, token-authenticated request functions.
|
Frontend API client layer that centralizes typed HTTP requests to backend services and external integrations.
|
||||||
## files
|
## files
|
||||||
- authentik.ts | API client providing functions to fetch users, send messages, and check message status from the Authentik service. | exp: AuthentikUser, AuthentikUsersResponse, AuthentikMessageInput, AuthentikMessageResponse, func:fetchAuthentikUsers(serviceId: string, params: { search?: string; page?: number; page_size?: number }) → Promise<AuthentikUsersResponse>, call:get, call:String, func:sendAuthentikMessage(serviceId: string, input: AuthentikMessageInput) → Promise<AuthentikMessageResponse>, call:post, func:fetchAuthentikMessageStatus(serviceId: string) → Promise<Record<string, unknown>>, call:get | dep: ./shared
|
- authentik.ts | API client providing functions to fetch users, send messages, and check message status from the Authentik service. | exp: AuthentikUser, AuthentikUsersResponse, AuthentikMessageInput, AuthentikMessageResponse, func:fetchAuthentikUsers(serviceId: string, params: { search?: string; page?: number; page_size?: number }) → Promise<AuthentikUsersResponse>, call:get, call:String, func:sendAuthentikMessage(serviceId: string, input: AuthentikMessageInput) → Promise<AuthentikMessageResponse>, call:post, func:fetchAuthentikMessageStatus(serviceId: string) → Promise<Record<string, unknown>>, call:get | dep: ./shared
|
||||||
- backups.ts | API client functions for fetching and managing backup jobs, runs, alerts, and dashboard summaries. | exp: func:fetchBackupJobs() → Promise<BackupJob[]>, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string) → Promise<BackupRun[]>, call:get, func:fetchBackupRun(runId: string) → Promise<BackupRun>, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string) → Promise<BackupAlert[]>, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise<BackupAlert>, call:post, func:fetchBackupDashboard() → Promise<BackupDashboardSummary>, call:get | dep: ./shared, ../types/backups
|
- backups.ts | API client functions for fetching and managing backup jobs, runs, alerts, and dashboard summaries. | exp: func:fetchBackupJobs(serviceId: string) → Promise<BackupJob[]>, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string, serviceId: string) → Promise<BackupRun[]>, call:get, func:fetchBackupRun(runId: string) → Promise<BackupRun>, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string, serviceId: string) → Promise<BackupAlert[]>, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise<BackupAlert>, call:post, func:fetchBackupDashboard() → Promise<BackupDashboardSummary>, call:get | dep: ./shared, ../types/backups
|
||||||
- client.ts | Typed API client module that provides frontend functions for interacting with a FastAPI backend across dashboard, monitoring, media, files, jobs, and observability endpoints. | exp: fetchCounts, fetchLibraries, fetchActivity, fetchUsers, fetchNowPlaying, fetchMonitoringMachines, fetchAppVersion, fetchDashboardShortcuts, saveDashboardShortcut, deleteDashboardShortcut, fetchMonitoringSettings, fetchSSHKeys, generateSSHKey, saveSSHKey, deleteSSHKey, fetchSavedTasks, fetchSavedTaskRuns, saveTask, deleteTask, runTask, saveMonitoringMachine, testMonitoringMachineSSH, deleteMonitoringMachine, resetLocalDatabase, fetchMediaStatus, buildMediaIndex, stopMediaIndexBuild, forceStopMediaIndexBuild, queryMedia, fetchDirectoryListing, fetchFfprobe, fetchStat, resolvePath, fetchJobTemplates, runJob, fetchUserMessageQueueStatus, sendUserMessage, fetchAlertmanagerAlerts, fetchAlertmanagerStatus, fetchPrometheusStatus, fetchPrometheusTargets | dep: ../types, ./shared, fetch API
|
- client.ts | Typed API client providing functions for interacting with a FastAPI backend across dashboard, monitoring, media, files, jobs, and observability endpoints. | exp: fetchCounts, fetchLibraries, fetchActivity, fetchUsers, fetchNowPlaying, fetchMonitoringMachines, fetchAppVersion, fetchDashboardShortcuts, saveDashboardShortcut, deleteDashboardShortcut, fetchMonitoringSettings, fetchSSHKeys, generateSSHKey, saveSSHKey, deleteSSHKey, fetchSavedTasks, fetchSavedTaskRuns, saveTask, deleteTask, runTask, saveMonitoringMachine, testMonitoringMachineSSH, deleteMonitoringMachine, resetLocalDatabase, fetchMediaStatus, buildMediaIndex, stopMediaIndexBuild, forceStopMediaIndexBuild, queryMedia, fetchDirectoryListing, fetchFfprobe, fetchStat, resolvePath, fetchJobTemplates, runJob, fetchUserMessageQueueStatus, sendUserMessage, fetchAlertmanagerAlerts, fetchAlertmanagerStatus, fetchPrometheusStatus, fetchPrometheusTargets | dep: ../types, ./shared
|
||||||
- dashboards.ts | API client providing CRUD operations for named dashboards via REST endpoints. | exp: NamedDashboard, NamedDashboardInput, func:fetchDashboards() → Promise<NamedDashboard[]>, call:get, func:fetchDashboardBySlug(slug: string) → Promise<NamedDashboard>, call:get, call:encodeURIComponent, func:createDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, call:post, func:updateDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, call:put, func:deleteDashboard(id: string) → Promise<{ status: string }>, call:del | dep: ./shared
|
- dashboards.ts | API client providing CRUD operations for named dashboards via REST endpoints. | exp: NamedDashboard, NamedDashboardInput, func:fetchDashboards() → Promise<NamedDashboard[]>, call:get, func:fetchDashboardBySlug(slug: string) → Promise<NamedDashboard>, call:get, call:encodeURIComponent, func:createDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, call:post, func:updateDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, call:put, func:deleteDashboard(id: string) → Promise<{ status: string }>, call:del | dep: ./shared
|
||||||
- services.ts | Provides API service functions for CRUD operations on service instances and fetching service types. | exp: func:fetchServiceTypes() → Promise<ServiceTypeInfo[]>, call:get, func:fetchServiceInstances(serviceType: string) → Promise<ServiceInstance[]>, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del | dep: ./shared, ../types
|
- jellyseerr.ts | This file provides API client functions to fetch Jellyseerr request statistics and request lists for a Jellyfin service instance. | exp: JellyseerStat, JellyseerRecentRequest, JellyseerStatsResponse, JellyseerRequest, func:fetchJellyseerrStats(jellyfinServiceId: string) → Promise<JellyseerStatsResponse>, call:get, func:fetchJellyseerrRequests(jellyfinServiceId: string) → Promise<JellyseerRequest[]> | dep: ./shared
|
||||||
|
- services.ts | API client functions for CRUD operations and testing of service instances. | exp: func:fetchServiceTypes() → Promise<ServiceTypeInfo[]>, call:get, func:fetchServiceInstances(serviceType: string) → Promise<ServiceInstance[]>, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del, func:testServiceInstance(input: ServiceInstanceInput) → Promise<ServiceTestResult>, call:post | dep: ./shared, ../types
|
||||||
- shared.ts | Provides shared API helper functions (GET, POST, PUT, DELETE, etc.) that automatically attach OIDC auth tokens and handle URL building and error parsing for backend requests. | exp: API_BASE, func:buildUrl(path: string, params: Record<string, string>) → string, call:isAbsoluteUrl, call:Object.entries, call:url.searchParams.set, call:url.toString, func:readErrorDetail(response: Response) → Promise<string>, call:response.text, call:JSON.parse, call:detail.trim, func:buildHeaders(isJsonBody: boolean) → Headers, call:getAccessToken, call:headers.set, func:get(path: string, params: Record<string, string>) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:post(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:postForm(path: string, body: FormData) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:put(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:del(path: string) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error | dep: ../auth, getAccessToken (from ../auth), fetch API, Headers API, URL API, import.meta.env
|
- shared.ts | Provides shared API helper functions (GET, POST, PUT, DELETE, etc.) that automatically attach OIDC auth tokens and handle URL building and error parsing for backend requests. | exp: API_BASE, func:buildUrl(path: string, params: Record<string, string>) → string, call:isAbsoluteUrl, call:Object.entries, call:url.searchParams.set, call:url.toString, func:readErrorDetail(response: Response) → Promise<string>, call:response.text, call:JSON.parse, call:detail.trim, func:buildHeaders(isJsonBody: boolean) → Headers, call:getAccessToken, call:headers.set, func:get(path: string, params: Record<string, string>) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:post(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:postForm(path: string, body: FormData) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:put(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:del(path: string) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error | dep: ../auth, getAccessToken (from ../auth), fetch API, Headers API, URL API, import.meta.env
|
||||||
- widgets.ts | API client module providing CRUD operations for widget instances, widget references, builtin widget kinds, and widget data retrieval. | exp: WidgetReference, WidgetReferenceInput, func:fetchBuiltinWidgetKinds() → Promise< BuiltinWidgetKindInfo[] >, call:get, func:fetchWidgetInstances(serviceId: string, scope: "dashboard" | "service") → Promise<WidgetInstance[]>, call:get, func:createWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:post, func:updateWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:put, raise:Error, func:deleteWidgetInstance(widgetId: string) → Promise<{ status: string }>, call:del, func:fetchWidgetData(widgetId: string) → Promise<WidgetDataResponse>, call:get, func:fetchWidgetReferences(dashboardScope: string) → Promise<WidgetReference[]>, call:get, func:createWidgetReference(input: WidgetReferenceInput) → Promise<WidgetReference>, call:post, func:deleteWidgetReference(referenceId: string) → Promise<{ status: string }>, call:del, func:detachWidgetReference(referenceId: string) → Promise<WidgetInstance>, call:post, func:updateWidgetReference(referenceId: string, sortOrder: number) → Promise<WidgetReference>, call:put | dep: ./shared, ../types
|
- widgets.ts | API client module providing CRUD operations for widget instances, widget references, builtin widget kinds, and widget data retrieval. | exp: WidgetReference, WidgetReferenceInput, func:fetchBuiltinWidgetKinds() → Promise< BuiltinWidgetKindInfo[] >, call:get, func:fetchWidgetInstances(serviceId: string, scope: "dashboard" | "service") → Promise<WidgetInstance[]>, call:get, func:createWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:post, func:updateWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:put, raise:Error, func:deleteWidgetInstance(widgetId: string) → Promise<{ status: string }>, call:del, func:fetchWidgetData(widgetId: string) → Promise<WidgetDataResponse>, call:get, func:fetchWidgetReferences(dashboardScope: string) → Promise<WidgetReference[]>, call:get, func:createWidgetReference(input: WidgetReferenceInput) → Promise<WidgetReference>, call:post, func:deleteWidgetReference(referenceId: string) → Promise<{ status: string }>, call:del, func:detachWidgetReference(referenceId: string) → Promise<WidgetInstance>, call:post, func:updateWidgetReference(referenceId: string, sortOrder: number) → Promise<WidgetReference>, call:put | dep: ./shared, ../types
|
||||||
## arch
|
## arch
|
||||||
Modular API client pattern with a shared base client (`shared.ts`) handling authentication and error parsing, while domain-specific modules (authentik, backups, dashboards, services, widgets, client) expose typed CRUD and fetch operations per service area.
|
Modular API client pattern with a shared helper module for authentication, URL building, and error handling, alongside domain-specific client files organized by service area.
|
||||||
## tags
|
## tags
|
||||||
fetch, call:get, widget, dashboard, call:build, authentik, delete, backup
|
fetch, call:get, widget, dashboard, call:build, authentik, delete, call:post
|
||||||
## symbols
|
## symbols
|
||||||
- fetchAuthentikUsers
|
- fetchAuthentikUsers
|
||||||
- sendAuthentikMessage
|
- sendAuthentikMessage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** API client for the Authentik service (directory + messaging). */
|
/** API client for Authentik directory, access metadata, and messaging. */
|
||||||
import { get, post } from "./shared";
|
import { get, post } from "./shared";
|
||||||
|
|
||||||
export interface AuthentikUser {
|
export interface AuthentikUser {
|
||||||
@@ -19,6 +19,49 @@ export interface AuthentikUsersResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthentikGroupReference {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
known: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthentikAccessSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
|
is_staff: boolean;
|
||||||
|
groups: AuthentikGroupReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthentikAccessSummaryResponse {
|
||||||
|
items: AuthentikAccessSummary[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthentikGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthentikApplication {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
launch_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthentikCollectionResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAuthentikUsers(
|
export async function fetchAuthentikUsers(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
params: { search?: string; page?: number; page_size?: number },
|
params: { search?: string; page?: number; page_size?: number },
|
||||||
@@ -33,6 +76,40 @@ export async function fetchAuthentikUsers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthentikAccessSummary(
|
||||||
|
serviceId: string,
|
||||||
|
params: { search?: string; page?: number; page_size?: number },
|
||||||
|
): Promise<AuthentikAccessSummaryResponse> {
|
||||||
|
return get<AuthentikAccessSummaryResponse>(
|
||||||
|
`/api/services/authentik/${serviceId}/access-summary`,
|
||||||
|
{
|
||||||
|
search: params.search ?? "",
|
||||||
|
page: String(params.page ?? 1),
|
||||||
|
page_size: String(params.page_size ?? 50),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthentikGroups(
|
||||||
|
serviceId: string,
|
||||||
|
limit = 100,
|
||||||
|
): Promise<AuthentikCollectionResponse<AuthentikGroup>> {
|
||||||
|
return get<AuthentikCollectionResponse<AuthentikGroup>>(
|
||||||
|
`/api/services/authentik/${serviceId}/groups`,
|
||||||
|
{ limit: String(limit) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthentikApplications(
|
||||||
|
serviceId: string,
|
||||||
|
limit = 100,
|
||||||
|
): Promise<AuthentikCollectionResponse<AuthentikApplication>> {
|
||||||
|
return get<AuthentikCollectionResponse<AuthentikApplication>>(
|
||||||
|
`/api/services/authentik/${serviceId}/applications`,
|
||||||
|
{ limit: String(limit) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthentikMessageInput {
|
export interface AuthentikMessageInput {
|
||||||
recipient_emails: string[];
|
recipient_emails: string[];
|
||||||
subject: string;
|
subject: string;
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import type {
|
|||||||
BackupRun,
|
BackupRun,
|
||||||
} from "../types/backups";
|
} from "../types/backups";
|
||||||
|
|
||||||
export async function fetchBackupJobs(): Promise<BackupJob[]> {
|
export async function fetchBackupJobs(
|
||||||
return get<BackupJob[]>("/api/backups/jobs");
|
serviceId?: string,
|
||||||
|
): Promise<BackupJob[]> {
|
||||||
|
return get<BackupJob[]>(
|
||||||
|
"/api/backups/jobs",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBackupJob(
|
export async function fetchBackupJob(
|
||||||
@@ -21,10 +26,12 @@ export async function fetchBackupJob(
|
|||||||
export async function fetchBackupRuns(
|
export async function fetchBackupRuns(
|
||||||
jobId?: string,
|
jobId?: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
|
serviceId?: string,
|
||||||
): Promise<BackupRun[]> {
|
): Promise<BackupRun[]> {
|
||||||
return get<BackupRun[]>("/api/backups/runs", {
|
return get<BackupRun[]>("/api/backups/runs", {
|
||||||
...(jobId ? { job_id: jobId } : {}),
|
...(jobId ? { job_id: jobId } : {}),
|
||||||
...(status ? { status } : {}),
|
...(status ? { status } : {}),
|
||||||
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +43,7 @@ export async function fetchBackupAlerts(
|
|||||||
jobId?: string,
|
jobId?: string,
|
||||||
acknowledged?: boolean,
|
acknowledged?: boolean,
|
||||||
severity?: string,
|
severity?: string,
|
||||||
|
serviceId?: string,
|
||||||
): Promise<BackupAlert[]> {
|
): Promise<BackupAlert[]> {
|
||||||
return get<BackupAlert[]>("/api/backups/alerts", {
|
return get<BackupAlert[]>("/api/backups/alerts", {
|
||||||
...(jobId ? { job_id: jobId } : {}),
|
...(jobId ? { job_id: jobId } : {}),
|
||||||
@@ -43,6 +51,7 @@ export async function fetchBackupAlerts(
|
|||||||
? { acknowledged: String(acknowledged) }
|
? { acknowledged: String(acknowledged) }
|
||||||
: {}),
|
: {}),
|
||||||
...(severity ? { severity } : {}),
|
...(severity ? { severity } : {}),
|
||||||
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+232
-253
@@ -3,303 +3,282 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MediaCounts,
|
MediaCounts,
|
||||||
LibraryCount,
|
LibraryCount,
|
||||||
UserDirectoryResponse,
|
UserDirectoryResponse,
|
||||||
UserMessageResponse,
|
UserMessageResponse,
|
||||||
UserMessageQueueStatus,
|
UserMessageQueueStatus,
|
||||||
NowPlayingSession,
|
NowPlayingSession,
|
||||||
AppVersionInfo,
|
AppVersionInfo,
|
||||||
SSHKey,
|
SSHKey,
|
||||||
SSHKeyInput,
|
SSHKeyInput,
|
||||||
SSHKeyGenerated,
|
SSHKeyGenerated,
|
||||||
SavedTask,
|
SavedTask,
|
||||||
SavedTaskInput,
|
SavedTaskInput,
|
||||||
SavedTaskRun,
|
SavedTaskRun,
|
||||||
MonitoringMachine,
|
MediaIndexStatus,
|
||||||
MonitoringMachineInput,
|
MediaIndexActionResponse,
|
||||||
MediaIndexStatus,
|
MediaQueryResponse,
|
||||||
MediaIndexActionResponse,
|
DirectoryListing,
|
||||||
MediaQueryResponse,
|
JobTemplate,
|
||||||
DirectoryListing,
|
JobResult,
|
||||||
JobTemplate,
|
ResolvedPath,
|
||||||
JobResult,
|
ResetLocalDatabaseInput,
|
||||||
ResolvedPath,
|
ResetLocalDatabaseResponse,
|
||||||
ResetLocalDatabaseInput,
|
DashboardShortcut,
|
||||||
ResetLocalDatabaseResponse,
|
DashboardShortcutInput,
|
||||||
SSHValidationResult,
|
AlertmanagerAlertSummary,
|
||||||
DashboardShortcut,
|
AlertmanagerStatus,
|
||||||
DashboardShortcutInput,
|
PrometheusStatus,
|
||||||
AlertmanagerAlertSummary,
|
|
||||||
AlertmanagerStatus,
|
|
||||||
PrometheusStatus,
|
|
||||||
PrometheusTarget,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
buildHeaders,
|
buildHeaders,
|
||||||
buildUrl,
|
buildUrl,
|
||||||
del,
|
del,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
postForm,
|
postForm,
|
||||||
readErrorDetail,
|
readErrorDetail,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
||||||
export const fetchCounts = (jellyfinServiceId?: string) =>
|
export const fetchCounts = (jellyfinServiceId?: string) =>
|
||||||
get<MediaCounts>(
|
get<MediaCounts>(
|
||||||
"/api/dashboard/counts",
|
"/api/dashboard/counts",
|
||||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
||||||
get<LibraryCount[]>(
|
get<LibraryCount[]>(
|
||||||
"/api/dashboard/libraries",
|
"/api/dashboard/libraries",
|
||||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchActivity = (jellyfinServiceId?: string) =>
|
export const fetchActivity = (jellyfinServiceId?: string) =>
|
||||||
get<NowPlayingSession[]>(
|
get<NowPlayingSession[]>(
|
||||||
"/api/dashboard/activity",
|
"/api/dashboard/activity",
|
||||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchUsers = (jellyfinServiceId?: string) =>
|
export const fetchUsers = (jellyfinServiceId?: string) =>
|
||||||
get<UserDirectoryResponse>(
|
get<UserDirectoryResponse>(
|
||||||
"/api/users",
|
"/api/users",
|
||||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backward-compatible alias used by older hooks/components.
|
// Backward-compatible alias used by older hooks/components.
|
||||||
export const fetchNowPlaying = fetchActivity;
|
export const fetchNowPlaying = fetchActivity;
|
||||||
|
|
||||||
// Monitoring
|
// General
|
||||||
export const fetchMonitoringMachines = () =>
|
|
||||||
get<MonitoringMachine[]>("/api/monitoring/machines");
|
|
||||||
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
|
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
|
||||||
export const fetchDashboardShortcuts = () =>
|
export const fetchDashboardShortcuts = () =>
|
||||||
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
||||||
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
|
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
|
||||||
fetch(
|
fetch(
|
||||||
buildUrl(
|
buildUrl(
|
||||||
shortcut.id
|
shortcut.id
|
||||||
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
|
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
|
||||||
: "/api/dashboard/shortcuts",
|
: "/api/dashboard/shortcuts",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: shortcut.id ? "PUT" : "POST",
|
method: shortcut.id ? "PUT" : "POST",
|
||||||
headers: buildHeaders(true),
|
headers: buildHeaders(true),
|
||||||
body: JSON.stringify(shortcut),
|
body: JSON.stringify(shortcut),
|
||||||
},
|
},
|
||||||
).then(async (response) => {
|
).then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<DashboardShortcut>;
|
return response.json() as Promise<DashboardShortcut>;
|
||||||
});
|
});
|
||||||
export const deleteDashboardShortcut = (shortcutId: string) =>
|
export const deleteDashboardShortcut = (shortcutId: string) =>
|
||||||
del<{ status: string }>(
|
del<{ status: string }>(
|
||||||
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
||||||
);
|
);
|
||||||
export const fetchMonitoringSettings = () =>
|
|
||||||
get<MonitoringMachine[]>("/api/settings/machines");
|
|
||||||
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
|
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
|
||||||
export const generateSSHKey = (payload: {
|
export const generateSSHKey = (payload: {
|
||||||
name: string;
|
name: string;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
bits?: number;
|
bits?: number;
|
||||||
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
|
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
|
||||||
export const saveSSHKey = (key: SSHKeyInput) =>
|
export const saveSSHKey = (key: SSHKeyInput) =>
|
||||||
fetch(
|
fetch(
|
||||||
buildUrl(
|
buildUrl(
|
||||||
key.id
|
key.id
|
||||||
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
|
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
|
||||||
: "/api/settings/ssh-keys",
|
: "/api/settings/ssh-keys",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: key.id ? "PUT" : "POST",
|
method: key.id ? "PUT" : "POST",
|
||||||
headers: buildHeaders(true),
|
headers: buildHeaders(true),
|
||||||
body: JSON.stringify(key),
|
body: JSON.stringify(key),
|
||||||
},
|
},
|
||||||
).then(async (response) => {
|
).then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<SSHKey>;
|
return response.json() as Promise<SSHKey>;
|
||||||
});
|
});
|
||||||
export const deleteSSHKey = (keyId: string) =>
|
export const deleteSSHKey = (keyId: string) =>
|
||||||
del<{ status: string }>(
|
del<{ status: string }>(
|
||||||
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
|
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchSavedTasks = () => get<SavedTask[]>("/api/tasks");
|
export const fetchSavedTasks = (serviceId: string) =>
|
||||||
export const fetchSavedTaskRuns = (taskId: string, limit = 10) =>
|
get<SavedTask[]>("/api/tasks", { service_id: serviceId });
|
||||||
get<{ items: SavedTaskRun[]; total: number }>(
|
export const fetchSavedTaskRuns = (
|
||||||
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
taskId: string,
|
||||||
{
|
serviceId: string,
|
||||||
limit: String(limit),
|
limit = 10,
|
||||||
},
|
) =>
|
||||||
);
|
get<{ items: SavedTaskRun[]; total: number }>(
|
||||||
|
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
||||||
|
{
|
||||||
|
service_id: serviceId,
|
||||||
|
limit: String(limit),
|
||||||
|
},
|
||||||
|
);
|
||||||
export const saveTask = (task: SavedTaskInput) =>
|
export const saveTask = (task: SavedTaskInput) =>
|
||||||
fetch(
|
fetch(
|
||||||
buildUrl(
|
buildUrl(
|
||||||
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
|
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: task.id ? "PUT" : "POST",
|
method: task.id ? "PUT" : "POST",
|
||||||
headers: buildHeaders(true),
|
headers: buildHeaders(true),
|
||||||
body: JSON.stringify(task),
|
body: JSON.stringify(task),
|
||||||
},
|
},
|
||||||
).then(async (response) => {
|
).then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<SavedTask>;
|
return response.json() as Promise<SavedTask>;
|
||||||
});
|
});
|
||||||
export const deleteTask = (taskId: string) =>
|
export const deleteTask = (taskId: string, serviceId: string) =>
|
||||||
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
|
del<{ status: string }>(
|
||||||
export const runTask = (taskId: string, serviceId?: string) =>
|
`/api/tasks/${encodeURIComponent(taskId)}?service_id=${encodeURIComponent(serviceId)}`,
|
||||||
post<{
|
);
|
||||||
task_id: string;
|
export const runTask = (taskId: string, serviceId: string) =>
|
||||||
task_name: string;
|
post<{
|
||||||
service_id: string;
|
task_id: string;
|
||||||
service_name: string;
|
task_name: string;
|
||||||
task_type: string;
|
service_id: string;
|
||||||
exit_status: number;
|
service_name: string;
|
||||||
stdout: string;
|
task_type: string;
|
||||||
stderr: string;
|
exit_status: number;
|
||||||
}>(
|
stdout: string;
|
||||||
serviceId
|
stderr: string;
|
||||||
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
|
}>(`/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`, {
|
||||||
: "/api/tasks/run",
|
task_id: taskId,
|
||||||
{ task_id: taskId },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
|
|
||||||
fetch(
|
|
||||||
buildUrl(
|
|
||||||
machine.id
|
|
||||||
? `/api/settings/machines/${encodeURIComponent(machine.id)}`
|
|
||||||
: "/api/settings/machines",
|
|
||||||
),
|
|
||||||
{
|
|
||||||
method: machine.id ? "PUT" : "POST",
|
|
||||||
headers: buildHeaders(true),
|
|
||||||
body: JSON.stringify(machine),
|
|
||||||
},
|
|
||||||
).then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<MonitoringMachine>;
|
|
||||||
});
|
|
||||||
export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) =>
|
|
||||||
post<SSHValidationResult>("/api/settings/machines/test-ssh", machine);
|
|
||||||
export const deleteMonitoringMachine = (machineId: string) =>
|
|
||||||
del<{ status: string }>(
|
|
||||||
`/api/settings/machines/${encodeURIComponent(machineId)}`,
|
|
||||||
);
|
|
||||||
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
||||||
post<ResetLocalDatabaseResponse>(
|
post<ResetLocalDatabaseResponse>(
|
||||||
"/api/settings/reset-local-database",
|
"/api/settings/reset-local-database",
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
||||||
get<MediaIndexStatus>(
|
get<MediaIndexStatus>(
|
||||||
"/api/media/status",
|
"/api/media/status",
|
||||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
jellyfinServiceId
|
jellyfinServiceId
|
||||||
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/build",
|
: "/api/media/build",
|
||||||
);
|
);
|
||||||
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
jellyfinServiceId
|
jellyfinServiceId
|
||||||
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/stop",
|
: "/api/media/stop",
|
||||||
);
|
);
|
||||||
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
jellyfinServiceId
|
jellyfinServiceId
|
||||||
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/force-stop",
|
: "/api/media/force-stop",
|
||||||
);
|
);
|
||||||
export const queryMedia = (params: {
|
export const queryMedia = (params: {
|
||||||
libraries?: string;
|
libraries?: string;
|
||||||
types?: string;
|
types?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
hdr_filter?: string;
|
hdr_filter?: string;
|
||||||
sort_key?: string;
|
sort_key?: string;
|
||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
jellyfinServiceId?: string;
|
jellyfinServiceId?: string;
|
||||||
}) =>
|
}) =>
|
||||||
get<MediaQueryResponse>("/api/media/query", {
|
get<MediaQueryResponse>("/api/media/query", {
|
||||||
libraries: params.libraries || "",
|
libraries: params.libraries || "",
|
||||||
types: params.types || "Movie,Episode",
|
types: params.types || "Movie,Episode",
|
||||||
search: params.search || "",
|
search: params.search || "",
|
||||||
hdr_filter: params.hdr_filter || "All",
|
hdr_filter: params.hdr_filter || "All",
|
||||||
sort_key: params.sort_key || "title",
|
sort_key: params.sort_key || "title",
|
||||||
sort_order: params.sort_order || "Ascending",
|
sort_order: params.sort_order || "Ascending",
|
||||||
limit: String(params.limit || 100),
|
limit: String(params.limit || 100),
|
||||||
offset: String(params.offset || 0),
|
offset: String(params.offset || 0),
|
||||||
...(params.jellyfinServiceId
|
...(params.jellyfinServiceId
|
||||||
? { jellyfin_service_id: params.jellyfinServiceId }
|
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
export const fetchDirectoryListing = (path: string, machineId?: string) =>
|
export const fetchDirectoryListing = (path: string, serviceId?: string) =>
|
||||||
get<DirectoryListing>("/api/files/list", {
|
get<DirectoryListing>("/api/files/list", {
|
||||||
path,
|
path,
|
||||||
...(machineId ? { machine_id: machineId } : {}),
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
export const fetchFfprobe = (path: string, machineId?: string) =>
|
export const fetchFfprobe = (path: string, serviceId?: string) =>
|
||||||
get<Record<string, unknown>>("/api/files/ffprobe", {
|
get<Record<string, unknown>>("/api/files/ffprobe", {
|
||||||
path,
|
path,
|
||||||
...(machineId ? { machine_id: machineId } : {}),
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
export const fetchStat = (path: string, machineId?: string) =>
|
export const fetchStat = (path: string, serviceId?: string) =>
|
||||||
get<{ path: string; output: string }>("/api/files/stat", {
|
get<{ path: string; output: string }>("/api/files/stat", {
|
||||||
path,
|
path,
|
||||||
...(machineId ? { machine_id: machineId } : {}),
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
export const resolvePath = (path: string, machineId?: string) =>
|
export const resolvePath = (path: string, serviceId?: string) =>
|
||||||
get<ResolvedPath>("/api/files/resolve-path", {
|
get<ResolvedPath>("/api/files/resolve-path", {
|
||||||
path,
|
path,
|
||||||
...(machineId ? { machine_id: machineId } : {}),
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
export const fetchJobTemplates = () =>
|
export const fetchJobTemplates = () =>
|
||||||
get<JobTemplate[]>("/api/jobs/templates");
|
get<JobTemplate[]>("/api/jobs/templates");
|
||||||
export const runJob = (jobKey: string, path: string, machineId?: string) =>
|
export const runJob = (jobKey: string, path: string, serviceId?: string) =>
|
||||||
post<JobResult>(
|
post<JobResult>(
|
||||||
machineId
|
serviceId
|
||||||
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/jobs/run?service_id=${encodeURIComponent(serviceId)}`
|
||||||
: "/api/jobs/run",
|
: "/api/jobs/run",
|
||||||
{ job_key: jobKey, path },
|
{ job_key: jobKey, path },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchUserMessageQueueStatus = () =>
|
export const fetchUserMessageQueueStatus = () =>
|
||||||
get<UserMessageQueueStatus>("/api/users/message/status");
|
get<UserMessageQueueStatus>("/api/users/message/status");
|
||||||
|
|
||||||
export const sendUserMessage = (formData: FormData) =>
|
export const sendUserMessage = (formData: FormData) =>
|
||||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||||
|
|
||||||
// Observability summary endpoints
|
// Observability summary endpoints
|
||||||
export const fetchAlertmanagerAlerts = () =>
|
export const fetchAlertmanagerAlerts = (serviceId?: string) =>
|
||||||
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
|
get<AlertmanagerAlertSummary>(
|
||||||
|
"/api/monitoring/alerts",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchAlertmanagerStatus = () =>
|
export const fetchAlertmanagerStatus = (serviceId?: string) =>
|
||||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
get<AlertmanagerStatus>(
|
||||||
|
"/api/monitoring/alertmanager-status",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchPrometheusStatus = () =>
|
export const fetchPrometheusStatus = (serviceId?: string) =>
|
||||||
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
get<PrometheusStatus>(
|
||||||
|
"/api/monitoring/prometheus-status",
|
||||||
export const fetchPrometheusTargets = () =>
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { get } from "./shared";
|
||||||
|
|
||||||
|
export interface JellyseerStat {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyseerRecentRequest {
|
||||||
|
id?: number | string;
|
||||||
|
type?: number | string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
media_status?: string;
|
||||||
|
created_at?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyseerStatsResponse {
|
||||||
|
stats: JellyseerStat[];
|
||||||
|
recent: JellyseerRecentRequest[];
|
||||||
|
detail?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch Jellyseerr request stats for a Jellyfin service instance. */
|
||||||
|
export async function fetchJellyseerrStats(
|
||||||
|
jellyfinServiceId?: string,
|
||||||
|
): Promise<JellyseerStatsResponse> {
|
||||||
|
return get<JellyseerStatsResponse>(
|
||||||
|
"/api/jellyseerr/stats",
|
||||||
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyseerRequest {
|
||||||
|
id?: number | string;
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
media_status?: string;
|
||||||
|
created_at?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch Jellyseerr requests (all, mapped) for the Requests tab table. */
|
||||||
|
export async function fetchJellyseerrRequests(
|
||||||
|
jellyfinServiceId?: string,
|
||||||
|
): Promise<JellyseerRequest[]> {
|
||||||
|
const res = await get<{ requests: JellyseerRequest[]}>(
|
||||||
|
"/api/jellyseerr/requests",
|
||||||
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
|
);
|
||||||
|
return res.requests ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { get, post } from "./shared";
|
||||||
|
import type {
|
||||||
|
SchedulerManualRunResponse,
|
||||||
|
SchedulerRunsResponse,
|
||||||
|
SchedulerSamplesResponse,
|
||||||
|
SchedulerStatus,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function fetchSchedulerStatus(
|
||||||
|
serviceId: string,
|
||||||
|
): Promise<SchedulerStatus> {
|
||||||
|
return get<SchedulerStatus>(`/api/scheduler/services/${serviceId}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSchedulerRuns(
|
||||||
|
serviceId: string,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<SchedulerRunsResponse> {
|
||||||
|
return get<SchedulerRunsResponse>(
|
||||||
|
`/api/scheduler/services/${serviceId}/runs`,
|
||||||
|
{ limit: String(limit) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSchedulerSamples(
|
||||||
|
serviceId: string,
|
||||||
|
window: number | "all",
|
||||||
|
): Promise<SchedulerSamplesResponse> {
|
||||||
|
return get<SchedulerSamplesResponse>(
|
||||||
|
`/api/scheduler/services/${serviceId}/samples`,
|
||||||
|
window === "all"
|
||||||
|
? { all_values: "true" }
|
||||||
|
: { window_seconds: String(window) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSchedulerAction(
|
||||||
|
serviceId: string,
|
||||||
|
): Promise<SchedulerManualRunResponse> {
|
||||||
|
return post<SchedulerManualRunResponse>(
|
||||||
|
`/api/scheduler/services/${serviceId}/run`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { del, get, post, put } from "./shared";
|
|||||||
import type {
|
import type {
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
ServiceInstanceInput,
|
ServiceInstanceInput,
|
||||||
|
ServiceTestResult,
|
||||||
ServiceTypeInfo,
|
ServiceTypeInfo,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
@@ -36,3 +37,9 @@ export async function deleteServiceInstance(
|
|||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
return del<{ status: string }>(`/api/services/instances/${serviceId}`);
|
return del<{ status: string }>(`/api/services/instances/${serviceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testServiceInstance(
|
||||||
|
input: ServiceInstanceInput,
|
||||||
|
): Promise<ServiceTestResult> {
|
||||||
|
return post<ServiceTestResult>("/api/services/test", input);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend/src/components
|
dir: frontend/src/components
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable UI component library providing presentation-layer building blocks (tables, cards, charts, dialogs, widgets) for the frontend application.
|
Shared UI component library providing reusable React components for tables, cards, charts, dialogs, and dashboard widgets across the frontend application.
|
||||||
## parent
|
## parent
|
||||||
index: frontend/src/.pi-map.index.md
|
index: frontend/src/.pi-map.index.md
|
||||||
map: frontend/src/.pi-map.md
|
map: frontend/src/.pi-map.md
|
||||||
@@ -21,6 +21,7 @@ map: frontend/src/.pi-map.md
|
|||||||
- ConfirmDialog.tsx
|
- ConfirmDialog.tsx
|
||||||
- DialogFooter.tsx
|
- DialogFooter.tsx
|
||||||
- HoverEditButton.tsx
|
- HoverEditButton.tsx
|
||||||
|
- JellyseerRequestsTable.tsx
|
||||||
- LibraryOverview.tsx
|
- LibraryOverview.tsx
|
||||||
- LineSeriesChart.tsx
|
- LineSeriesChart.tsx
|
||||||
- MetricCard.tsx
|
- MetricCard.tsx
|
||||||
@@ -28,6 +29,7 @@ map: frontend/src/.pi-map.md
|
|||||||
- PinnedServiceLink.tsx
|
- PinnedServiceLink.tsx
|
||||||
- SectionCard.tsx
|
- SectionCard.tsx
|
||||||
- SelectionRailCard.tsx
|
- SelectionRailCard.tsx
|
||||||
|
- ServiceTestPanel.tsx
|
||||||
- SessionActivityPanel.tsx
|
- SessionActivityPanel.tsx
|
||||||
- TabbedCard.tsx
|
- TabbedCard.tsx
|
||||||
- WidgetConfigDialog.tsx
|
- WidgetConfigDialog.tsx
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ dir: frontend/src/components
|
|||||||
index: frontend/src/components/.pi-map.index.md
|
index: frontend/src/components/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable UI component library providing presentation-layer building blocks (tables, cards, charts, dialogs, widgets) for the frontend application.
|
Shared UI component library providing reusable React components for tables, cards, charts, dialogs, and dashboard widgets across the frontend application.
|
||||||
## files
|
## files
|
||||||
- BackupAlertsTable.tsx | Renders a responsive table of backup alerts with severity badges and acknowledge actions, switching between desktop table and mobile card layouts. | exp: func:BackupAlertsTable({ alerts, onAcknowledge }: Props), call:useIsMobile, call:onAcknowledge, call:alerts.map, call:severityVariant, call:formatTimestamp | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, @/components/ui/mobile-card, ../hooks/useIsMobile, ../types/backups, useIsMobile hook, BackupAlert type
|
- BackupAlertsTable.tsx | Renders a responsive table of backup alerts with severity badges and acknowledge actions, switching between desktop table and mobile card layouts. | exp: func:BackupAlertsTable({ alerts, onAcknowledge }: Props), call:useIsMobile, call:onAcknowledge, call:alerts.map, call:severityVariant, call:formatTimestamp | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, @/components/ui/mobile-card, ../hooks/useIsMobile, ../types/backups, useIsMobile hook, BackupAlert type
|
||||||
- BackupDashboardWidget.tsx | Displays a dashboard widget summarizing backup job statistics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupDashboardWidget(), call:useBackupDashboard, call:new Date(data.last_failed_at * 1000).toLocaleString | dep: @/components/ui/badge, @/components/ui/card, ../hooks/useBackups
|
- BackupDashboardWidget.tsx | Displays a dashboard widget summarizing backup job statistics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupDashboardWidget(), call:useBackupDashboard, call:new Date(data.last_failed_at * 1000).toLocaleString | dep: @/components/ui/badge, @/components/ui/card, ../hooks/useBackups
|
||||||
@@ -13,21 +13,23 @@ Reusable UI component library providing presentation-layer building blocks (tabl
|
|||||||
- ConfirmDialog.tsx | Reusable confirmation dialog component that wraps shadcn/ui Dialog primitives with standardized cancel/confirm footer behavior. | exp: func:ConfirmDialog({ open, title, message, confirmLabel = "Delete", onCancel, onConfirm, busy, }: { open: boolean; title: string; message: string; confirmLabel?: string; onCancel: () => void; onConfirm: () => void; busy?: boolean; }), call:onCancel | dep: @/components/ui/dialog, ./DialogFooter
|
- ConfirmDialog.tsx | Reusable confirmation dialog component that wraps shadcn/ui Dialog primitives with standardized cancel/confirm footer behavior. | exp: func:ConfirmDialog({ open, title, message, confirmLabel = "Delete", onCancel, onConfirm, busy, }: { open: boolean; title: string; message: string; confirmLabel?: string; onCancel: () => void; onConfirm: () => void; busy?: boolean; }), call:onCancel | dep: @/components/ui/dialog, ./DialogFooter
|
||||||
- DialogFooter.tsx | Renders a dialog footer component with cancel, optional secondary action, and confirm buttons, mapping legacy MUI color/variant props to shadcn Button variants. | exp: func:DialogFooter({ onCancel, cancelLabel = "Cancel", onConfirm, confirmLabel, confirmBusyLabel, confirmDisabled, confirmColor = "primary", confirmVariant = "contained", confirmStartIcon, secondaryAction, }: DialogFooterProps), call:resolveConfirmVariant | dep: react, @/components/ui/button
|
- DialogFooter.tsx | Renders a dialog footer component with cancel, optional secondary action, and confirm buttons, mapping legacy MUI color/variant props to shadcn Button variants. | exp: func:DialogFooter({ onCancel, cancelLabel = "Cancel", onConfirm, confirmLabel, confirmBusyLabel, confirmDisabled, confirmColor = "primary", confirmVariant = "contained", confirmStartIcon, secondaryAction, }: DialogFooterProps), call:resolveConfirmVariant | dep: react, @/components/ui/button
|
||||||
- HoverEditButton.tsx | Renders a hover-reveal edit button for desktop and always-visible edit button for mobile, preserving legacy CSS class hooks. | exp: func:HoverEditButton({ onClick, label = "Edit", mobile = "always", }: HoverEditButtonProps), call:e.stopPropagation, call:onClick | dep: lucide-react, @/components/ui/button
|
- HoverEditButton.tsx | Renders a hover-reveal edit button for desktop and always-visible edit button for mobile, preserving legacy CSS class hooks. | exp: func:HoverEditButton({ onClick, label = "Edit", mobile = "always", }: HoverEditButtonProps), call:e.stopPropagation, call:onClick | dep: lucide-react, @/components/ui/button
|
||||||
|
- JellyseerRequestsTable.tsx | Displays a sortable, filterable, and paginated table of Jellyseerr media requests fetched via a custom hook. | exp: func:JellyseerRequestsTable({ serviceId }: { serviceId: string }), call:useJellyseerRequests, call:useState, call:useMemo, call:search.trim().toLowerCase, call:requests.filter, call:OPEN_STATUSES.has, call:String(r.name ?? "") .toLowerCase() .includes, call:useReactTable, call:getCoreRowModel, call:getSortedRowModel, call:getPaginationRowModel, call:setSearch, call:setStatusFilter, call:table.getHeaderGroups().map, call:hg.headers.map, call:header.column.getToggleSortingHandler, call:flexRender, call:header.getContext, call:header.column.getIsSorted, call:table.getRowModel().rows.map, call:row.getVisibleCells().map, call:cell.getContext, call:table.getState, call:table.getPageCount | dep: react, @tanstack/react-table, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/input, @/components/ui/select, @/components/ui/skeleton, @/components/ui/table, @/components/ui/table-pagination, ../hooks/useJellyseer, ../api/jellyseerr, @/components/ui/* (alert, badge, input, select, skeleton, table, table-pagination)
|
||||||
- LibraryOverview.tsx | Renders a two-column responsive grid displaying movie and TV library counts using shadcn/ui Card components | exp: func:LibraryOverview({ libraries }: Props), call:libraries.filter, call:movieLibs.map, call:lib.total.toLocaleString, call:lib.movies.toLocaleString, call:tvLibs.map, call:lib.series.toLocaleString | dep: @/components/ui/card, ../types
|
- LibraryOverview.tsx | Renders a two-column responsive grid displaying movie and TV library counts using shadcn/ui Card components | exp: func:LibraryOverview({ libraries }: Props), call:libraries.filter, call:movieLibs.map, call:lib.total.toLocaleString, call:lib.movies.toLocaleString, call:tvLibs.map, call:lib.series.toLocaleString | dep: @/components/ui/card, ../types
|
||||||
- LineSeriesChart.tsx | Renders multiple time-series as a shared recharts line chart with merged data and formatted timestamps. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, }: LineSeriesChartProps), call:mergeSeries, call:formatTime, call:Number, call:series.map | dep: recharts
|
- LineSeriesChart.tsx | Renders multiple time-series as a responsive line chart with automatic metric scaling and formatting. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", }: LineSeriesChartProps), call:series.reduce, call:Math.abs, call:metricScaleInfo, call:formatScaled, call:mergeSeries, call:formatTime, call:Number, call:fmt, call:series.map | dep: recharts, ../lib/metricFormat, metricFormat
|
||||||
- MetricCard.tsx | Renders a compact metric display card with label, value, and optional subtext using Tailwind CSS styling. | exp: func:MetricCard({ label, value, subtext }: Props) | dep: @/components/ui/card
|
- MetricCard.tsx | Renders a compact metric display card with label, value, and optional subtext using Tailwind CSS styling. | exp: func:MetricCard({ label, value, subtext }: Props) | dep: @/components/ui/card
|
||||||
- NowPlaying.tsx | Renders a now-playing panel by wrapping SessionActivityPanel with a specific empty message for user activity sessions. | exp: func:NowPlaying({ sessions, onSelectSession }: Props) | dep: ../types, ./SessionActivityPanel
|
- NowPlaying.tsx | Renders a now-playing panel by wrapping SessionActivityPanel with a specific empty message for user activity sessions. | exp: func:NowPlaying({ sessions, onSelectSession }: Props) | dep: ../types, ./SessionActivityPanel
|
||||||
- PinnedServiceLink.tsx | Renders a navigable card-shaped button for pinned service shortcuts on dashboards and provides a helper to construct service target paths. | exp: PinnedServiceLinkProps, func:PinnedServiceLink({ label, target, icon: Icon = Boxes, className, }: PinnedServiceLinkProps), call:useNavigate, call:navigate, call:cn, func:serviceLinkTarget(serviceType: string, serviceId: string, tab: string) → string | dep: react-router-dom, lucide-react, @/lib/utils
|
- PinnedServiceLink.tsx | Renders a navigable card-shaped button for pinned service shortcuts on dashboards and provides a helper to construct service target paths. | exp: PinnedServiceLinkProps, func:PinnedServiceLink({ label, target, icon: Icon = Boxes, className, }: PinnedServiceLinkProps), call:useNavigate, call:navigate, call:cn, func:serviceLinkTarget(serviceType: string, serviceId: string, tab: string) → string | dep: react-router-dom, lucide-react, @/lib/utils
|
||||||
- SectionCard.tsx | A reusable card component that renders a titled section with optional description and action, built on shadcn/ui Card primitives for comfortable density layout. | exp: func:SectionCard({ title, description, action, children, }: SectionCardProps) | dep: react, @/components/ui/card
|
- SectionCard.tsx | A reusable card component that renders a titled section with optional description and action, built on shadcn/ui Card primitives for comfortable density layout. | exp: func:SectionCard({ title, description, action, children, }: SectionCardProps) | dep: react, @/components/ui/card
|
||||||
- SelectionRailCard.tsx | A reusable card component for a selection rail UI with titled header, scrollable body, and optional footer, preserving legacy API compatibility during a migration from MUI. | exp: func:SelectionRailCard({ title, description, children, footer, minHeight = 420, }: SelectionRailCardProps) | dep: react, @/components/ui/card
|
- SelectionRailCard.tsx | A reusable card component for a selection rail UI with titled header, scrollable body, and optional footer, preserving legacy API compatibility during a migration from MUI. | exp: func:SelectionRailCard({ title, description, children, footer, minHeight = 420, }: SelectionRailCardProps) | dep: react, @/components/ui/card
|
||||||
|
- ServiceTestPanel.tsx | Presentational component rendering a "Test credentials" panel with test button, result display, and a "Save anyway" checkbox. | exp: func:ServiceTestPanel({ result, isPending, saveAnyway, onTest, onSaveAnywayChange, disabled, }: Props), call:onSaveAnywayChange | dep: @/components/ui/alert, @/components/ui/button, ../types
|
||||||
- SessionActivityPanel.tsx | Renders a scrollable table displaying live media session activity details with status badges and optional session selection callbacks. | exp: func:SessionActivityPanel({ sessions, emptyMessage = "No live sessions matched to this user.", selectedUserLabel, onSelectSession, }: Props), call:buildStatusSummary, call:sessions.map, call:formatStateLabel, call:onSelectSession, call:sessionStateVariant, call:event.stopPropagation | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, ../types
|
- SessionActivityPanel.tsx | Renders a scrollable table displaying live media session activity details with status badges and optional session selection callbacks. | exp: func:SessionActivityPanel({ sessions, emptyMessage = "No live sessions matched to this user.", selectedUserLabel, onSelectSession, }: Props), call:buildStatusSummary, call:sessions.map, call:formatStateLabel, call:onSelectSession, call:sessionStateVariant, call:event.stopPropagation | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, ../types
|
||||||
- TabbedCard.tsx | Renders a card with a line-style tab bar header and content area, acting as a controlled wrapper around shadcn/ui Tabs for backward-compatible API migration from MUI. | exp: func:TabbedCard({ value, onChange, tabs, children, }: TabbedCardProps), call:onChange, call:String | dep: react, @/components/ui/card, @/components/ui/tabs
|
- TabbedCard.tsx | Renders a card with a line-style tab bar header and content area, acting as a controlled wrapper around shadcn/ui Tabs for backward-compatible API migration from MUI. | exp: func:TabbedCard({ value, onChange, tabs, children, }: TabbedCardProps), call:onChange, call:String | dep: react, @/components/ui/card, @/components/ui/tabs
|
||||||
- WidgetConfigDialog.tsx | Provides a user interface dialog and form for creating, configuring, editing, and managing the sort order of dashboard widgets and widget references. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:useMemo, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/*
|
- WidgetConfigDialog.tsx | This file provides a React dialog component for creating, editing, reordering, and deleting dashboard widgets, including managing references to existing widgets. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/*
|
||||||
- WidgetInstance.tsx | Renders a widget instance card that dynamically resolves and displays a widget component, with optional edit and copy actions. | exp: func:WidgetInstanceCard({ widget, onEdit, onCopy }: Props), call:useServiceInstances, call:resolveWidget, call:onCopy, call:onEdit | dep: @/components/ui/alert, @/components/ui/button, lucide-react, ../hooks/useServices, ../integrations/registry, ../types, ./SectionCard
|
- WidgetInstance.tsx | Renders a widget instance card that dynamically resolves and displays a widget component, with optional edit and copy actions. | exp: func:WidgetInstanceCard({ widget, onEdit, onCopy }: Props), call:useServiceInstances, call:resolveWidget, call:onCopy, call:onEdit | dep: @/components/ui/alert, @/components/ui/button, lucide-react, ../hooks/useServices, ../integrations/registry, ../types, ./SectionCard
|
||||||
## arch
|
## arch
|
||||||
React functional components with responsive design patterns, built on shadcn/ui and Tailwind CSS with adapter layers for backward-compatible MUI-to-shadcn migration.
|
Presentational React functional components built on shadcn/ui primitives with Tailwind CSS, employing responsive design patterns (desktop table/mobile card switching), controlled component patterns, and legacy API compatibility layers for MUI-to-shadcn migration.
|
||||||
## tags
|
## tags
|
||||||
call:use, components, ui, card, widget, table, backup, call:on
|
call:use, components, ui, card, table, widget, backup, call:on
|
||||||
## symbols
|
## symbols
|
||||||
- BackupAlertsTable
|
- BackupAlertsTable
|
||||||
- BackupDashboardWidget
|
- BackupDashboardWidget
|
||||||
@@ -36,7 +38,7 @@ call:use, components, ui, card, widget, table, backup, call:on
|
|||||||
- ConfirmDialog
|
- ConfirmDialog
|
||||||
- DialogFooter
|
- DialogFooter
|
||||||
- HoverEditButton
|
- HoverEditButton
|
||||||
- LibraryOverview
|
- JellyseerRequestsTable
|
||||||
## workflows
|
## workflows
|
||||||
- change components behavior
|
- change components behavior
|
||||||
read: BackupAlertsTable.tsx, BackupDashboardWidget.tsx, BackupJobsTable.tsx
|
read: BackupAlertsTable.tsx, BackupDashboardWidget.tsx, BackupJobsTable.tsx
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* JellyseerRequestsTable — sortable/filterable table of Jellyseerr requests.
|
||||||
|
*
|
||||||
|
* Uses TanStack Table directly (the shared DataTable is deliberately
|
||||||
|
* visibility-only). Defaults: status filter = "open" (pending/approved/
|
||||||
|
* processing), sorted by date added (newest first). Client-side sort + filter
|
||||||
|
* + pagination over the backend's fetched set.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
|
import { useJellyseerRequests } from "../hooks/useJellyseer";
|
||||||
|
import type { JellyseerRequest } from "../api/jellyseerr";
|
||||||
|
|
||||||
|
const OPEN_STATUSES = new Set(["pending", "approved", "processing"]);
|
||||||
|
type StatusFilter = "open" | "pending" | "approved";
|
||||||
|
|
||||||
|
function formatDate(v?: number | string): string {
|
||||||
|
if (!v) return "—";
|
||||||
|
const n = Number(v);
|
||||||
|
const ms = n > 1e12 ? n : n * 1000; // seconds -> ms
|
||||||
|
const d = new Date(ms);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? String(v)
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<JellyseerRequest>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="truncate font-medium">{row.original.name ?? "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="capitalize">{row.original.type ?? "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">{row.original.status ?? "—"}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "media_status",
|
||||||
|
header: "Media",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.media_status ? (
|
||||||
|
<Badge variant="outline">{row.original.media_status}</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: "Requested",
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) {
|
||||||
|
const {
|
||||||
|
data: requests = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useJellyseerRequests(serviceId);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "created_at", desc: true },
|
||||||
|
]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return requests.filter((r) => {
|
||||||
|
const status = String(r.status ?? "");
|
||||||
|
if (statusFilter === "open") {
|
||||||
|
if (!OPEN_STATUSES.has(status)) return false;
|
||||||
|
} else if (status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
q &&
|
||||||
|
!String(r.name ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [requests, statusFilter, search]);
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/incompatible-library -- TanStack's useReactTable
|
||||||
|
intentionally returns non-memoizable updater fns (controlled state). */
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filtered,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
initialState: { pagination: { pageSize: 10 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-48 w-full" />;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[180px] flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search requests…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((hg) => (
|
||||||
|
<TableRow key={hg.id} className="hover:bg-transparent">
|
||||||
|
{hg.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
{header.column.getIsSorted() === "asc" ? (
|
||||||
|
<ArrowUp className="size-3" />
|
||||||
|
) : header.column.getIsSorted() === "desc" ? (
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="size-3 opacity-40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={String(row.original.id ?? row.index)}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-16 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No requests.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
pageIndex={table.getState().pagination.pageIndex}
|
||||||
|
pageSize={table.getState().pagination.pageSize}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
totalRows={table.getRowModel().rows.length}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
onPaginationChange={table.setPagination}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHART_RANGES,
|
||||||
|
type ChartRangeOption,
|
||||||
|
type ChartRangeValue,
|
||||||
|
} from "./chartRanges";
|
||||||
|
import {
|
||||||
|
formatScaled,
|
||||||
|
metricScaleInfo,
|
||||||
|
type MetricScale,
|
||||||
|
type MetricUnit,
|
||||||
|
} from "../lib/metricFormat";
|
||||||
|
|
||||||
export interface SeriesPoint {
|
export interface SeriesPoint {
|
||||||
t: number;
|
t: number;
|
||||||
@@ -21,11 +40,11 @@ export interface ChartSeries {
|
|||||||
/** Merge multiple time-series into a single recharts-friendly array. */
|
/** Merge multiple time-series into a single recharts-friendly array. */
|
||||||
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
|
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
|
||||||
const map = new Map<number, Record<string, unknown>>();
|
const map = new Map<number, Record<string, unknown>>();
|
||||||
for (const s of series) {
|
for (const seriesItem of series) {
|
||||||
for (const p of s.points) {
|
for (const point of seriesItem.points) {
|
||||||
const existing = map.get(p.t) ?? { time: p.t };
|
const existing = map.get(point.t) ?? { time: point.t };
|
||||||
existing[s.label] = p.v;
|
existing[seriesItem.label] = point.v;
|
||||||
map.set(p.t, existing);
|
map.set(point.t, existing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...map.values()].sort(
|
return [...map.values()].sort(
|
||||||
@@ -51,45 +70,145 @@ const CHART_COLORS = [
|
|||||||
interface LineSeriesChartProps {
|
interface LineSeriesChartProps {
|
||||||
series: ChartSeries[];
|
series: ChartSeries[];
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** Display unit for the Y axis + tooltip (drives decimal-prefix scaling). */
|
||||||
|
unit?: MetricUnit;
|
||||||
|
/** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */
|
||||||
|
scale?: MetricScale;
|
||||||
|
/** Available displayed time ranges. Defaults to the shared range choices. */
|
||||||
|
rangeOptions?: readonly ChartRangeOption[];
|
||||||
|
/** Whether to render the interactive range selector. */
|
||||||
|
showRangeSelector?: boolean;
|
||||||
|
/** Initial uncontrolled range. Defaults to the largest numeric option. */
|
||||||
|
defaultRangeSeconds?: ChartRangeValue;
|
||||||
|
/** Controlled range for consumers that refetch when the selection changes. */
|
||||||
|
rangeSeconds?: ChartRangeValue;
|
||||||
|
onRangeChange?: (range: ChartRangeValue) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
|
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
||||||
export function LineSeriesChart({
|
export function LineSeriesChart({
|
||||||
series,
|
series,
|
||||||
height = 300,
|
height = 300,
|
||||||
|
unit = "none",
|
||||||
|
scale = "auto",
|
||||||
|
rangeOptions = DEFAULT_CHART_RANGES,
|
||||||
|
showRangeSelector = true,
|
||||||
|
defaultRangeSeconds,
|
||||||
|
rangeSeconds,
|
||||||
|
onRangeChange,
|
||||||
}: LineSeriesChartProps) {
|
}: LineSeriesChartProps) {
|
||||||
|
const initialRange =
|
||||||
|
defaultRangeSeconds ??
|
||||||
|
[...rangeOptions].reverse().find((range) => typeof range.value === "number")
|
||||||
|
?.value;
|
||||||
|
const [localRangeSeconds, setLocalRangeSeconds] = useState<
|
||||||
|
ChartRangeValue | undefined
|
||||||
|
>(initialRange);
|
||||||
|
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
||||||
|
const latestTimestamp = series.reduce(
|
||||||
|
(max, seriesItem) =>
|
||||||
|
seriesItem.points.reduce(
|
||||||
|
(seriesMax, point) => Math.max(seriesMax, point.t),
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const cutoff =
|
||||||
|
typeof selectedRangeSeconds === "number"
|
||||||
|
? latestTimestamp - selectedRangeSeconds * 1000
|
||||||
|
: null;
|
||||||
|
const visibleSeries =
|
||||||
|
cutoff !== null && latestTimestamp > 0
|
||||||
|
? series.map((seriesItem) => ({
|
||||||
|
...seriesItem,
|
||||||
|
points: seriesItem.points.filter((point) => point.t >= cutoff),
|
||||||
|
}))
|
||||||
|
: series;
|
||||||
|
|
||||||
|
const maxAbs = visibleSeries.reduce((max, seriesItem) => {
|
||||||
|
for (const point of seriesItem.points) {
|
||||||
|
const value = point.v == null ? 0 : Math.abs(point.v);
|
||||||
|
if (value > max) max = value;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, 0);
|
||||||
|
const scaleInfo = metricScaleInfo(maxAbs, unit, scale);
|
||||||
|
const formatValue = (value: number | null | undefined) =>
|
||||||
|
formatScaled(value, scaleInfo, unit);
|
||||||
|
|
||||||
|
function handleRangeChange(value: string) {
|
||||||
|
const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value);
|
||||||
|
setLocalRangeSeconds(nextRange);
|
||||||
|
onRangeChange?.(nextRange);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<div className="space-y-2">
|
||||||
<LineChart data={mergeSeries(series)}>
|
{showRangeSelector && rangeOptions.length > 0 && (
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<div className="flex justify-end">
|
||||||
<XAxis
|
<Select
|
||||||
dataKey="time"
|
value={
|
||||||
tickFormatter={formatTime}
|
selectedRangeSeconds === undefined
|
||||||
tick={{ fontSize: 11 }}
|
? undefined
|
||||||
className="fill-muted-foreground"
|
: String(selectedRangeSeconds)
|
||||||
/>
|
}
|
||||||
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
onValueChange={handleRangeChange}
|
||||||
<Tooltip
|
>
|
||||||
labelFormatter={(label) => formatTime(Number(label))}
|
<SelectTrigger
|
||||||
contentStyle={{
|
className="w-[150px]"
|
||||||
backgroundColor: "hsl(var(--popover))",
|
size="sm"
|
||||||
border: "1px solid hsl(var(--border))",
|
aria-label="Chart range"
|
||||||
borderRadius: "0.5rem",
|
>
|
||||||
color: "hsl(var(--popover-foreground))",
|
<SelectValue placeholder="Chart range" />
|
||||||
}}
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
{series.map((s, i) => (
|
{rangeOptions.map((range) => (
|
||||||
<Line
|
<SelectItem key={range.value} value={String(range.value)}>
|
||||||
key={s.label}
|
{range.label}
|
||||||
type="monotone"
|
</SelectItem>
|
||||||
dataKey={s.label}
|
))}
|
||||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
</SelectContent>
|
||||||
dot={false}
|
</Select>
|
||||||
strokeWidth={2}
|
</div>
|
||||||
connectNulls
|
)}
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={mergeSeries(visibleSeries)}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tickFormatter={formatTime}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
className="fill-muted-foreground"
|
||||||
/>
|
/>
|
||||||
))}
|
<YAxis
|
||||||
</LineChart>
|
tickFormatter={formatValue}
|
||||||
</ResponsiveContainer>
|
tick={{ fontSize: 11 }}
|
||||||
|
width={56}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(label) => formatTime(Number(label))}
|
||||||
|
formatter={(value) => formatValue(Number(value))}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--color-popover)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
color: "var(--color-popover-foreground)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{visibleSeries.map((seriesItem, index) => (
|
||||||
|
<Line
|
||||||
|
key={seriesItem.label}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={seriesItem.label}
|
||||||
|
stroke={CHART_COLORS[index % CHART_COLORS.length]}
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ServiceTestResult } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Current test result (null = not tested yet). Parent clears this when the form input changes. */
|
||||||
|
result: ServiceTestResult | null;
|
||||||
|
/** Whether the test mutation is in-flight. */
|
||||||
|
isPending: boolean;
|
||||||
|
/** Whether the "Save anyway" checkbox is checked. */
|
||||||
|
saveAnyway: boolean;
|
||||||
|
/** Fired when the user clicks "Test credentials". */
|
||||||
|
onTest: () => void;
|
||||||
|
/** Fired when the "Save anyway" checkbox toggles. */
|
||||||
|
onSaveAnywayChange: (checked: boolean) => void;
|
||||||
|
/** Disable the Test button (e.g. no draft yet). */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "Test credentials" panel used by both the add-service dialog and the
|
||||||
|
* edit-service panel. Purely presentational — the parent owns the test result
|
||||||
|
* + saveAnyway state and the mutation hook. This avoids setState-in-effect
|
||||||
|
* issues with clearing the result on input change (the parent uses the
|
||||||
|
* React-recommended "store previous prop" pattern instead).
|
||||||
|
*/
|
||||||
|
export function ServiceTestPanel({
|
||||||
|
result,
|
||||||
|
isPending,
|
||||||
|
saveAnyway,
|
||||||
|
onTest,
|
||||||
|
onSaveAnywayChange,
|
||||||
|
disabled,
|
||||||
|
}: Props) {
|
||||||
|
const testPassed = result?.ok === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isPending || disabled}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
|
{isPending ? "Testing…" : "Test credentials"}
|
||||||
|
</Button>
|
||||||
|
{result ? (
|
||||||
|
<Alert variant={result.ok ? "default" : "destructive"}>
|
||||||
|
<AlertDescription>
|
||||||
|
{result.ok
|
||||||
|
? `✓ Connected${result.evidence ? ` — ${result.evidence}` : ""}`
|
||||||
|
: `✗ ${result.detail}`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveAnyway}
|
||||||
|
onChange={(e) => onSaveAnywayChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Save anyway (skip test)
|
||||||
|
</label>
|
||||||
|
{!testPassed && !saveAnyway ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Test credentials or check "Save anyway" to enable the save button.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -155,12 +155,24 @@ function WidgetConfigEditor({
|
|||||||
const isNumber =
|
const isNumber =
|
||||||
(schema as { type?: string }).type === "integer" ||
|
(schema as { type?: string }).type === "integer" ||
|
||||||
(schema as { type?: string }).type === "number";
|
(schema as { type?: string }).type === "number";
|
||||||
// Use a multi-line textarea for fields that tend to hold complex
|
// Use a multi-line resizable textarea for fields that tend to hold
|
||||||
// multi-line values (PromQL, text blocks, etc.). The widget kind's
|
// complex multi-line values (PromQL expressions, Grafana query strings,
|
||||||
// config schema can opt in via `format: "textarea"`; the well-known
|
// markdown/text blocks, etc.). The widget kind's config schema can opt
|
||||||
// `query` field is treated as textarea by default.
|
// in via `format: "textarea"`; the well-known field names below are
|
||||||
|
// treated as textarea by default.
|
||||||
const schemaFormat = (schema as { format?: string }).format;
|
const schemaFormat = (schema as { format?: string }).format;
|
||||||
const isTextarea = schemaFormat === "textarea" || key === "query";
|
const TEXTAREA_KEYS = new Set([
|
||||||
|
"promql",
|
||||||
|
"query",
|
||||||
|
"text",
|
||||||
|
"command",
|
||||||
|
"notes",
|
||||||
|
]);
|
||||||
|
const isTextarea =
|
||||||
|
schemaFormat === "textarea" || TEXTAREA_KEYS.has(key);
|
||||||
|
// Enum schema fields (e.g. unit/scale) render as a dropdown so users pick
|
||||||
|
// from the allowed values consistently across every widget kind.
|
||||||
|
const enumOptions = (schema as { enum?: string[] }).enum;
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
@@ -168,11 +180,27 @@ function WidgetConfigEditor({
|
|||||||
htmlFor={`widget-cfg-${key}`}
|
htmlFor={`widget-cfg-${key}`}
|
||||||
helper={(schema as { description?: string }).description}
|
helper={(schema as { description?: string }).description}
|
||||||
>
|
>
|
||||||
{isTextarea ? (
|
{enumOptions ? (
|
||||||
|
<Select
|
||||||
|
value={String(config[key] ?? "")}
|
||||||
|
onValueChange={(v) => onChange({ ...config, [key]: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`widget-cfg-${key}`}>
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{enumOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt === "all" ? "All values" : opt.replace(/_/g, " ")}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : isTextarea ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`widget-cfg-${key}`}
|
id={`widget-cfg-${key}`}
|
||||||
rows={4}
|
rows={6}
|
||||||
className="resize-y font-mono text-xs"
|
className="resize font-mono text-xs min-h-[120px]"
|
||||||
value={String(config[key] ?? "")}
|
value={String(config[key] ?? "")}
|
||||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -209,15 +237,29 @@ export function WidgetConfigDialog({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
|
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
|
||||||
// (service_id IS NULL) so service-scoped widgets don't leak into the list.
|
// (service_id IS NULL) so service-scoped widgets don't leak into the list.
|
||||||
const { data: instances = [] } = useWidgetInstances(
|
// NOTE: stabilize the `data ?? []` defaults via useMemo. Using the inline
|
||||||
|
// `= []` fallback would create a NEW array reference on every render, which
|
||||||
|
// feeds the auto-edit useEffect below (deps include `instances`/`references`)
|
||||||
|
// and causes React error #185 (Maximum update depth exceeded) when the
|
||||||
|
// underlying query returns undefined — e.g. editing a service-scoped widget
|
||||||
|
// where `dashboardScope` is undefined and `useWidgetReferences` yields no data.
|
||||||
|
const { data: instancesData } = useWidgetInstances(
|
||||||
serviceId,
|
serviceId,
|
||||||
!serviceId && dashboardScope ? "dashboard" : undefined,
|
!serviceId && dashboardScope ? "dashboard" : undefined,
|
||||||
);
|
);
|
||||||
const { data: services = [] } = useServiceInstances();
|
const instances = useMemo(() => instancesData ?? [], [instancesData]);
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: servicesData } = useServiceInstances();
|
||||||
|
const services = useMemo(() => servicesData ?? [], [servicesData]);
|
||||||
|
const remoteMachineServiceId = services.find(
|
||||||
|
(service) =>
|
||||||
|
service.id === serviceId && service.service_type === "remote_machine",
|
||||||
|
)?.id;
|
||||||
|
const { data: tasksData } = useTasks(remoteMachineServiceId);
|
||||||
|
const tasks = useMemo(() => tasksData ?? [], [tasksData]);
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
const { data: references = [] } = useWidgetReferences(dashboardScope);
|
const { data: referencesData } = useWidgetReferences(dashboardScope);
|
||||||
|
const references = useMemo(() => referencesData ?? [], [referencesData]);
|
||||||
const createRef = useCreateWidgetReference();
|
const createRef = useCreateWidgetReference();
|
||||||
const deleteRef = useDeleteWidgetReference();
|
const deleteRef = useDeleteWidgetReference();
|
||||||
const detachRef = useDetachWidgetReference();
|
const detachRef = useDetachWidgetReference();
|
||||||
@@ -367,14 +409,15 @@ export function WidgetConfigDialog({
|
|||||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
);
|
);
|
||||||
|
|
||||||
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
|
|
||||||
|
|
||||||
// Available widgets for the "Add existing" picker: all widgets not already
|
// Available widgets for the "Add existing" picker: all widgets not already
|
||||||
// on this dashboard (owned or referenced).
|
// on this dashboard (owned or referenced). The referenced-id Set is built
|
||||||
|
// INSIDE the memo so its identity is stable across renders (building it in
|
||||||
|
// the render body would change the memo's deps every render and recompute
|
||||||
|
// it every frame — the lint-flagged footgun).
|
||||||
const availableWidgets = useMemo(() => {
|
const availableWidgets = useMemo(() => {
|
||||||
const onDashboard = new Set([
|
const onDashboard = new Set([
|
||||||
...instances.map((w) => w.id),
|
...instances.map((w) => w.id),
|
||||||
...referencedWidgetIds,
|
...references.map((r) => r.widget_id),
|
||||||
]);
|
]);
|
||||||
const search = existingSearch.toLowerCase().trim();
|
const search = existingSearch.toLowerCase().trim();
|
||||||
return allWidgets
|
return allWidgets
|
||||||
@@ -385,7 +428,7 @@ export function WidgetConfigDialog({
|
|||||||
w.title.toLowerCase().includes(search) ||
|
w.title.toLowerCase().includes(search) ||
|
||||||
w.widget_kind.toLowerCase().includes(search),
|
w.widget_kind.toLowerCase().includes(search),
|
||||||
);
|
);
|
||||||
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
|
}, [allWidgets, instances, references, existingSearch]);
|
||||||
|
|
||||||
async function handleAddReference(widgetId: string) {
|
async function handleAddReference(widgetId: string) {
|
||||||
await createRef.mutateAsync({
|
await createRef.mutateAsync({
|
||||||
@@ -420,7 +463,7 @@ export function WidgetConfigDialog({
|
|||||||
const isTaskOutput =
|
const isTaskOutput =
|
||||||
draft?.serviceId !== null &&
|
draft?.serviceId !== null &&
|
||||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
"ssh_tasks";
|
"remote_machine";
|
||||||
|
|
||||||
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
||||||
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { LineSeriesChart } from "../LineSeriesChart";
|
import { LineSeriesChart } from "../LineSeriesChart";
|
||||||
import type { ChartSeries } from "../LineSeriesChart";
|
import type { ChartSeries } from "../LineSeriesChart";
|
||||||
|
import { chartRangesThrough } from "../chartRanges";
|
||||||
|
|
||||||
describe("LineSeriesChart", () => {
|
describe("LineSeriesChart", () => {
|
||||||
it("renders without crashing with series data", () => {
|
it("renders without crashing with series data", () => {
|
||||||
@@ -24,6 +25,42 @@ describe("LineSeriesChart", () => {
|
|||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a configurable displayed data range", () => {
|
||||||
|
render(
|
||||||
|
<LineSeriesChart
|
||||||
|
series={[]}
|
||||||
|
rangeOptions={chartRangesThrough(7200)}
|
||||||
|
defaultRangeSeconds={7200}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("combobox", { name: "Chart range" }),
|
||||||
|
).toHaveTextContent("2 hours");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can hide the interactive selector for configured widgets", () => {
|
||||||
|
render(<LineSeriesChart series={[]} showRangeSelector={false} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all loaded values and reports that selection", () => {
|
||||||
|
const onRangeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<LineSeriesChart
|
||||||
|
series={[]}
|
||||||
|
rangeOptions={chartRangesThrough(3600)}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Chart range" }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "All values" }));
|
||||||
|
|
||||||
|
expect(onRangeChange).toHaveBeenCalledWith("all");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders with custom height", () => {
|
it("renders with custom height", () => {
|
||||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { ServiceTestPanel } from "../ServiceTestPanel";
|
||||||
|
import type { ServiceTestResult } from "../../types";
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
describe("ServiceTestPanel", () => {
|
||||||
|
it("renders the Test credentials button", () => {
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={null}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Test credentials")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Testing… and disables button while pending", () => {
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={null}
|
||||||
|
isPending={true}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Testing…")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Testing…")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders green ✓ Connected pill with evidence on success", () => {
|
||||||
|
const result: ServiceTestResult = {
|
||||||
|
ok: true,
|
||||||
|
detail: "ok",
|
||||||
|
evidence: "v4.5.0",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={result}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/✓ Connected — v4.5.0/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders red ✗ pill with detail on failure", () => {
|
||||||
|
const result: ServiceTestResult = {
|
||||||
|
ok: false,
|
||||||
|
detail: "Authentication failed",
|
||||||
|
evidence: null,
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={result}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/✗ Authentication failed/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onTest when Test credentials is clicked", () => {
|
||||||
|
const onTest = vi.fn();
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={null}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={onTest}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Test credentials"));
|
||||||
|
expect(onTest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onSaveAnywayChange when checkbox is toggled", () => {
|
||||||
|
const onSaveAnywayChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={null}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={onSaveAnywayChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(onSaveAnywayChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Save anyway checkbox", () => {
|
||||||
|
render(
|
||||||
|
<ServiceTestPanel
|
||||||
|
result={null}
|
||||||
|
isPending={false}
|
||||||
|
saveAnyway={false}
|
||||||
|
onTest={noop}
|
||||||
|
onSaveAnywayChange={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("checkbox")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
chartRangesThrough,
|
||||||
|
PROMETHEUS_WINDOW_VALUES,
|
||||||
|
rangeSecondsFromWindow,
|
||||||
|
} from "../chartRanges";
|
||||||
|
|
||||||
|
describe("chart ranges", () => {
|
||||||
|
it("maps every persisted Prometheus window to its display duration", () => {
|
||||||
|
expect(PROMETHEUS_WINDOW_VALUES).toEqual([
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"3h",
|
||||||
|
"6h",
|
||||||
|
"12h",
|
||||||
|
"24h",
|
||||||
|
"2d",
|
||||||
|
"7d",
|
||||||
|
"14d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(PROMETHEUS_WINDOW_VALUES.map(rangeSecondsFromWindow)).toEqual([
|
||||||
|
300, 900, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 604800, 1209600,
|
||||||
|
2592000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all values after every finite range through the available history", () => {
|
||||||
|
expect(chartRangesThrough(86_400).at(-1)).toEqual({
|
||||||
|
value: "all",
|
||||||
|
label: "All values",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export type ChartRangeValue = number | "all";
|
||||||
|
|
||||||
|
export interface ChartRangeOption {
|
||||||
|
value: ChartRangeValue;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FiniteChartRange extends ChartRangeOption {
|
||||||
|
key: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical finite windows for chart configuration and display filtering. */
|
||||||
|
const FINITE_CHART_RANGES: readonly FiniteChartRange[] = [
|
||||||
|
{ key: "5m", value: 300, label: "5 minutes" },
|
||||||
|
{ key: "15m", value: 900, label: "15 minutes" },
|
||||||
|
{ key: "30m", value: 1800, label: "30 minutes" },
|
||||||
|
{ key: "1h", value: 3600, label: "1 hour" },
|
||||||
|
{ key: "3h", value: 10800, label: "3 hours" },
|
||||||
|
{ key: "6h", value: 21600, label: "6 hours" },
|
||||||
|
{ key: "12h", value: 43200, label: "12 hours" },
|
||||||
|
{ key: "24h", value: 86400, label: "24 hours" },
|
||||||
|
{ key: "2d", value: 172800, label: "2 days" },
|
||||||
|
{ key: "7d", value: 604800, label: "7 days" },
|
||||||
|
{ key: "14d", value: 1209600, label: "14 days" },
|
||||||
|
{ key: "30d", value: 2592000, label: "30 days" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Shared range choices used by every time-series chart. */
|
||||||
|
export const DEFAULT_CHART_RANGES: readonly ChartRangeOption[] =
|
||||||
|
FINITE_CHART_RANGES;
|
||||||
|
|
||||||
|
/** Symbolic range values persisted by Prometheus chart and mean widgets. */
|
||||||
|
export const PROMETHEUS_WINDOW_VALUES = FINITE_CHART_RANGES.map(
|
||||||
|
(range) => range.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ALL_VALUES_CHART_RANGE: ChartRangeOption = {
|
||||||
|
value: "all",
|
||||||
|
label: "All values",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRangeLabel(seconds: number): string {
|
||||||
|
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
||||||
|
if (seconds % 60 === 0) return `${seconds / 60} minutes`;
|
||||||
|
return `${seconds} seconds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
||||||
|
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
||||||
|
return [DEFAULT_CHART_RANGES[0], ALL_VALUES_CHART_RANGE];
|
||||||
|
}
|
||||||
|
const ranges = FINITE_CHART_RANGES.filter(
|
||||||
|
(range) => range.value < maxSeconds,
|
||||||
|
);
|
||||||
|
const exact = FINITE_CHART_RANGES.find((range) => range.value === maxSeconds);
|
||||||
|
return [
|
||||||
|
...(exact
|
||||||
|
? [...ranges, exact]
|
||||||
|
: [
|
||||||
|
...ranges,
|
||||||
|
{ value: maxSeconds, label: formatRangeLabel(maxSeconds) },
|
||||||
|
]),
|
||||||
|
ALL_VALUES_CHART_RANGE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeSecondsFromWindow(window: unknown): number {
|
||||||
|
return (
|
||||||
|
FINITE_CHART_RANGES.find((range) => range.key === String(window))?.value ??
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric chart windows suitable for sources with bounded local retention. */
|
||||||
|
export function numericChartRangesThrough(maxSeconds: number): number[] {
|
||||||
|
return FINITE_CHART_RANGES.flatMap((range) =>
|
||||||
|
range.value <= maxSeconds ? [range.value] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { useBackupJobs, useBackupRuns, useBackupAlerts } from "../useBackups";
|
||||||
|
import {
|
||||||
|
useAlertmanagerAlerts,
|
||||||
|
usePrometheusStatus,
|
||||||
|
} from "../useObservability";
|
||||||
|
|
||||||
|
vi.mock("../../api/client", () => ({
|
||||||
|
fetchAlertmanagerAlerts: vi.fn(),
|
||||||
|
fetchAlertmanagerStatus: vi.fn(),
|
||||||
|
fetchPrometheusStatus: vi.fn(),
|
||||||
|
fetchPrometheusTargets: vi.fn(),
|
||||||
|
fetchMonitoringMachines: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../api/backups", () => ({
|
||||||
|
fetchBackupJobs: vi.fn(),
|
||||||
|
fetchBackupRuns: vi.fn(),
|
||||||
|
fetchBackupAlerts: vi.fn(),
|
||||||
|
fetchBackupDashboard: vi.fn(),
|
||||||
|
fetchBackupJob: vi.fn(),
|
||||||
|
acknowledgeBackupAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) =>
|
||||||
|
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("per-instance hook queryKey isolation", () => {
|
||||||
|
it("useBackupJobs produces different keys for different serviceIds", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => useBackupJobs("svc-a"), { wrapper });
|
||||||
|
const { result: b } = renderHook(() => useBackupJobs("svc-b"), { wrapper });
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
expect(b).toBeDefined();
|
||||||
|
// Different serviceId → different query → different cache slot
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupJobs with undefined serviceId is stable (same key)", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => useBackupJobs(), { wrapper });
|
||||||
|
const { result: b } = renderHook(() => useBackupJobs(), { wrapper });
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
expect(b).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupRuns includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(
|
||||||
|
() => useBackupRuns(undefined, undefined, "svc-a"),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupAlerts includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(
|
||||||
|
() => useBackupAlerts(undefined, false, undefined, "svc-a"),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useAlertmanagerAlerts includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => useAlertmanagerAlerts("svc-a"), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("usePrometheusStatus includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => usePrometheusStatus("svc-a"), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/** Hooks for the Authentik directory + messaging tabs. */
|
/** Hooks for Authentik directory, access metadata, and messaging tabs. */
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
fetchAuthentikAccessSummary,
|
||||||
|
fetchAuthentikApplications,
|
||||||
|
fetchAuthentikGroups,
|
||||||
fetchAuthentikMessageStatus,
|
fetchAuthentikMessageStatus,
|
||||||
fetchAuthentikUsers,
|
fetchAuthentikUsers,
|
||||||
sendAuthentikMessage,
|
sendAuthentikMessage,
|
||||||
@@ -17,6 +20,33 @@ export function useAuthentikUsers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAuthentikAccessSummary(
|
||||||
|
serviceId: string,
|
||||||
|
params: { search?: string; page?: number; page_size?: number },
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["authentik", "access-summary", serviceId, params],
|
||||||
|
queryFn: () => fetchAuthentikAccessSummary(serviceId, params),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthentikGroups(serviceId: string, limit = 100) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["authentik", "groups", serviceId, limit],
|
||||||
|
queryFn: () => fetchAuthentikGroups(serviceId, limit),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthentikApplications(serviceId: string, limit = 100) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["authentik", "applications", serviceId, limit],
|
||||||
|
queryFn: () => fetchAuthentikApplications(serviceId, limit),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSendAuthentikMessage(serviceId: string) {
|
export function useSendAuthentikMessage(serviceId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,59 +1,75 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
acknowledgeBackupAlert,
|
acknowledgeBackupAlert,
|
||||||
fetchBackupAlerts,
|
fetchBackupAlerts,
|
||||||
fetchBackupDashboard,
|
fetchBackupDashboard,
|
||||||
fetchBackupJob,
|
fetchBackupJob,
|
||||||
fetchBackupJobs,
|
fetchBackupJobs,
|
||||||
fetchBackupRuns,
|
fetchBackupRuns,
|
||||||
} from "../api/backups";
|
} from "../api/backups";
|
||||||
|
|
||||||
export function useBackupJobs() {
|
export function useBackupJobs(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "jobs"],
|
queryKey: ["backups", "jobs", serviceId ?? ""],
|
||||||
queryFn: fetchBackupJobs,
|
queryFn: () => fetchBackupJobs(serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupJob(jobId: string) {
|
export function useBackupJob(jobId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "jobs", jobId],
|
queryKey: ["backups", "jobs", jobId],
|
||||||
queryFn: () => fetchBackupJob(jobId),
|
queryFn: () => fetchBackupJob(jobId),
|
||||||
enabled: !!jobId,
|
enabled: !!jobId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupRuns(jobId?: string, status?: string) {
|
export function useBackupRuns(
|
||||||
return useQuery({
|
jobId?: string,
|
||||||
queryKey: ["backups", "runs", jobId, status],
|
status?: string,
|
||||||
queryFn: () => fetchBackupRuns(jobId, status),
|
serviceId?: string,
|
||||||
refetchInterval: 15_000,
|
) {
|
||||||
});
|
return useQuery({
|
||||||
|
queryKey: ["backups", "runs", jobId, status, serviceId ?? ""],
|
||||||
|
queryFn: () => fetchBackupRuns(jobId, status, serviceId),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string) {
|
export function useBackupAlerts(
|
||||||
return useQuery({
|
jobId?: string,
|
||||||
queryKey: ["backups", "alerts", jobId, acknowledged, severity],
|
acknowledged?: boolean,
|
||||||
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity),
|
severity?: string,
|
||||||
refetchInterval: 30_000,
|
serviceId?: string,
|
||||||
});
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"backups",
|
||||||
|
"alerts",
|
||||||
|
jobId,
|
||||||
|
acknowledged,
|
||||||
|
severity,
|
||||||
|
serviceId ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAcknowledgeAlert() {
|
export function useAcknowledgeAlert() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: acknowledgeBackupAlert,
|
mutationFn: acknowledgeBackupAlert,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["backups", "alerts"] });
|
queryClient.invalidateQueries({ queryKey: ["backups", "alerts"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupDashboard() {
|
export function useBackupDashboard() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "backups"],
|
queryKey: ["dashboard", "backups"],
|
||||||
queryFn: fetchBackupDashboard,
|
queryFn: fetchBackupDashboard,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
fetchDirectoryListing,
|
fetchDirectoryListing,
|
||||||
fetchFfprobe,
|
fetchFfprobe,
|
||||||
fetchStat,
|
fetchStat,
|
||||||
fetchJobTemplates,
|
fetchJobTemplates,
|
||||||
runJob,
|
runJob,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useDirectoryListing(path: string, machineId?: string) {
|
export function useDirectoryListing(path: string, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["files", "list", path, machineId ?? "default"],
|
queryKey: ["files", "list", path, serviceId ?? "default"],
|
||||||
queryFn: () => fetchDirectoryListing(path, machineId),
|
queryFn: () => fetchDirectoryListing(path, serviceId),
|
||||||
enabled: !!path,
|
enabled: !!path,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFfprobe(path: string, enabled = false, machineId?: string) {
|
export function useFfprobe(path: string, enabled = false, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["files", "ffprobe", path, machineId ?? "default"],
|
queryKey: ["files", "ffprobe", path, serviceId ?? "default"],
|
||||||
queryFn: () => fetchFfprobe(path, machineId),
|
queryFn: () => fetchFfprobe(path, serviceId),
|
||||||
enabled: enabled && !!path,
|
enabled: enabled && !!path,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStat(path: string, enabled = false, machineId?: string) {
|
export function useStat(path: string, enabled = false, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["files", "stat", path, machineId ?? "default"],
|
queryKey: ["files", "stat", path, serviceId ?? "default"],
|
||||||
queryFn: () => fetchStat(path, machineId),
|
queryFn: () => fetchStat(path, serviceId),
|
||||||
enabled: enabled && !!path,
|
enabled: enabled && !!path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useJobTemplates() {
|
export function useJobTemplates() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["jobs", "templates"],
|
queryKey: ["jobs", "templates"],
|
||||||
queryFn: fetchJobTemplates,
|
queryFn: fetchJobTemplates,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRunJob(machineId?: string) {
|
export function useRunJob(serviceId?: string) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
|
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
|
||||||
runJob(jobKey, path, machineId),
|
runJob(jobKey, path, serviceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user