Compare commits
9 Commits
fe90feb1b7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e6177d86 | |||
| 0866dc4136 | |||
| 11f093cd2c | |||
| 1bf8a34a97 | |||
| e0f66a51f7 | |||
| 3871f24724 | |||
| 17976eab80 | |||
| 4562a9dfca | |||
| 37533dd219 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
docker compose up --build
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at <http://localhost:8080>.
|
Generate a Fernet key if needed:
|
||||||
|
|
||||||
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`.
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
> **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).
|
2. Start the development stack:
|
||||||
|
|
||||||
Local development with hot reload:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
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 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.
|
||||||
|
|
||||||
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.
|
## Production-style deployment
|
||||||
|
|
||||||
### Manual backend/frontend development
|
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
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Manual development
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,9 +15,34 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_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 = DEFAULT_READ_TIMEOUT):
|
def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||||
if not base_url:
|
if not base_url:
|
||||||
@@ -31,88 +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
|
||||||
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
|
||||||
self.timeout = http_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
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
len(items),
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
}
|
}
|
||||||
|
for group_id in group_ids
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
|
||||||
|
|||||||
@@ -220,7 +220,12 @@ class QbittorrentClient:
|
|||||||
if fields is None:
|
if fields is None:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
else:
|
else:
|
||||||
snap["torrents"][hash_] = fields
|
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 []:
|
for hash_ in update.get("torrents_removed") or []:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
categories = update.get("categories")
|
categories = update.get("categories")
|
||||||
|
|||||||
@@ -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,7 +125,7 @@ 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 Jellyfin user Id, resolving a configured username if needed.
|
"""Return the Jellyfin user Id, resolving a configured username if needed.
|
||||||
|
|
||||||
The service ``user_id`` config field accepts either the internal Jellyfin Id
|
The service ``user_id`` config field accepts either the internal Jellyfin Id
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"""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 typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
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,
|
||||||
@@ -17,27 +13,25 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
TestResult,
|
TestResult,
|
||||||
|
WidgetConfigBase,
|
||||||
translate_connection_error,
|
translate_connection_error,
|
||||||
|
widget_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
def test_connection(
|
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
|
||||||
config: dict[str, Any],
|
"""Probe the least-expensive Authentik directory endpoint."""
|
||||||
secrets: dict[str, str],
|
|
||||||
store: SettingsStore,
|
|
||||||
) -> TestResult:
|
|
||||||
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
|
|
||||||
try:
|
try:
|
||||||
base_url = str(config.get("base_url") or "").rstrip("/")
|
client = AuthentikClient(
|
||||||
api_token = str(secrets.get("api_token") or "")
|
base_url=str(config.get("base_url") or "").rstrip("/"),
|
||||||
timeout = float(config.get("timeout_seconds") or 60)
|
api_token=str(secrets.get("api_token") or ""),
|
||||||
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
timeout=float(config.get("timeout_seconds") or 60),
|
||||||
|
)
|
||||||
result = client.users(page=1, page_size=1)
|
result = client.users(page=1, page_size=1)
|
||||||
total = result.get("total", 0) if isinstance(result, dict) else 0
|
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
|
||||||
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return translate_connection_error(exc, context="Authentik")
|
return translate_connection_error(exc, context="Authentik")
|
||||||
|
|
||||||
@@ -46,17 +40,46 @@ class AuthentikConfig(ServiceConfigBase):
|
|||||||
"""Non-secret Authentik connection config."""
|
"""Non-secret Authentik connection config."""
|
||||||
|
|
||||||
base_url: ServiceBaseUrl
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 60
|
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,
|
test_callable=test_connection,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class TestResult:
|
|||||||
|
|
||||||
|
|
||||||
#: A test routine receives (config, secrets, store). The store is needed for
|
#: A test routine receives (config, secrets, store). The store is needed for
|
||||||
#: ssh_tasks (SSH-key resolution). Other types ignore it.
|
#: remote_machine (SSH-key resolution). Other types ignore it.
|
||||||
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ 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
|
# 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.).
|
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
||||||
unit: Literal[
|
unit: Literal[
|
||||||
@@ -116,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
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 (
|
||||||
@@ -83,7 +83,7 @@ class QbittorrentWidgetConfig(WidgetConfigBase):
|
|||||||
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
||||||
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
||||||
|
|
||||||
window_seconds: int = Field(default=1_800, ge=60, le=86_400)
|
window_seconds: int | Literal["all"] = 1_800
|
||||||
unit: Literal[
|
unit: Literal[
|
||||||
"none",
|
"none",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -95,6 +95,16 @@ class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
|||||||
] = "bytes_per_sec"
|
] = "bytes_per_sec"
|
||||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
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",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -1,6 +1,6 @@
|
|||||||
"""SSH task runner service definition.
|
"""Remote machine service definition.
|
||||||
|
|
||||||
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
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
|
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``
|
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||||
and shown as history on the instance's service page.
|
and shown as history on the instance's service page.
|
||||||
@@ -42,7 +42,7 @@ def test_connection(
|
|||||||
try:
|
try:
|
||||||
service = ServiceRecord(
|
service = ServiceRecord(
|
||||||
id="",
|
id="",
|
||||||
service_type="ssh_tasks",
|
service_type="remote_machine",
|
||||||
name="test",
|
name="test",
|
||||||
config=config,
|
config=config,
|
||||||
secrets=secrets,
|
secrets=secrets,
|
||||||
@@ -77,8 +77,8 @@ def test_connection(
|
|||||||
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
class SshTasksConfig(ServiceConfigBase):
|
class RemoteMachineConfig(ServiceConfigBase):
|
||||||
"""Non-secret SSH task runner config.
|
"""Non-secret Remote machine config.
|
||||||
|
|
||||||
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
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.
|
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||||
@@ -91,7 +91,7 @@ class SshTasksConfig(ServiceConfigBase):
|
|||||||
timeout_seconds: int = 30
|
timeout_seconds: int = 30
|
||||||
|
|
||||||
|
|
||||||
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
class RemoteMachineTaskOutputWidgetConfig(WidgetConfigBase):
|
||||||
"""Output of a saved task run on this instance."""
|
"""Output of a saved task run on this instance."""
|
||||||
|
|
||||||
task_id: str
|
task_id: str
|
||||||
@@ -100,19 +100,20 @@ class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
|||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="ssh_tasks",
|
service_type="remote_machine",
|
||||||
name="SSH task runner",
|
name="Remote machine",
|
||||||
description="Run reusable saved tasks over SSH and keep run history.",
|
description="SSH transport for files and reusable actions.",
|
||||||
config_model=SshTasksConfig,
|
config_model=RemoteMachineConfig,
|
||||||
secret_fields=[
|
secret_fields=[
|
||||||
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||||
|
SecretField(key="password", label="SSH password", helper="Optional"),
|
||||||
],
|
],
|
||||||
widget_kinds=[
|
widget_kinds=[
|
||||||
widget_kind(
|
widget_kind(
|
||||||
kind="task_output",
|
kind="task_output",
|
||||||
name="Task output",
|
name="Task output",
|
||||||
description="Output of a saved task run.",
|
description="Output of a saved task run.",
|
||||||
model_cls=SshTaskOutputWidgetConfig,
|
model_cls=RemoteMachineTaskOutputWidgetConfig,
|
||||||
default_config={"task_id": ""},
|
default_config={"task_id": ""},
|
||||||
refresh_interval_ms=0,
|
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}"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class SchedulerSample(BaseModel):
|
|||||||
|
|
||||||
class SchedulerSamplesResponse(BaseModel):
|
class SchedulerSamplesResponse(BaseModel):
|
||||||
service_id: str
|
service_id: str
|
||||||
window_seconds: int
|
window_seconds: int | None
|
||||||
|
all_values: bool = False
|
||||||
samples: list[SchedulerSample]
|
samples: list[SchedulerSample]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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__)
|
||||||
@@ -59,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")
|
||||||
|
|||||||
@@ -103,14 +103,22 @@ def run_scheduler_action(
|
|||||||
def get_scheduler_samples(
|
def get_scheduler_samples(
|
||||||
service_id: str,
|
service_id: str,
|
||||||
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
|
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
|
||||||
|
all_values: bool = Query(default=False),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> SchedulerSamplesResponse:
|
) -> SchedulerSamplesResponse:
|
||||||
_require_qbittorrent(service_id, store)
|
_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
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
samples = QbittorrentSampleStore().window(service_id, since_ts=since_ts)
|
samples = sample_store.window(service_id, since_ts=since_ts)
|
||||||
|
response_window = window_seconds
|
||||||
return SchedulerSamplesResponse(
|
return SchedulerSamplesResponse(
|
||||||
service_id=service_id,
|
service_id=service_id,
|
||||||
window_seconds=window_seconds,
|
window_seconds=response_window,
|
||||||
|
all_values=all_values,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,139 @@ 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()
|
||||||
|
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(
|
||||||
"""
|
"""
|
||||||
return None
|
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()
|
self._migrate_jellyseerr_api_key_to_secret()
|
||||||
|
|
||||||
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
|
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
|
||||||
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets.
|
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets."""
|
||||||
|
|
||||||
The key was originally a plaintext config field; it is now a secret.
|
|
||||||
Idempotent: once no Jellyfin config carries the key this is a no-op. Uses
|
|
||||||
a direct UPDATE so existing (encrypted) secrets are preserved untouched
|
|
||||||
rather than re-encrypted.
|
|
||||||
"""
|
|
||||||
from media_library_viewer_api.services.secrets import encrypt_value
|
from media_library_viewer_api.services.secrets import encrypt_value
|
||||||
|
|
||||||
self.init_schema()
|
|
||||||
moved = 0
|
moved = 0
|
||||||
for row in self.list_services("jellyfin"):
|
for row in self.list_services("jellyfin"):
|
||||||
config = dict(row.get("config") or {})
|
config = dict(row.get("config") or {})
|
||||||
@@ -476,27 +381,15 @@ class SettingsStore:
|
|||||||
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
|
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
|
||||||
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
|
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
moved += 1
|
moved += 1
|
||||||
logger.info(
|
logger.info("migrated jellyseerr_api_key config->secret for jellyfin service %r", row["name"])
|
||||||
"migrated jellyseerr_api_key config->secret for jellyfin service %r",
|
|
||||||
row["name"],
|
|
||||||
)
|
|
||||||
if moved:
|
if moved:
|
||||||
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", 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"
|
||||||
@@ -510,28 +403,23 @@ 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:
|
||||||
# list_services returns the stored (encrypted) secrets blob, so
|
|
||||||
# decrypt the existing Jellyfin api_key before handing it back to
|
|
||||||
# upsert_service (which re-encrypts) — otherwise it double-encrypts.
|
|
||||||
target_api_key = str(target["secrets"].get("api_key") or "")
|
target_api_key = str(target["secrets"].get("api_key") or "")
|
||||||
if target_api_key:
|
if target_api_key:
|
||||||
try:
|
try:
|
||||||
@@ -549,142 +437,14 @@ class SettingsStore:
|
|||||||
"config": merged_config,
|
"config": merged_config,
|
||||||
"enabled": target["enabled"],
|
"enabled": target["enabled"],
|
||||||
},
|
},
|
||||||
secret_values={
|
secret_values={"api_key": target_api_key, "jellyseerr_api_key": js_api_key},
|
||||||
"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]:
|
||||||
@@ -755,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:
|
||||||
@@ -833,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"],
|
||||||
@@ -850,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 ""
|
||||||
@@ -864,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -901,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
|
||||||
""",
|
""",
|
||||||
@@ -911,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +44,9 @@ 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))
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 (
|
||||||
@@ -316,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."""
|
||||||
|
|
||||||
@@ -431,9 +462,13 @@ def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _qbit_torrent_is_active(torrent: dict[str, Any]) -> bool:
|
def _qbit_torrent_transfer_direction(torrent: dict[str, Any]) -> str | None:
|
||||||
state = str(torrent.get("state") or "").lower()
|
"""Return a direction only while qBittorrent reports nonzero throughput."""
|
||||||
return bool(_qbit_torrent_direction(torrent)) or state in _QBITTORRENT_OTHER_ACTIVE_STATES
|
if _safe_int(torrent.get("dlspeed")) > 0:
|
||||||
|
return "downloading"
|
||||||
|
if _safe_int(torrent.get("upspeed")) > 0:
|
||||||
|
return "uploading"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentWidgetSource:
|
class QbittorrentWidgetSource:
|
||||||
@@ -445,8 +480,12 @@ class QbittorrentWidgetSource:
|
|||||||
return {"error": "qBittorrent widget is missing its service"}
|
return {"error": "qBittorrent widget is missing its service"}
|
||||||
|
|
||||||
if widget_kind == "speed":
|
if widget_kind == "speed":
|
||||||
|
configured_window = config.get("window_seconds")
|
||||||
|
if configured_window == "all":
|
||||||
|
samples = QbittorrentSampleStore().window(service.id)
|
||||||
|
else:
|
||||||
window_seconds = _safe_int(
|
window_seconds = _safe_int(
|
||||||
config.get("window_seconds") or service.config.get("sample_retention_seconds") or 1_800
|
configured_window or service.config.get("sample_retention_seconds") or 1_800
|
||||||
)
|
)
|
||||||
window_seconds = max(60, min(window_seconds, 86_400))
|
window_seconds = max(60, min(window_seconds, 86_400))
|
||||||
since_ts = _safe_int(time.time()) - window_seconds
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
@@ -498,9 +537,9 @@ class QbittorrentWidgetSource:
|
|||||||
if widget_kind == "active":
|
if widget_kind == "active":
|
||||||
active = []
|
active = []
|
||||||
for torrent in torrents.values():
|
for torrent in torrents.values():
|
||||||
if not _qbit_torrent_is_active(torrent):
|
direction = _qbit_torrent_transfer_direction(torrent)
|
||||||
|
if not direction:
|
||||||
continue
|
continue
|
||||||
direction = _qbit_torrent_direction(torrent)
|
|
||||||
active.append(
|
active.append(
|
||||||
{
|
{
|
||||||
"name": torrent.get("name"),
|
"name": torrent.get("name"),
|
||||||
@@ -508,6 +547,7 @@ class QbittorrentWidgetSource:
|
|||||||
"direction": direction,
|
"direction": direction,
|
||||||
"size": torrent.get("size"),
|
"size": torrent.get("size"),
|
||||||
"progress": torrent.get("progress"),
|
"progress": torrent.get("progress"),
|
||||||
|
"ratio": torrent.get("ratio"),
|
||||||
"dl_speed": torrent.get("dlspeed"),
|
"dl_speed": torrent.get("dlspeed"),
|
||||||
"up_speed": torrent.get("upspeed"),
|
"up_speed": torrent.get("upspeed"),
|
||||||
}
|
}
|
||||||
@@ -531,7 +571,8 @@ SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
|||||||
"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] = {
|
||||||
|
|||||||
@@ -257,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 ---
|
||||||
@@ -469,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)."""
|
||||||
|
|
||||||
@@ -569,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from media_library_viewer_api.integrations.jellyfin import test_connection as jf
|
|||||||
from media_library_viewer_api.integrations.nextcloud import test_connection as nc_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.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.qbittorrent import test_connection as qbit_test
|
||||||
from media_library_viewer_api.integrations.ssh_tasks import test_connection as ssh_test
|
from media_library_viewer_api.integrations.remote_machine import test_connection as ssh_test
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# translate_connection_error (CT-119)
|
# translate_connection_error (CT-119)
|
||||||
@@ -26,32 +26,32 @@ class TestTranslateConnectionError:
|
|||||||
resp = SimpleNamespace(status_code=401)
|
resp = SimpleNamespace(status_code=401)
|
||||||
exc = requests.HTTPError(response=resp)
|
exc = requests.HTTPError(response=resp)
|
||||||
result = translate_connection_error(exc)
|
result = translate_connection_error(exc)
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" in result.detail
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
def test_http_403_maps_to_auth_message(self) -> None:
|
def test_http_403_maps_to_auth_message(self) -> None:
|
||||||
resp = SimpleNamespace(status_code=403)
|
resp = SimpleNamespace(status_code=403)
|
||||||
exc = requests.HTTPError(response=resp)
|
exc = requests.HTTPError(response=resp)
|
||||||
result = translate_connection_error(exc)
|
result = translate_connection_error(exc)
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" in result.detail
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
|
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
|
||||||
exc = requests.ConnectionError("getaddrinfo failed")
|
exc = requests.ConnectionError("getaddrinfo failed")
|
||||||
result = translate_connection_error(exc)
|
result = translate_connection_error(exc)
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Host not found" in result.detail
|
assert "Host not found" in result.detail
|
||||||
|
|
||||||
def test_timeout_maps_to_timed_out(self) -> None:
|
def test_timeout_maps_to_timed_out(self) -> None:
|
||||||
exc = requests.Timeout("timed out")
|
exc = requests.Timeout("timed out")
|
||||||
result = translate_connection_error(exc)
|
result = translate_connection_error(exc)
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "timed out" in result.detail.lower()
|
assert "timed out" in result.detail.lower()
|
||||||
|
|
||||||
def test_generic_fallback_includes_context(self) -> None:
|
def test_generic_fallback_includes_context(self) -> None:
|
||||||
exc = ValueError("something weird happened")
|
exc = ValueError("something weird happened")
|
||||||
result = translate_connection_error(exc, context="qBittorrent")
|
result = translate_connection_error(exc, context="qBittorrent")
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "qBittorrent" in result.detail
|
assert "qBittorrent" in result.detail
|
||||||
assert "something weird happened" in result.detail
|
assert "something weird happened" in result.detail
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class TestQbittorrentTestConnection:
|
|||||||
{"username": "u", "password": "p"},
|
{"username": "u", "password": "p"},
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert result.evidence == "v4.6.0"
|
assert result.evidence == "v4.6.0"
|
||||||
|
|
||||||
def test_login_failed_translates_to_auth_message(self) -> None:
|
def test_login_failed_translates_to_auth_message(self) -> None:
|
||||||
@@ -82,7 +82,7 @@ class TestQbittorrentTestConnection:
|
|||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
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())
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" in result.detail
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None:
|
def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None:
|
||||||
@@ -94,7 +94,7 @@ class TestQbittorrentTestConnection:
|
|||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
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())
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" not in result.detail
|
assert "Authentication failed" not in result.detail
|
||||||
assert "504" in result.detail
|
assert "504" in result.detail
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class TestQbittorrentTestConnection:
|
|||||||
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
|
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
|
||||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
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())
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Connection refused" in result.detail
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
@@ -121,17 +121,17 @@ class TestPrometheusTestConnection:
|
|||||||
{"grafana_api_key": "tok"},
|
{"grafana_api_key": "tok"},
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert "Gateway" in (result.evidence or "")
|
assert "Gateway" in (result.evidence or "")
|
||||||
|
|
||||||
def test_missing_url_returns_error_without_network(self) -> None:
|
def test_missing_url_returns_error_without_network(self) -> None:
|
||||||
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
|
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "URL" in result.detail
|
assert "URL" in result.detail
|
||||||
|
|
||||||
def test_missing_api_key_returns_error_without_network(self) -> None:
|
def test_missing_api_key_returns_error_without_network(self) -> None:
|
||||||
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
|
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "API key" in result.detail
|
assert "API key" in result.detail
|
||||||
|
|
||||||
def test_http_401_translates_to_auth(self) -> None:
|
def test_http_401_translates_to_auth(self) -> None:
|
||||||
@@ -143,7 +143,7 @@ class TestPrometheusTestConnection:
|
|||||||
{"grafana_api_key": "wrong"},
|
{"grafana_api_key": "wrong"},
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" in result.detail
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ class TestAlertmanagerTestConnection:
|
|||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
|
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
|
||||||
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert result.evidence == "0.27.0"
|
assert result.evidence == "0.27.0"
|
||||||
|
|
||||||
def test_connection_refused_translates(self) -> None:
|
def test_connection_refused_translates(self) -> None:
|
||||||
@@ -169,7 +169,7 @@ class TestAlertmanagerTestConnection:
|
|||||||
side_effect=requests.ConnectionError("refused"),
|
side_effect=requests.ConnectionError("refused"),
|
||||||
):
|
):
|
||||||
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Connection refused" in result.detail
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ class TestJellyfinTestConnection:
|
|||||||
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
|
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
|
||||||
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
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())
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "k"}, MagicMock())
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert "2 users" == result.evidence
|
assert "2 users" == result.evidence
|
||||||
|
|
||||||
def test_http_401_translates_to_auth(self) -> None:
|
def test_http_401_translates_to_auth(self) -> None:
|
||||||
@@ -192,7 +192,7 @@ class TestJellyfinTestConnection:
|
|||||||
mock_client.users.side_effect = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
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):
|
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())
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "wrong"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Authentication failed" in result.detail
|
assert "Authentication failed" in result.detail
|
||||||
|
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ class TestAuthentikTestConnection:
|
|||||||
mock_client.users.return_value = {"total": 5, "items": []}
|
mock_client.users.return_value = {"total": 5, "items": []}
|
||||||
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
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())
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert "5 users" == result.evidence
|
assert "5 users" == result.evidence
|
||||||
|
|
||||||
def test_connection_error_translates(self) -> None:
|
def test_connection_error_translates(self) -> None:
|
||||||
@@ -215,7 +215,7 @@ class TestAuthentikTestConnection:
|
|||||||
mock_client.users.side_effect = requests.ConnectionError("refused")
|
mock_client.users.side_effect = requests.ConnectionError("refused")
|
||||||
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
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())
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Connection refused" in result.detail
|
assert "Connection refused" in result.detail
|
||||||
|
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ class TestSshTasksTestConnection:
|
|||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
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())
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {"passphrase": ""}, MagicMock())
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert "Connected to srv:22" == result.evidence
|
assert "Connected to srv:22" == result.evidence
|
||||||
|
|
||||||
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
|
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
|
||||||
@@ -237,7 +237,7 @@ class TestSshTasksTestConnection:
|
|||||||
mock_client.connect.side_effect = Exception("SSH authentication failed")
|
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):
|
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())
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "SSH authentication failed" in result.detail
|
assert "SSH authentication failed" in result.detail
|
||||||
|
|
||||||
def test_protocol_banner_translates(self) -> None:
|
def test_protocol_banner_translates(self) -> None:
|
||||||
@@ -245,12 +245,12 @@ class TestSshTasksTestConnection:
|
|||||||
mock_client.connect.side_effect = Exception("protocol banner error")
|
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):
|
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())
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "SSH banner" in result.detail
|
assert "SSH banner" in result.detail
|
||||||
|
|
||||||
def test_missing_host_returns_value_error(self) -> None:
|
def test_missing_host_returns_value_error(self) -> None:
|
||||||
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
|
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -263,7 +263,7 @@ class TestNextcloudTestConnection:
|
|||||||
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"version": "29.0.0"})
|
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):
|
with patch("media_library_viewer_api.integrations.nextcloud.requests.get", return_value=payload):
|
||||||
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||||
assert result.ok is True
|
assert result.ok
|
||||||
assert result.evidence == "29.0.0"
|
assert result.evidence == "29.0.0"
|
||||||
|
|
||||||
def test_connection_error_translates(self) -> None:
|
def test_connection_error_translates(self) -> None:
|
||||||
@@ -272,5 +272,5 @@ class TestNextcloudTestConnection:
|
|||||||
side_effect=requests.ConnectionError("refused"),
|
side_effect=requests.ConnectionError("refused"),
|
||||||
):
|
):
|
||||||
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||||
assert result.ok is False
|
assert not result.ok
|
||||||
assert "Connection refused" in result.detail
|
assert "Connection refused" in result.detail
|
||||||
|
|||||||
@@ -14,16 +14,33 @@ from media_library_viewer_api.widgets.prometheus_range import (
|
|||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -125,13 +125,21 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
"rid": 10,
|
"rid": 10,
|
||||||
"full_update": True,
|
"full_update": True,
|
||||||
"server_state": {"dl_info_speed": 100},
|
"server_state": {"dl_info_speed": 100},
|
||||||
"torrents": {"a": {"name": "A", "state": "downloading"}},
|
"torrents": {
|
||||||
|
"a": {
|
||||||
|
"name": "A",
|
||||||
|
"state": "downloading",
|
||||||
|
"size": 1_024,
|
||||||
|
"progress": 0.5,
|
||||||
|
"dlspeed": 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
partial = {
|
partial = {
|
||||||
"rid": 11,
|
"rid": 11,
|
||||||
"full_update": False,
|
"full_update": False,
|
||||||
"server_state": {"dl_info_speed": 200},
|
"server_state": {"dl_info_speed": 200},
|
||||||
"torrents": {"a": {"name": "A", "state": "pausedDL"}},
|
"torrents": {"a": {"dlspeed": 200}},
|
||||||
}
|
}
|
||||||
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
||||||
|
|
||||||
@@ -143,7 +151,11 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
r2 = self.client.maindata()
|
r2 = self.client.maindata()
|
||||||
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
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["server_state"]["dl_info_speed"], 200) # merged
|
||||||
self.assertEqual(r2["torrents"]["a"]["state"], "pausedDL") # 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:
|
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
||||||
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
||||||
@@ -227,8 +239,8 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
self.session.post.return_value = self._login_response()
|
self.session.post.return_value = self._login_response()
|
||||||
self.client._login()
|
self.client._login()
|
||||||
call_kwargs = self.session.post.call_args.kwargs
|
call_kwargs = self.session.post.call_args.kwargs
|
||||||
assert call_kwargs["timeout"] == (5.0, 5.0)
|
self.assertEqual(call_kwargs["timeout"], (5.0, 5.0))
|
||||||
assert not isinstance(call_kwargs["timeout"], int)
|
self.assertNotIsInstance(call_kwargs["timeout"], int)
|
||||||
|
|
||||||
def test_login_fails_message_names_bad_credentials(self) -> None:
|
def test_login_fails_message_names_bad_credentials(self) -> None:
|
||||||
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -87,6 +87,32 @@ def test_scheduler_routes_expose_status_history_and_disabled_manual_run(schedule
|
|||||||
assert manual.status_code == 400
|
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):
|
def test_sample_store_applies_time_and_row_limits(tmp_path):
|
||||||
harness = ServiceDataHarness(tmp_path)
|
harness = ServiceDataHarness(tmp_path)
|
||||||
harness.register(QBITTORRENT_CONCERN)
|
harness.register(QBITTORRENT_CONCERN)
|
||||||
|
|||||||
@@ -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,7 +80,7 @@ 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 = get_service_definition("jellyfin")
|
jellyfin = _definition("jellyfin")
|
||||||
jellyfin_config = jellyfin.config_schema["properties"]
|
jellyfin_config = jellyfin.config_schema["properties"]
|
||||||
assert "jellyseerr_url" in jellyfin_config
|
assert "jellyseerr_url" in jellyfin_config
|
||||||
# jellyseerr_api_key moved from config to a secret field.
|
# jellyseerr_api_key moved from config to a secret field.
|
||||||
@@ -82,8 +89,8 @@ def test_jellyseerr_absorbed_into_jellyfin():
|
|||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -91,35 +98,43 @@ 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} == {
|
assert {wk.kind for wk in _definition("jellyfin").widget_kinds} == {
|
||||||
"activity",
|
"activity",
|
||||||
"now_playing",
|
"now_playing",
|
||||||
"stat",
|
"stat",
|
||||||
"stats_overview",
|
"stats_overview",
|
||||||
}
|
}
|
||||||
assert get_service_definition("nextcloud").widget_kinds == []
|
assert _definition("nextcloud").widget_kinds == []
|
||||||
assert get_service_definition("authentik").widget_kinds == []
|
assert {widget.kind for widget in _definition("authentik").widget_kinds} == {
|
||||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
"access_summary",
|
||||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
"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():
|
def test_chart_widget_kinds_expose_unit_and_scale_options():
|
||||||
@@ -128,25 +143,28 @@ def test_chart_widget_kinds_expose_unit_and_scale_options():
|
|||||||
scales = ["auto", "k", "m", "g", "t"]
|
scales = ["auto", "k", "m", "g", "t"]
|
||||||
|
|
||||||
prom_chart = get_widget_kind("prometheus", "chart")
|
prom_chart = get_widget_kind("prometheus", "chart")
|
||||||
assert prom_chart is not None
|
assert prom_chart
|
||||||
prom_props = prom_chart.config_schema["properties"]
|
prom_props = prom_chart.config_schema["properties"]
|
||||||
assert prom_props["unit"]["enum"] == units
|
assert prom_props["unit"]["enum"] == units
|
||||||
assert prom_props["scale"]["enum"] == scales
|
assert prom_props["scale"]["enum"] == scales
|
||||||
|
|
||||||
qbit_speed = get_widget_kind("qbittorrent", "speed")
|
qbit_speed = get_widget_kind("qbittorrent", "speed")
|
||||||
assert qbit_speed is not None
|
assert qbit_speed
|
||||||
qbit_props = qbit_speed.config_schema["properties"]
|
qbit_props = qbit_speed.config_schema["properties"]
|
||||||
assert qbit_props["unit"]["enum"] == units
|
assert qbit_props["unit"]["enum"] == units
|
||||||
assert qbit_props["scale"]["enum"] == scales
|
assert qbit_props["scale"]["enum"] == scales
|
||||||
# qBittorrent speed data is bytes/sec by default.
|
# qBittorrent speed data is bytes/sec by default.
|
||||||
assert qbit_speed.default_config["unit"] == "bytes_per_sec"
|
assert qbit_speed.default_config["unit"] == "bytes_per_sec"
|
||||||
# totals/active are not graphs and stay option-less.
|
# totals/active are not graphs and stay option-less.
|
||||||
assert "unit" not in get_widget_kind("qbittorrent", "totals").config_schema["properties"]
|
qbit_totals = get_widget_kind("qbittorrent", "totals")
|
||||||
assert "unit" not in get_widget_kind("qbittorrent", "active").config_schema["properties"]
|
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 "grafana_url" in schema["properties"]
|
assert "grafana_url" in schema["properties"]
|
||||||
|
|
||||||
@@ -206,7 +224,7 @@ def test_list_service_types(client):
|
|||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"ssh_tasks",
|
"remote_machine",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -321,7 +339,7 @@ def test_service_test_uses_stored_secrets_when_not_reentered(client):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json()["ok"] is True
|
assert res.json()["ok"]
|
||||||
# The stored grafana_api_key was used for the request (not empty).
|
# The stored grafana_api_key was used for the request (not empty).
|
||||||
headers = mock_post.call_args.kwargs["headers"]
|
headers = mock_post.call_args.kwargs["headers"]
|
||||||
assert headers["Authorization"] == "Bearer secret-token"
|
assert headers["Authorization"] == "Bearer secret-token"
|
||||||
@@ -354,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({"grafana_url": bad_url, "timeout_seconds": 5})
|
model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service_type", ["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):
|
||||||
@@ -451,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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -539,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(
|
||||||
{
|
{
|
||||||
@@ -593,6 +612,7 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
|
|||||||
|
|
||||||
# Jellyfin config gained jellyseerr_url; the api key is now a secret.
|
# 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 "jellyseerr_api_key" not in migrated["config"]
|
assert "jellyseerr_api_key" not in migrated["config"]
|
||||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
|
||||||
@@ -621,11 +641,13 @@ def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
|
|||||||
store.ensure_defaults() # runs the config->secret migration
|
store.ensure_defaults() # runs the config->secret migration
|
||||||
|
|
||||||
migrated = store.get_service(jellyfin["id"])
|
migrated = store.get_service(jellyfin["id"])
|
||||||
|
assert migrated
|
||||||
assert "jellyseerr_api_key" not in migrated["config"]
|
assert "jellyseerr_api_key" not in migrated["config"]
|
||||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||||
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
|
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
|
||||||
store.ensure_defaults()
|
store.ensure_defaults()
|
||||||
migrated = store.get_service(jellyfin["id"])
|
migrated = store.get_service(jellyfin["id"])
|
||||||
|
assert migrated
|
||||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) == []
|
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -16,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,
|
||||||
@@ -416,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]()
|
||||||
@@ -437,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="")
|
||||||
@@ -458,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),
|
||||||
@@ -1059,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,
|
||||||
},
|
},
|
||||||
@@ -1067,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,
|
||||||
},
|
},
|
||||||
@@ -1132,8 +1160,8 @@ async def test_qbittorrent_totals_counts_all_torrents():
|
|||||||
|
|
||||||
|
|
||||||
@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 all active download/upload states, including queued work."""
|
"""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()
|
||||||
@@ -1149,15 +1177,11 @@ async def test_qbittorrent_active_filters_dl_ul_only():
|
|||||||
result = await adapter.fetch(service, "active", {})
|
result = await adapter.fetch(service, "active", {})
|
||||||
|
|
||||||
active = result["torrents"]
|
active = result["torrents"]
|
||||||
assert len(active) == 5
|
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]
|
||||||
assert "Forced download" in names
|
assert all((torrent["dl_speed"] or 0) > 0 or (torrent["up_speed"] or 0) > 0 for torrent in active)
|
||||||
assert "Stalled upload" in names
|
|
||||||
assert "Queued" in names
|
|
||||||
# Paused torrents remain excluded, but queued transfer work is visible.
|
|
||||||
assert "Paused" not in names
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1203,6 +1227,21 @@ async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path):
|
|||||||
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
|
||||||
|
|||||||
+32
-10
@@ -46,7 +46,7 @@ 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 range controls, filtering, and display formatting remain consistent across Prometheus and qBittorrent charts.
|
- 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`).
|
||||||
@@ -134,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.
|
||||||
@@ -200,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.
|
||||||
@@ -311,7 +321,12 @@ These do not reference a service.
|
|||||||
- 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.
|
- 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.
|
- 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 widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
||||||
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, selectable chart windows, and paginated scheduled-action history.
|
- 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.
|
- 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.
|
- 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.
|
- Persistent polling failures should be visible in the service UI and application metrics; a new notification channel is not required for the first release.
|
||||||
@@ -394,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.
|
||||||
@@ -415,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.
|
||||||
@@ -445,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.
|
||||||
|
|
||||||
@@ -542,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.
|
||||||
|
|||||||
+11
-5
@@ -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,
|
||||||
@@ -97,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}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+28
-58
@@ -16,8 +16,6 @@ import type {
|
|||||||
SavedTask,
|
SavedTask,
|
||||||
SavedTaskInput,
|
SavedTaskInput,
|
||||||
SavedTaskRun,
|
SavedTaskRun,
|
||||||
MonitoringMachine,
|
|
||||||
MonitoringMachineInput,
|
|
||||||
MediaIndexStatus,
|
MediaIndexStatus,
|
||||||
MediaIndexActionResponse,
|
MediaIndexActionResponse,
|
||||||
MediaQueryResponse,
|
MediaQueryResponse,
|
||||||
@@ -27,13 +25,11 @@ import type {
|
|||||||
ResolvedPath,
|
ResolvedPath,
|
||||||
ResetLocalDatabaseInput,
|
ResetLocalDatabaseInput,
|
||||||
ResetLocalDatabaseResponse,
|
ResetLocalDatabaseResponse,
|
||||||
SSHValidationResult,
|
|
||||||
DashboardShortcut,
|
DashboardShortcut,
|
||||||
DashboardShortcutInput,
|
DashboardShortcutInput,
|
||||||
AlertmanagerAlertSummary,
|
AlertmanagerAlertSummary,
|
||||||
AlertmanagerStatus,
|
AlertmanagerStatus,
|
||||||
PrometheusStatus,
|
PrometheusStatus,
|
||||||
PrometheusTarget,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
buildHeaders,
|
buildHeaders,
|
||||||
@@ -70,9 +66,7 @@ export const fetchUsers = (jellyfinServiceId?: string) =>
|
|||||||
// 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");
|
||||||
@@ -98,8 +92,6 @@ 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;
|
||||||
@@ -130,11 +122,17 @@ export const deleteSSHKey = (keyId: 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 });
|
||||||
|
export const fetchSavedTaskRuns = (
|
||||||
|
taskId: string,
|
||||||
|
serviceId: string,
|
||||||
|
limit = 10,
|
||||||
|
) =>
|
||||||
get<{ items: SavedTaskRun[]; total: number }>(
|
get<{ items: SavedTaskRun[]; total: number }>(
|
||||||
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
||||||
{
|
{
|
||||||
|
service_id: serviceId,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -154,9 +152,11 @@ export const saveTask = (task: SavedTaskInput) =>
|
|||||||
}
|
}
|
||||||
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)}`,
|
||||||
|
);
|
||||||
|
export const runTask = (taskId: string, serviceId: string) =>
|
||||||
post<{
|
post<{
|
||||||
task_id: string;
|
task_id: string;
|
||||||
task_name: string;
|
task_name: string;
|
||||||
@@ -166,37 +166,10 @@ export const runTask = (taskId: string, serviceId?: string) =>
|
|||||||
exit_status: number;
|
exit_status: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
}>(
|
}>(`/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`, {
|
||||||
serviceId
|
task_id: taskId,
|
||||||
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
|
|
||||||
: "/api/tasks/run",
|
|
||||||
{ 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",
|
||||||
@@ -253,34 +226,34 @@ export const queryMedia = (params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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 },
|
||||||
);
|
);
|
||||||
@@ -309,6 +282,3 @@ export const fetchPrometheusStatus = (serviceId?: string) =>
|
|||||||
"/api/monitoring/prometheus-status",
|
"/api/monitoring/prometheus-status",
|
||||||
serviceId ? { service_id: serviceId } : undefined,
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchPrometheusTargets = () =>
|
|
||||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ export function fetchSchedulerRuns(
|
|||||||
|
|
||||||
export function fetchSchedulerSamples(
|
export function fetchSchedulerSamples(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
windowSeconds: number,
|
window: number | "all",
|
||||||
): Promise<SchedulerSamplesResponse> {
|
): Promise<SchedulerSamplesResponse> {
|
||||||
return get<SchedulerSamplesResponse>(
|
return get<SchedulerSamplesResponse>(
|
||||||
`/api/scheduler/services/${serviceId}/samples`,
|
`/api/scheduler/services/${serviceId}/samples`,
|
||||||
{ window_seconds: String(windowSeconds) },
|
window === "all"
|
||||||
|
? { all_values: "true" }
|
||||||
|
: { window_seconds: String(window) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges";
|
import {
|
||||||
|
DEFAULT_CHART_RANGES,
|
||||||
|
type ChartRangeOption,
|
||||||
|
type ChartRangeValue,
|
||||||
|
} from "./chartRanges";
|
||||||
import {
|
import {
|
||||||
formatScaled,
|
formatScaled,
|
||||||
metricScaleInfo,
|
metricScaleInfo,
|
||||||
@@ -72,11 +76,13 @@ interface LineSeriesChartProps {
|
|||||||
scale?: MetricScale;
|
scale?: MetricScale;
|
||||||
/** Available displayed time ranges. Defaults to the shared range choices. */
|
/** Available displayed time ranges. Defaults to the shared range choices. */
|
||||||
rangeOptions?: readonly ChartRangeOption[];
|
rangeOptions?: readonly ChartRangeOption[];
|
||||||
/** Initial uncontrolled range. Defaults to the largest available option. */
|
/** Whether to render the interactive range selector. */
|
||||||
defaultRangeSeconds?: number;
|
showRangeSelector?: boolean;
|
||||||
|
/** Initial uncontrolled range. Defaults to the largest numeric option. */
|
||||||
|
defaultRangeSeconds?: ChartRangeValue;
|
||||||
/** Controlled range for consumers that refetch when the selection changes. */
|
/** Controlled range for consumers that refetch when the selection changes. */
|
||||||
rangeSeconds?: number;
|
rangeSeconds?: ChartRangeValue;
|
||||||
onRangeChange?: (rangeSeconds: number) => void;
|
onRangeChange?: (range: ChartRangeValue) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
||||||
@@ -86,13 +92,18 @@ export function LineSeriesChart({
|
|||||||
unit = "none",
|
unit = "none",
|
||||||
scale = "auto",
|
scale = "auto",
|
||||||
rangeOptions = DEFAULT_CHART_RANGES,
|
rangeOptions = DEFAULT_CHART_RANGES,
|
||||||
|
showRangeSelector = true,
|
||||||
defaultRangeSeconds,
|
defaultRangeSeconds,
|
||||||
rangeSeconds,
|
rangeSeconds,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
}: LineSeriesChartProps) {
|
}: LineSeriesChartProps) {
|
||||||
const initialRange =
|
const initialRange =
|
||||||
defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value;
|
defaultRangeSeconds ??
|
||||||
const [localRangeSeconds, setLocalRangeSeconds] = useState(initialRange);
|
[...rangeOptions].reverse().find((range) => typeof range.value === "number")
|
||||||
|
?.value;
|
||||||
|
const [localRangeSeconds, setLocalRangeSeconds] = useState<
|
||||||
|
ChartRangeValue | undefined
|
||||||
|
>(initialRange);
|
||||||
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
||||||
const latestTimestamp = series.reduce(
|
const latestTimestamp = series.reduce(
|
||||||
(max, seriesItem) =>
|
(max, seriesItem) =>
|
||||||
@@ -102,7 +113,8 @@ export function LineSeriesChart({
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const cutoff = selectedRangeSeconds
|
const cutoff =
|
||||||
|
typeof selectedRangeSeconds === "number"
|
||||||
? latestTimestamp - selectedRangeSeconds * 1000
|
? latestTimestamp - selectedRangeSeconds * 1000
|
||||||
: null;
|
: null;
|
||||||
const visibleSeries =
|
const visibleSeries =
|
||||||
@@ -125,18 +137,20 @@ export function LineSeriesChart({
|
|||||||
formatScaled(value, scaleInfo, unit);
|
formatScaled(value, scaleInfo, unit);
|
||||||
|
|
||||||
function handleRangeChange(value: string) {
|
function handleRangeChange(value: string) {
|
||||||
const nextRange = Number(value);
|
const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value);
|
||||||
setLocalRangeSeconds(nextRange);
|
setLocalRangeSeconds(nextRange);
|
||||||
onRangeChange?.(nextRange);
|
onRangeChange?.(nextRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rangeOptions.length > 0 && (
|
{showRangeSelector && rangeOptions.length > 0 && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
|
selectedRangeSeconds === undefined
|
||||||
|
? undefined
|
||||||
|
: String(selectedRangeSeconds)
|
||||||
}
|
}
|
||||||
onValueChange={handleRangeChange}
|
onValueChange={handleRangeChange}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ function WidgetConfigEditor({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{enumOptions.map((opt) => (
|
{enumOptions.map((opt) => (
|
||||||
<SelectItem key={opt} value={opt}>
|
<SelectItem key={opt} value={opt}>
|
||||||
{opt.replace(/_/g, " ")}
|
{opt === "all" ? "All values" : opt.replace(/_/g, " ")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -250,7 +250,11 @@ export function WidgetConfigDialog({
|
|||||||
const instances = useMemo(() => instancesData ?? [], [instancesData]);
|
const instances = useMemo(() => instancesData ?? [], [instancesData]);
|
||||||
const { data: servicesData } = useServiceInstances();
|
const { data: servicesData } = useServiceInstances();
|
||||||
const services = useMemo(() => servicesData ?? [], [servicesData]);
|
const services = useMemo(() => servicesData ?? [], [servicesData]);
|
||||||
const { data: tasksData } = useTasks();
|
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 tasks = useMemo(() => tasksData ?? [], [tasksData]);
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
@@ -459,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,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } 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";
|
import { chartRangesThrough } from "../chartRanges";
|
||||||
@@ -38,6 +38,29 @@ describe("LineSeriesChart", () => {
|
|||||||
).toHaveTextContent("2 hours");
|
).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,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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,45 @@
|
|||||||
|
export type ChartRangeValue = number | "all";
|
||||||
|
|
||||||
export interface ChartRangeOption {
|
export interface ChartRangeOption {
|
||||||
value: number;
|
value: ChartRangeValue;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared range choices used by every time-series chart. */
|
interface FiniteChartRange extends ChartRangeOption {
|
||||||
export const DEFAULT_CHART_RANGES: ChartRangeOption[] = [
|
key: string;
|
||||||
{ value: 900, label: "15 minutes" },
|
value: number;
|
||||||
{ value: 1800, label: "30 minutes" },
|
}
|
||||||
{ value: 3600, label: "1 hour" },
|
|
||||||
{ value: 21600, label: "6 hours" },
|
/** Canonical finite windows for chart configuration and display filtering. */
|
||||||
{ value: 86400, label: "24 hours" },
|
const FINITE_CHART_RANGES: readonly FiniteChartRange[] = [
|
||||||
{ value: 604800, label: "7 days" },
|
{ 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 {
|
function formatRangeLabel(seconds: number): string {
|
||||||
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
||||||
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
||||||
@@ -22,27 +49,33 @@ function formatRangeLabel(seconds: number): string {
|
|||||||
|
|
||||||
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
||||||
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
||||||
return [DEFAULT_CHART_RANGES[0]];
|
return [DEFAULT_CHART_RANGES[0], ALL_VALUES_CHART_RANGE];
|
||||||
}
|
}
|
||||||
const ranges = DEFAULT_CHART_RANGES.filter(
|
const ranges = FINITE_CHART_RANGES.filter(
|
||||||
(range) => range.value < maxSeconds,
|
(range) => range.value < maxSeconds,
|
||||||
);
|
);
|
||||||
const exact = DEFAULT_CHART_RANGES.find(
|
const exact = FINITE_CHART_RANGES.find((range) => range.value === maxSeconds);
|
||||||
(range) => range.value === maxSeconds,
|
return [
|
||||||
);
|
...(exact
|
||||||
return exact
|
|
||||||
? [...ranges, exact]
|
? [...ranges, exact]
|
||||||
: [...ranges, { value: maxSeconds, label: formatRangeLabel(maxSeconds) }];
|
: [
|
||||||
|
...ranges,
|
||||||
|
{ value: maxSeconds, label: formatRangeLabel(maxSeconds) },
|
||||||
|
]),
|
||||||
|
ALL_VALUES_CHART_RANGE,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeSecondsFromWindow(window: unknown): number {
|
export function rangeSecondsFromWindow(window: unknown): number {
|
||||||
const values: Record<string, number> = {
|
return (
|
||||||
"15m": 900,
|
FINITE_CHART_RANGES.find((range) => range.key === String(window))?.value ??
|
||||||
"30m": 1800,
|
3600
|
||||||
"1h": 3600,
|
);
|
||||||
"6h": 21600,
|
}
|
||||||
"24h": 86400,
|
|
||||||
"7d": 604800,
|
/** Numeric chart windows suitable for sources with bounded local retention. */
|
||||||
};
|
export function numericChartRangesThrough(maxSeconds: number): number[] {
|
||||||
return values[String(window)] ?? 3600;
|
return FINITE_CHART_RANGES.flatMap((range) =>
|
||||||
|
range.value <= maxSeconds ? [range.value] : [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -7,28 +7,28 @@ import {
|
|||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,9 +41,9 @@ export function useJobTemplates() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
fetchAlertmanagerAlerts,
|
fetchAlertmanagerAlerts,
|
||||||
fetchAlertmanagerStatus,
|
fetchAlertmanagerStatus,
|
||||||
fetchPrometheusStatus,
|
fetchPrometheusStatus,
|
||||||
fetchPrometheusTargets,
|
|
||||||
fetchMonitoringMachines,
|
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useAlertmanagerAlerts(serviceId?: string) {
|
export function useAlertmanagerAlerts(serviceId?: string) {
|
||||||
@@ -36,23 +34,3 @@ export function usePrometheusStatus(serviceId?: string) {
|
|||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePrometheusTargets() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["observability", "prometheus-targets"],
|
|
||||||
queryFn: fetchPrometheusTargets,
|
|
||||||
retry: 2,
|
|
||||||
staleTime: 10_000,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMonitoringMachines() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["monitoring", "machines"],
|
|
||||||
queryFn: fetchMonitoringMachines,
|
|
||||||
retry: 2,
|
|
||||||
staleTime: 10_000,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fetchSchedulerStatus,
|
fetchSchedulerStatus,
|
||||||
runSchedulerAction,
|
runSchedulerAction,
|
||||||
} from "../api/scheduler";
|
} from "../api/scheduler";
|
||||||
|
import type { ChartRangeValue } from "../components/chartRanges";
|
||||||
|
|
||||||
export function useSchedulerStatus(serviceId: string) {
|
export function useSchedulerStatus(serviceId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -24,10 +25,13 @@ export function useSchedulerRuns(serviceId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchedulerSamples(serviceId: string, windowSeconds: number) {
|
export function useSchedulerSamples(
|
||||||
|
serviceId: string,
|
||||||
|
window: ChartRangeValue,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheduler", "samples", serviceId, windowSeconds],
|
queryKey: ["scheduler", "samples", serviceId, window],
|
||||||
queryFn: () => fetchSchedulerSamples(serviceId, windowSeconds),
|
queryFn: () => fetchSchedulerSamples(serviceId, window),
|
||||||
enabled: Boolean(serviceId),
|
enabled: Boolean(serviceId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,22 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
deleteMonitoringMachine,
|
|
||||||
deleteSSHKey,
|
deleteSSHKey,
|
||||||
fetchMonitoringSettings,
|
|
||||||
fetchSSHKeys,
|
fetchSSHKeys,
|
||||||
fetchSavedTaskRuns,
|
fetchSavedTaskRuns,
|
||||||
fetchSavedTasks,
|
fetchSavedTasks,
|
||||||
generateSSHKey,
|
generateSSHKey,
|
||||||
resetLocalDatabase,
|
resetLocalDatabase,
|
||||||
saveMonitoringMachine,
|
|
||||||
saveSSHKey,
|
saveSSHKey,
|
||||||
saveTask,
|
saveTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
runTask,
|
runTask,
|
||||||
testMonitoringMachineSSH,
|
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import type {
|
import type {
|
||||||
MonitoringMachineInput,
|
|
||||||
ResetLocalDatabaseInput,
|
ResetLocalDatabaseInput,
|
||||||
SavedTaskInput,
|
SavedTaskInput,
|
||||||
SSHKeyInput,
|
SSHKeyInput,
|
||||||
SSHValidationResult,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export function useMonitoringSettings() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["settings", "monitoring-machines"],
|
|
||||||
queryFn: fetchMonitoringSettings,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSSHKeys() {
|
export function useSSHKeys() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["settings", "ssh-keys"],
|
queryKey: ["settings", "ssh-keys"],
|
||||||
@@ -71,19 +57,20 @@ export function useDeleteSSHKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTasks() {
|
export function useTasks(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["tasks"],
|
queryKey: ["tasks", serviceId ?? "none"],
|
||||||
queryFn: fetchSavedTasks,
|
queryFn: () => fetchSavedTasks(serviceId ?? ""),
|
||||||
|
enabled: Boolean(serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTaskRuns(taskId?: string) {
|
export function useTaskRuns(taskId?: string, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["tasks", taskId ?? "none", "runs"],
|
queryKey: ["tasks", serviceId ?? "none", taskId ?? "none", "runs"],
|
||||||
queryFn: () => fetchSavedTaskRuns(taskId ?? ""),
|
queryFn: () => fetchSavedTaskRuns(taskId ?? "", serviceId ?? ""),
|
||||||
enabled: Boolean(taskId),
|
enabled: Boolean(taskId && serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,7 +88,13 @@ export function useSaveTask() {
|
|||||||
export function useDeleteTask() {
|
export function useDeleteTask() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (taskId: string) => deleteTask(taskId),
|
mutationFn: ({
|
||||||
|
taskId,
|
||||||
|
serviceId,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
serviceId: string;
|
||||||
|
}) => deleteTask(taskId, serviceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
},
|
},
|
||||||
@@ -116,7 +109,7 @@ export function useRunTask() {
|
|||||||
serviceId,
|
serviceId,
|
||||||
}: {
|
}: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
serviceId?: string;
|
serviceId: string;
|
||||||
}) => runTask(taskId, serviceId),
|
}) => runTask(taskId, serviceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
@@ -124,35 +117,6 @@ export function useRunTask() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveMonitoringMachine() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (machine: MonitoringMachineInput) =>
|
|
||||||
saveMonitoringMachine(machine),
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTestMonitoringMachineSSH() {
|
|
||||||
return useMutation<SSHValidationResult, Error, MonitoringMachineInput>({
|
|
||||||
mutationFn: testMonitoringMachineSSH,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteMonitoringMachine() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (machineId: string) => deleteMonitoringMachine(machineId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResetLocalDatabase() {
|
export function useResetLocalDatabase() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
|
import {
|
||||||
|
configuredNavEntries,
|
||||||
|
remoteMachineNavEntries,
|
||||||
|
SERVICE_TYPE_NAV_ENTRIES,
|
||||||
|
} from "../navEntries";
|
||||||
|
|
||||||
describe("navEntries", () => {
|
describe("navEntries", () => {
|
||||||
it("returns no entries when no types are configured", () => {
|
it("returns no entries when no types are configured", () => {
|
||||||
@@ -13,37 +17,69 @@ describe("navEntries", () => {
|
|||||||
expect(entries[0].path).toBe("/services/jellyfin");
|
expect(entries[0].path).toBe("/services/jellyfin");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
|
it("creates one top-level entry per enabled remote machine", () => {
|
||||||
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
const entries = remoteMachineNavEntries([
|
||||||
expect(entries).toHaveLength(1);
|
{
|
||||||
expect(entries[0].label).toBe("SSH Tasks");
|
id: "storage",
|
||||||
|
name: "Storage",
|
||||||
|
service_type: "remote_machine",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
name: "Worker",
|
||||||
|
service_type: "remote_machine",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "disabled",
|
||||||
|
name: "Disabled",
|
||||||
|
service_type: "remote_machine",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.label)).toEqual(["Storage", "Worker"]);
|
||||||
|
expect(entries.map((entry) => entry.path)).toEqual([
|
||||||
|
"/services/remote_machine/storage",
|
||||||
|
"/services/remote_machine/worker",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns all observability entries", () => {
|
it("returns all observability entries", () => {
|
||||||
const entries = configuredNavEntries(
|
const entries = configuredNavEntries(
|
||||||
new Set(["alertmanager", "prometheus"]),
|
new Set(["alertmanager", "prometheus"]),
|
||||||
);
|
);
|
||||||
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
|
expect(entries.map((entry) => entry.label)).toEqual([
|
||||||
|
"Alertmanager",
|
||||||
|
"Prometheus",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns Backups + Authentik when configured", () => {
|
it("returns Backups + Authentik when configured", () => {
|
||||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
||||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
|
expect(entries.map((entry) => entry.label)).toEqual([
|
||||||
|
"Backups",
|
||||||
|
"Authentik",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nextcloud has no nav entries in the static map", () => {
|
it("keeps non-operational service types out of the static map", () => {
|
||||||
expect(
|
expect(
|
||||||
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
|
SERVICE_TYPE_NAV_ENTRIES.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.serviceType === "nextcloud" ||
|
||||||
|
entry.serviceType === "remote_machine",
|
||||||
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves declaration order across mixed types", () => {
|
it("preserves declaration order across mixed navigable types", () => {
|
||||||
const entries = configuredNavEntries(
|
const entries = configuredNavEntries(
|
||||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
new Set(["authentik", "remote_machine", "jellyfin"]),
|
||||||
);
|
);
|
||||||
expect(entries.map((e) => e.label)).toEqual([
|
expect(entries.map((entry) => entry.label)).toEqual([
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
"SSH Tasks",
|
|
||||||
"Authentik",
|
"Authentik",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export interface NavEntry {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteMachineNavSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
service_type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static mapping from service type to its conditional nav entry.
|
* Static mapping from service type to its conditional nav entry.
|
||||||
* Uses the service type's display name. One entry per type.
|
* Uses the service type's display name. One entry per type.
|
||||||
@@ -37,12 +44,6 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
|||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
path: "/services/jellyfin",
|
path: "/services/jellyfin",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
serviceType: "ssh_tasks",
|
|
||||||
label: "SSH Tasks",
|
|
||||||
icon: Server,
|
|
||||||
path: "/services/ssh_tasks",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
serviceType: "alertmanager",
|
serviceType: "alertmanager",
|
||||||
label: "Alertmanager",
|
label: "Alertmanager",
|
||||||
@@ -84,3 +85,20 @@ export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
|
|||||||
configuredTypes.has(e.serviceType),
|
configuredTypes.has(e.serviceType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One direct sidebar entry for each enabled Remote Machine service. */
|
||||||
|
export function remoteMachineNavEntries(
|
||||||
|
services: RemoteMachineNavSource[],
|
||||||
|
): NavEntry[] {
|
||||||
|
return services
|
||||||
|
.filter(
|
||||||
|
(service) => service.enabled && service.service_type === "remote_machine",
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
.map((service) => ({
|
||||||
|
serviceType: service.service_type,
|
||||||
|
label: service.name,
|
||||||
|
icon: Server,
|
||||||
|
path: `/services/remote_machine/${encodeURIComponent(service.id)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ describe("service registry", () => {
|
|||||||
it("registers the backend service types", () => {
|
it("registers the backend service types", () => {
|
||||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
|
"authentik",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"ssh_tasks",
|
"remote_machine",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,7 +31,12 @@ describe("service registry", () => {
|
|||||||
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||||
"active_alerts",
|
"active_alerts",
|
||||||
]);
|
]);
|
||||||
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.authentik.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"access_summary",
|
||||||
|
"groups",
|
||||||
|
"applications",
|
||||||
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([
|
||||||
"task_output",
|
"task_output",
|
||||||
]);
|
]);
|
||||||
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
||||||
@@ -41,10 +47,17 @@ describe("service registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes unit/scale options on graph widget kinds", () => {
|
it("exposes unit/scale options on graph widget kinds", () => {
|
||||||
const propsOf = (binding: { configSchema: Record<string, unknown> } | undefined) =>
|
const propsOf = (
|
||||||
(binding?.configSchema as { properties?: Record<string, { enum?: string[] }> } | undefined)
|
binding: { configSchema: Record<string, unknown> } | undefined,
|
||||||
?.properties ?? {};
|
) =>
|
||||||
const chart = getServiceBinding("prometheus")?.widgets.find((w) => w.kind === "chart");
|
(
|
||||||
|
binding?.configSchema as
|
||||||
|
| { properties?: Record<string, { enum?: string[] }> }
|
||||||
|
| undefined
|
||||||
|
)?.properties ?? {};
|
||||||
|
const chart = getServiceBinding("prometheus")?.widgets.find(
|
||||||
|
(w) => w.kind === "chart",
|
||||||
|
);
|
||||||
const speed = getServiceBinding("qbittorrent")?.widgets.find(
|
const speed = getServiceBinding("qbittorrent")?.widgets.find(
|
||||||
(w) => w.kind === "speed",
|
(w) => w.kind === "speed",
|
||||||
);
|
);
|
||||||
@@ -56,6 +69,44 @@ describe("service registry", () => {
|
|||||||
expect(speed?.defaultConfig.unit).toBe("bytes_per_sec");
|
expect(speed?.defaultConfig.unit).toBe("bytes_per_sec");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shares expanded chart windows and an all-retained option", () => {
|
||||||
|
const propertiesOf = (kind: string) => {
|
||||||
|
const binding = SERVICE_REGISTRY[
|
||||||
|
kind === "speed" ? "qbittorrent" : "prometheus"
|
||||||
|
].widgets.find((widget) => widget.kind === kind);
|
||||||
|
const schema = binding?.configSchema as
|
||||||
|
| { properties?: Record<string, { enum?: string[] }> }
|
||||||
|
| undefined;
|
||||||
|
return schema?.properties ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(propertiesOf("chart").window?.enum).toEqual([
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"3h",
|
||||||
|
"6h",
|
||||||
|
"12h",
|
||||||
|
"24h",
|
||||||
|
"2d",
|
||||||
|
"7d",
|
||||||
|
"14d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(propertiesOf("speed").window_seconds?.enum).toEqual([
|
||||||
|
"300",
|
||||||
|
"900",
|
||||||
|
"1800",
|
||||||
|
"3600",
|
||||||
|
"10800",
|
||||||
|
"21600",
|
||||||
|
"43200",
|
||||||
|
"86400",
|
||||||
|
"all",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves a prometheus metric widget via the services list", () => {
|
it("resolves a prometheus metric widget via the services list", () => {
|
||||||
const widget: WidgetInstance = {
|
const widget: WidgetInstance = {
|
||||||
id: "w1",
|
id: "w1",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||||
|
import { AuthentikAccessSummaryWidget } from "../widgets/AuthentikAccessSummaryWidget";
|
||||||
|
import { AuthentikApplicationsWidget } from "../widgets/AuthentikApplicationsWidget";
|
||||||
|
import { AuthentikGroupsWidget } from "../widgets/AuthentikGroupsWidget";
|
||||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||||
import { MetricChartWidget } from "../widgets/MetricChartWidget";
|
import { MetricChartWidget } from "../widgets/MetricChartWidget";
|
||||||
import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget";
|
import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget";
|
||||||
@@ -14,6 +17,10 @@ import { RequestStatWidget } from "../widgets/RequestStatWidget";
|
|||||||
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
|
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
|
||||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
import { StaticWidget } from "../widgets/StaticWidget";
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
|
import {
|
||||||
|
numericChartRangesThrough,
|
||||||
|
PROMETHEUS_WINDOW_VALUES,
|
||||||
|
} from "../components/chartRanges";
|
||||||
import type {
|
import type {
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
ServiceTypeInfo,
|
ServiceTypeInfo,
|
||||||
@@ -61,6 +68,10 @@ const UNIT_VALUES = [
|
|||||||
"seconds",
|
"seconds",
|
||||||
];
|
];
|
||||||
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
|
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
|
||||||
|
const SPEED_WINDOW_VALUES = [
|
||||||
|
...numericChartRangesThrough(86_400).map(String),
|
||||||
|
"all",
|
||||||
|
];
|
||||||
const AXIS_FORMAT_PROPERTIES = {
|
const AXIS_FORMAT_PROPERTIES = {
|
||||||
unit: {
|
unit: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -76,6 +87,53 @@ const AXIS_FORMAT_PROPERTIES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||||
|
authentik: {
|
||||||
|
serviceType: "authentik",
|
||||||
|
name: "Authentik",
|
||||||
|
description: "Read-only user directory, group, and application metadata.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "access_summary",
|
||||||
|
name: "User access summary",
|
||||||
|
description:
|
||||||
|
"Group membership and explicit privileged flags; not effective authorization.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: { limit: 10 },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
component: AuthentikAccessSummaryWidget,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "groups",
|
||||||
|
name: "Groups",
|
||||||
|
description: "Read-only Authentik group list.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: { limit: 10 },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
component: AuthentikGroupsWidget,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "applications",
|
||||||
|
name: "Applications",
|
||||||
|
description: "Read-only Authentik application list.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: { limit: 10 },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
component: AuthentikApplicationsWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
alertmanager: {
|
alertmanager: {
|
||||||
serviceType: "alertmanager",
|
serviceType: "alertmanager",
|
||||||
name: "Alertmanager",
|
name: "Alertmanager",
|
||||||
@@ -136,7 +194,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
enum: PROMETHEUS_WINDOW_VALUES,
|
||||||
|
description: "Maximum history fetched for the chart",
|
||||||
},
|
},
|
||||||
...AXIS_FORMAT_PROPERTIES,
|
...AXIS_FORMAT_PROPERTIES,
|
||||||
},
|
},
|
||||||
@@ -183,7 +242,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
enum: PROMETHEUS_WINDOW_VALUES,
|
||||||
|
description: "Time window used to calculate the average",
|
||||||
},
|
},
|
||||||
unit: { type: "string" },
|
unit: { type: "string" },
|
||||||
},
|
},
|
||||||
@@ -232,7 +292,9 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
properties: {
|
properties: {
|
||||||
window_seconds: {
|
window_seconds: {
|
||||||
type: "integer",
|
type: "integer",
|
||||||
description: "Maximum data window available to the chart",
|
enum: SPEED_WINDOW_VALUES,
|
||||||
|
description:
|
||||||
|
"Maximum history fetched for the chart, or all retained samples",
|
||||||
},
|
},
|
||||||
...AXIS_FORMAT_PROPERTIES,
|
...AXIS_FORMAT_PROPERTIES,
|
||||||
},
|
},
|
||||||
@@ -310,10 +372,10 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
description: "Self-hosted files and collaboration.",
|
description: "Self-hosted files and collaboration.",
|
||||||
widgets: [],
|
widgets: [],
|
||||||
},
|
},
|
||||||
ssh_tasks: {
|
remote_machine: {
|
||||||
serviceType: "ssh_tasks",
|
serviceType: "remote_machine",
|
||||||
name: "SSH task runner",
|
name: "Remote machine",
|
||||||
description: "Run reusable saved tasks over SSH and keep run history.",
|
description: "SSH transport for files and reusable actions.",
|
||||||
widgets: [
|
widgets: [
|
||||||
{
|
{
|
||||||
kind: "task_output",
|
kind: "task_output",
|
||||||
|
|||||||
@@ -124,14 +124,14 @@ function ServiceConfigFields({
|
|||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
label={
|
label={
|
||||||
type.service_type === "ssh_tasks" && key === "ssh_key_id"
|
type.service_type === "remote_machine" && key === "ssh_key_id"
|
||||||
? "SSH key"
|
? "SSH key"
|
||||||
: key
|
: key
|
||||||
}
|
}
|
||||||
htmlFor={`cfg-${key}`}
|
htmlFor={`cfg-${key}`}
|
||||||
helper={schema.description}
|
helper={schema.description}
|
||||||
>
|
>
|
||||||
{type.service_type === "ssh_tasks" && key === "ssh_key_id" ? (
|
{type.service_type === "remote_machine" && key === "ssh_key_id" ? (
|
||||||
<Select
|
<Select
|
||||||
value={String(config[key] ?? "") || noSSHKey}
|
value={String(config[key] ?? "") || noSSHKey}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
+11
-845
@@ -1,26 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type {
|
import type { SSHKey, SSHKeyInput } from "../types";
|
||||||
MonitoringMachine,
|
|
||||||
MonitoringMachineInput,
|
|
||||||
SSHKey,
|
|
||||||
SSHKeyInput,
|
|
||||||
} from "../types";
|
|
||||||
import {
|
import {
|
||||||
useDeleteMonitoringMachine,
|
|
||||||
useDeleteSSHKey,
|
useDeleteSSHKey,
|
||||||
useGenerateSSHKey,
|
useGenerateSSHKey,
|
||||||
useMonitoringSettings,
|
|
||||||
useResetLocalDatabase,
|
useResetLocalDatabase,
|
||||||
useSSHKeys,
|
useSSHKeys,
|
||||||
useSaveMonitoringMachine,
|
|
||||||
useSaveSSHKey,
|
useSaveSSHKey,
|
||||||
useTestMonitoringMachineSSH,
|
|
||||||
} from "../hooks/useSettings";
|
} from "../hooks/useSettings";
|
||||||
import { useIsMobile } from "../hooks/useIsMobile";
|
|
||||||
import { CreateServiceDialog, DashboardManagementCard } from "./ServicesPage";
|
import { CreateServiceDialog, DashboardManagementCard } from "./ServicesPage";
|
||||||
import { SheetForm } from "@/components/ui/sheet-form";
|
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { HoverEditButton } from "../components/HoverEditButton";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -67,22 +56,11 @@ import type {
|
|||||||
ServiceTypeInfo,
|
ServiceTypeInfo,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const SERVICE_OPTIONS = [
|
|
||||||
{ value: "monitoring", label: "Monitoring" },
|
|
||||||
{ value: "files", label: "Files" },
|
|
||||||
{ value: "nextcloud", label: "Nextcloud" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Radix Select disallows empty-string item values, so the "no selection" option
|
// Radix Select disallows empty-string item values, so the "no selection" option
|
||||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||||
const NONE = "__none__";
|
const NONE = "__none__";
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab = "ssh-keys" | "services" | "dashboards" | "danger";
|
||||||
| "machines"
|
|
||||||
| "ssh-keys"
|
|
||||||
| "services"
|
|
||||||
| "dashboards"
|
|
||||||
| "danger";
|
|
||||||
|
|
||||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||||
function FormField({
|
function FormField({
|
||||||
@@ -109,418 +87,6 @@ function FormField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overline section label (replaces MUI `Typography variant="overline"`). */
|
|
||||||
function SectionLabel({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="pt-1">
|
|
||||||
<p className="text-[0.7rem] leading-tight font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyMachine(
|
|
||||||
mode: MonitoringMachineInput["mode"] = "local",
|
|
||||||
): MonitoringMachineInput {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
name: mode === "local" ? "This machine" : "",
|
|
||||||
mode,
|
|
||||||
enabled: true,
|
|
||||||
services: mode === "local" ? ["monitoring", "files"] : [],
|
|
||||||
host: "",
|
|
||||||
port: 22,
|
|
||||||
username: "",
|
|
||||||
key_directory: "",
|
|
||||||
key_name: "",
|
|
||||||
ssh_key_id: "",
|
|
||||||
ssh_private_key: "",
|
|
||||||
ssh_private_key_passphrase: "",
|
|
||||||
password: "",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dirty check for the machine editor SheetForm guard (spec R4.5).
|
|
||||||
* Pragmatic field-by-field comparison of the user-editable fields. In create
|
|
||||||
* mode (editingMachine is null) the form is always dirty.
|
|
||||||
*/
|
|
||||||
function isMachineDraftDirty(
|
|
||||||
draft: MonitoringMachineInput,
|
|
||||||
editingMachine: MonitoringMachine | null,
|
|
||||||
): boolean {
|
|
||||||
if (!editingMachine) return true;
|
|
||||||
return (
|
|
||||||
draft.name !== editingMachine.name ||
|
|
||||||
draft.host !== editingMachine.host ||
|
|
||||||
draft.mode !== editingMachine.mode ||
|
|
||||||
draft.port !== editingMachine.port ||
|
|
||||||
draft.username !== editingMachine.username ||
|
|
||||||
draft.ssh_key_id !== editingMachine.ssh_key_id ||
|
|
||||||
draft.enabled !== editingMachine.enabled ||
|
|
||||||
draft.notes !== editingMachine.notes ||
|
|
||||||
JSON.stringify([...draft.services].sort()) !==
|
|
||||||
JSON.stringify([...editingMachine.services].sort())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MachineEditor({
|
|
||||||
title,
|
|
||||||
hint,
|
|
||||||
machine,
|
|
||||||
sshKeys,
|
|
||||||
editingMachine,
|
|
||||||
onChange,
|
|
||||||
onValidateSSH,
|
|
||||||
isValidatingSSH,
|
|
||||||
sshValidationMessage,
|
|
||||||
sshValidationError,
|
|
||||||
sshValidationStatus,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
hint?: string;
|
|
||||||
machine: MonitoringMachineInput;
|
|
||||||
sshKeys: SSHKey[];
|
|
||||||
editingMachine?: MonitoringMachine | null;
|
|
||||||
onChange: (
|
|
||||||
draft:
|
|
||||||
| MonitoringMachineInput
|
|
||||||
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
|
|
||||||
) => void;
|
|
||||||
onValidateSSH: () => void;
|
|
||||||
isValidatingSSH: boolean;
|
|
||||||
sshValidationMessage: string;
|
|
||||||
sshValidationError: string;
|
|
||||||
sshValidationStatus: string;
|
|
||||||
}) {
|
|
||||||
const draft = machine;
|
|
||||||
const setDraft = onChange;
|
|
||||||
const isLocal = draft.mode === "local";
|
|
||||||
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
|
||||||
const enabledServices = draft.services.length;
|
|
||||||
const placeholderIfSet = (isSet: boolean | undefined) =>
|
|
||||||
isSet ? "Set, not shown" : undefined;
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col gap-4 p-3">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold">{title}</p>
|
|
||||||
{hint ? (
|
|
||||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
|
||||||
<Badge variant="outline">{draft.mode}</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{draft.enabled ? "enabled" : "disabled"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">{`${enabledServices} services`}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-12 gap-2">
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="General"
|
|
||||||
description="Name, mode, enabled state, and service roles."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Name" htmlFor="machine-name">
|
|
||||||
<Input
|
|
||||||
id="machine-name"
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({ ...current, name: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-3">
|
|
||||||
{editingMachine ? (
|
|
||||||
<FormField label="Mode">
|
|
||||||
<Input value={draft.mode} disabled />
|
|
||||||
</FormField>
|
|
||||||
) : (
|
|
||||||
<FormField label="Mode">
|
|
||||||
<Select
|
|
||||||
value={draft.mode}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newMode = value as MonitoringMachineInput["mode"];
|
|
||||||
setDraft((current) => ({
|
|
||||||
...emptyMachine(newMode),
|
|
||||||
id: current.id,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full" size="sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="local">Local</SelectItem>
|
|
||||||
<SelectItem value="ssh">SSH</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="machine-enabled"
|
|
||||||
className="mobile-touch-target"
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft((current) => ({ ...current, enabled: checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="machine-enabled">
|
|
||||||
{draft.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
|
||||||
{SERVICE_OPTIONS.map((option) => {
|
|
||||||
const checked = draft.services.includes(option.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
services: checked
|
|
||||||
? current.services.filter(
|
|
||||||
(service) => service !== option.value,
|
|
||||||
)
|
|
||||||
: [...current.services, option.value],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-5 cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
|
|
||||||
checked
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "border border-border text-foreground hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isLocal && (
|
|
||||||
<>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Connection"
|
|
||||||
description="SSH host, port, username, and credentials."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-4">
|
|
||||||
<FormField label="Host">
|
|
||||||
<Input
|
|
||||||
value={draft.host}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
host: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-2">
|
|
||||||
<FormField label="Port">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={draft.port}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
port: Number(e.target.value || 22),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-3">
|
|
||||||
<FormField label="Username">
|
|
||||||
<Input
|
|
||||||
value={draft.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
username: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-4">
|
|
||||||
<FormField
|
|
||||||
label="SSH key"
|
|
||||||
helperText={
|
|
||||||
sshKeys.length > 0
|
|
||||||
? "Select a saved SSH key."
|
|
||||||
: "No SSH keys are saved yet. Add one in the SSH Keys tab."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={draft.ssh_key_id || NONE}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
ssh_key_id: value === NONE ? "" : value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full" size="sm">
|
|
||||||
<SelectValue placeholder="No key selected" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NONE}>No key selected</SelectItem>
|
|
||||||
{sshKeys.map((key) => (
|
|
||||||
<SelectItem key={key.id} value={key.id}>
|
|
||||||
{key.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Password">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={placeholderIfSet(editingMachine?.password_set)}
|
|
||||||
value={draft.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
password: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Monitoring / Files"
|
|
||||||
description="Enable monitoring and file browsing for this machine."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isLocal && (
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Local hint">
|
|
||||||
<Input value="Uses the API host directly" disabled />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Notes"
|
|
||||||
description="Free-form administrator notes for this machine."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<FormField label="Notes">
|
|
||||||
<Input
|
|
||||||
value={draft.notes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({ ...current, notes: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedSSHKey ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Selected key: {selectedSSHKey.name}
|
|
||||||
{selectedSSHKey.fingerprint
|
|
||||||
? ` · ${selectedSSHKey.fingerprint}`
|
|
||||||
: ""}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : !isLocal && sshKeys.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
No SSH keys have been saved yet. Add one before configuring SSH
|
|
||||||
machines.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : draft.ssh_key_id ? (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
The selected SSH key was not found.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{!isLocal && enabledServices === 0 && (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
SSH machines usually need monitoring or files enabled.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!isLocal && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Validate SSH before saving: this records the first trusted host
|
|
||||||
key in the backend-managed known_hosts file, then checks SSH
|
|
||||||
auth.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
className="mobile-touch-target"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onValidateSSH}
|
|
||||||
disabled={
|
|
||||||
isValidatingSSH ||
|
|
||||||
!draft.host.trim() ||
|
|
||||||
!draft.username.trim()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isValidatingSSH
|
|
||||||
? "Validating SSH..."
|
|
||||||
: "Validate SSH + trust host"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
SSH status: {sshValidationStatus || "Not tested yet"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{sshValidationMessage && (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>{sshValidationMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{sshValidationError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{sshValidationError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SSHKeyManager({
|
function SSHKeyManager({
|
||||||
sshKeys,
|
sshKeys,
|
||||||
selectedKeyId,
|
selectedKeyId,
|
||||||
@@ -895,134 +461,31 @@ function ResetLocalDatabaseCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { data: machines, error } = useMonitoringSettings();
|
|
||||||
const { data: sshKeys = [] } = useSSHKeys();
|
const { data: sshKeys = [] } = useSSHKeys();
|
||||||
const saveMachine = useSaveMonitoringMachine();
|
|
||||||
const deleteMachine = useDeleteMonitoringMachine();
|
|
||||||
const testMachineSSH = useTestMonitoringMachineSSH();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [tab, setTab] = useState<SettingsTab>(
|
const [tab, setTab] = useState<SettingsTab>(
|
||||||
(searchParams.get("tab") as SettingsTab | null) ?? "machines",
|
(searchParams.get("tab") as SettingsTab | null) ?? "services",
|
||||||
);
|
);
|
||||||
const initialServiceId = searchParams.get("service") ?? "";
|
const initialServiceId = searchParams.get("service") ?? "";
|
||||||
const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
|
|
||||||
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
|
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
|
||||||
const [sshValidationMessage, setSSHValidationMessage] = useState("");
|
|
||||||
const [sshValidationError, setSSHValidationError] = useState("");
|
|
||||||
const [sshValidationStatus, setSSHValidationStatus] = useState("");
|
|
||||||
const [machineDialogOpen, setMachineDialogOpen] = useState(false);
|
|
||||||
const [machineDraft, setMachineDraft] = useState<MonitoringMachineInput>(
|
|
||||||
emptyMachine(),
|
|
||||||
);
|
|
||||||
const [editingMachine, setEditingMachine] =
|
|
||||||
useState<MonitoringMachine | null>(null);
|
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
|
||||||
const selectedMachine = useMemo(
|
|
||||||
() =>
|
|
||||||
orderedMachines.find((machine) => machine.id === selectedMachineId) ??
|
|
||||||
orderedMachines[0] ??
|
|
||||||
null,
|
|
||||||
[orderedMachines, selectedMachineId],
|
|
||||||
);
|
|
||||||
const clearSSHValidation = () => {
|
|
||||||
setSSHValidationMessage("");
|
|
||||||
setSSHValidationError("");
|
|
||||||
setSSHValidationStatus("");
|
|
||||||
};
|
|
||||||
const openEditMachine = (
|
|
||||||
input: MonitoringMachineInput,
|
|
||||||
original?: MonitoringMachine | null,
|
|
||||||
) => {
|
|
||||||
clearSSHValidation();
|
|
||||||
setMachineDraft(input);
|
|
||||||
setEditingMachine(original ?? null);
|
|
||||||
setMachineDialogOpen(true);
|
|
||||||
};
|
|
||||||
const closeMachineDialog = () => {
|
|
||||||
clearSSHValidation();
|
|
||||||
setMachineDialogOpen(false);
|
|
||||||
setEditingMachine(null);
|
|
||||||
};
|
|
||||||
const updateMachineDraft = (
|
|
||||||
draft:
|
|
||||||
| MonitoringMachineInput
|
|
||||||
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
|
|
||||||
) => {
|
|
||||||
clearSSHValidation();
|
|
||||||
setMachineDraft(draft);
|
|
||||||
};
|
|
||||||
const saveMachineDraft = async (draft: MonitoringMachineInput) => {
|
|
||||||
clearSSHValidation();
|
|
||||||
await saveMachine.mutateAsync(draft);
|
|
||||||
setMachineDialogOpen(false);
|
|
||||||
setEditingMachine(null);
|
|
||||||
setMachineDraft(emptyMachine(draft.mode));
|
|
||||||
};
|
|
||||||
const validateMachineSSH = async () => {
|
|
||||||
clearSSHValidation();
|
|
||||||
try {
|
|
||||||
const result = await testMachineSSH.mutateAsync(machineDraft);
|
|
||||||
setSSHValidationMessage(result.message);
|
|
||||||
setSSHValidationStatus(
|
|
||||||
result.known_hosts_updated
|
|
||||||
? "Host key trusted and SSH auth succeeded."
|
|
||||||
: "Host key already trusted and SSH auth succeeded.",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setSSHValidationError(message);
|
|
||||||
const lowered = message.toLowerCase();
|
|
||||||
if (lowered.includes("protocol banner")) {
|
|
||||||
setSSHValidationStatus("SSH banner not received.");
|
|
||||||
} else if (
|
|
||||||
lowered.includes("no authentication methods available") ||
|
|
||||||
lowered.includes("authentication failed")
|
|
||||||
) {
|
|
||||||
setSSHValidationStatus("SSH authentication failed.");
|
|
||||||
} else {
|
|
||||||
setSSHValidationStatus("SSH validation failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-lg font-semibold">Settings</h1>
|
<h1 className="text-lg font-semibold">Settings</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Structure machines, reusable SSH keys, and safety controls from a
|
Configure services, reusable SSH keys, and safety controls.
|
||||||
tabbed admin workspace.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{String(error)}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{saveMachine.error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{String(saveMachine.error)}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{deleteMachine.error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{String(deleteMachine.error)}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<TabbedCard
|
<TabbedCard
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(value) => setTab(value as SettingsTab)}
|
onChange={(value) => setTab(value as SettingsTab)}
|
||||||
tabs={[
|
tabs={[
|
||||||
<TabsTrigger key="machines" value="machines">
|
<TabsTrigger key="services" value="services">
|
||||||
Machines
|
Services
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
||||||
SSH Keys
|
SSH Keys
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
<TabsTrigger key="services" value="services">
|
|
||||||
Services
|
|
||||||
</TabsTrigger>,
|
|
||||||
<TabsTrigger key="dashboards" value="dashboards">
|
<TabsTrigger key="dashboards" value="dashboards">
|
||||||
Dashboards
|
Dashboards
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
@@ -1032,175 +495,8 @@ export function Settings() {
|
|||||||
]}
|
]}
|
||||||
contentSx={{}}
|
contentSx={{}}
|
||||||
>
|
>
|
||||||
{tab === "machines" && (
|
{tab === "services" && (
|
||||||
<div className="flex flex-col gap-4">
|
<ServicesAdminCard initialServiceId={initialServiceId} />
|
||||||
{orderedMachines.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
|
||||||
<SelectionRailCard
|
|
||||||
title="Machines"
|
|
||||||
description="Select a machine to see its settings."
|
|
||||||
minHeight={420}
|
|
||||||
footer={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mobile-touch-target w-full"
|
|
||||||
onClick={() => {
|
|
||||||
clearSSHValidation();
|
|
||||||
setMachineDraft(emptyMachine("local"));
|
|
||||||
setEditingMachine(null);
|
|
||||||
setMachineDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add machine
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{orderedMachines.map((machine) => {
|
|
||||||
const active = machine.id === selectedMachine?.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={machine.id}
|
|
||||||
onClick={() => setSelectedMachineId(machine.id)}
|
|
||||||
className={cn(
|
|
||||||
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5 group-hover:[&_.rail-edit]:opacity-100",
|
|
||||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-semibold">
|
|
||||||
{machine.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{machine.mode} ·{" "}
|
|
||||||
{machine.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<HoverEditButton
|
|
||||||
onClick={() => {
|
|
||||||
openEditMachine(
|
|
||||||
{
|
|
||||||
id: machine.id,
|
|
||||||
name: machine.name,
|
|
||||||
mode: machine.mode,
|
|
||||||
enabled: machine.enabled,
|
|
||||||
services: machine.services,
|
|
||||||
host: machine.host,
|
|
||||||
port: machine.port,
|
|
||||||
username: machine.username,
|
|
||||||
key_directory: "",
|
|
||||||
key_name: "",
|
|
||||||
ssh_key_id: machine.ssh_key_id,
|
|
||||||
ssh_private_key: "",
|
|
||||||
ssh_private_key_passphrase: "",
|
|
||||||
password: "",
|
|
||||||
notes: machine.notes,
|
|
||||||
},
|
|
||||||
machine,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectionRailCard>
|
|
||||||
<SectionCard
|
|
||||||
title={selectedMachine?.name || "No machine selected"}
|
|
||||||
description={
|
|
||||||
selectedMachine
|
|
||||||
? selectedMachine.mode === "local"
|
|
||||||
? "Local API host"
|
|
||||||
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
|
|
||||||
: "Select a machine on the left to view its settings."
|
|
||||||
}
|
|
||||||
action={
|
|
||||||
selectedMachine ? (
|
|
||||||
<Badge variant="outline">{selectedMachine.mode}</Badge>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedMachine ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{selectedMachine.enabled ? "enabled" : "disabled"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">{`${selectedMachine.services.length} services`}</Badge>
|
|
||||||
{selectedMachine.ssh_key_id && (
|
|
||||||
<Badge variant="outline">{`SSH key ${selectedMachine.ssh_key_id}`}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{selectedMachine.notes || "No notes."}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{selectedMachine.mode === "ssh" ? (
|
|
||||||
<>
|
|
||||||
<FormField label="Host">
|
|
||||||
<Input value={selectedMachine.host} disabled />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Port">
|
|
||||||
<Input value={selectedMachine.port} disabled />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Username">
|
|
||||||
<Input
|
|
||||||
value={selectedMachine.username}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FormField label="Local hint">
|
|
||||||
<Input
|
|
||||||
value="Uses the API host directly"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
className="mobile-touch-target"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
openEditMachine(
|
|
||||||
{
|
|
||||||
id: selectedMachine.id,
|
|
||||||
name: selectedMachine.name,
|
|
||||||
mode: selectedMachine.mode,
|
|
||||||
enabled: selectedMachine.enabled,
|
|
||||||
services: selectedMachine.services,
|
|
||||||
host: selectedMachine.host,
|
|
||||||
port: selectedMachine.port,
|
|
||||||
username: selectedMachine.username,
|
|
||||||
key_directory: "",
|
|
||||||
key_name: "",
|
|
||||||
ssh_key_id: selectedMachine.ssh_key_id,
|
|
||||||
ssh_private_key: "",
|
|
||||||
ssh_private_key_passphrase: "",
|
|
||||||
password: "",
|
|
||||||
notes: selectedMachine.notes,
|
|
||||||
},
|
|
||||||
selectedMachine,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="mobile-touch-target"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{tab === "ssh-keys" && (
|
{tab === "ssh-keys" && (
|
||||||
<SSHKeyManager
|
<SSHKeyManager
|
||||||
@@ -1209,140 +505,9 @@ export function Settings() {
|
|||||||
onSelectKeyId={setSelectedSSHKeyId}
|
onSelectKeyId={setSelectedSSHKeyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "services" && (
|
|
||||||
<ServicesAdminCard initialServiceId={initialServiceId} />
|
|
||||||
)}
|
|
||||||
{tab === "dashboards" && <DashboardManagementCard />}
|
{tab === "dashboards" && <DashboardManagementCard />}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
{isMobile ? (
|
|
||||||
<SheetForm
|
|
||||||
open={machineDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) closeMachineDialog();
|
|
||||||
}}
|
|
||||||
title={machineDraft.id ? "Edit machine" : "Create machine"}
|
|
||||||
onSave={() => {
|
|
||||||
void saveMachineDraft(machineDraft);
|
|
||||||
}}
|
|
||||||
onCancel={closeMachineDialog}
|
|
||||||
isPending={saveMachine.isPending}
|
|
||||||
saveDisabled={
|
|
||||||
!machineDraft.name ||
|
|
||||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
|
||||||
}
|
|
||||||
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
|
||||||
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
|
|
||||||
>
|
|
||||||
<MachineEditor
|
|
||||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
|
||||||
title={
|
|
||||||
machineDraft.id
|
|
||||||
? machineDraft.name || "Edit machine"
|
|
||||||
: "New machine"
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
|
||||||
}
|
|
||||||
machine={machineDraft}
|
|
||||||
sshKeys={sshKeys}
|
|
||||||
editingMachine={editingMachine}
|
|
||||||
onChange={updateMachineDraft}
|
|
||||||
onValidateSSH={validateMachineSSH}
|
|
||||||
isValidatingSSH={testMachineSSH.isPending}
|
|
||||||
sshValidationMessage={sshValidationMessage}
|
|
||||||
sshValidationError={sshValidationError}
|
|
||||||
sshValidationStatus={sshValidationStatus}
|
|
||||||
/>
|
|
||||||
{machineDraft.id ? (
|
|
||||||
<Button
|
|
||||||
className="mobile-touch-target"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setDeleteMachineId(machineDraft.id as string)}
|
|
||||||
>
|
|
||||||
Delete machine
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</SheetForm>
|
|
||||||
) : (
|
|
||||||
<Dialog
|
|
||||||
open={machineDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) closeMachineDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-4xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{machineDraft.mode === "local"
|
|
||||||
? "Local API host"
|
|
||||||
: "SSH target"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<MachineEditor
|
|
||||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
|
||||||
title={
|
|
||||||
machineDraft.id
|
|
||||||
? machineDraft.name || "Edit machine"
|
|
||||||
: "New machine"
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
|
||||||
}
|
|
||||||
machine={machineDraft}
|
|
||||||
sshKeys={sshKeys}
|
|
||||||
editingMachine={editingMachine}
|
|
||||||
onChange={updateMachineDraft}
|
|
||||||
onValidateSSH={validateMachineSSH}
|
|
||||||
isValidatingSSH={testMachineSSH.isPending}
|
|
||||||
sshValidationMessage={sshValidationMessage}
|
|
||||||
sshValidationError={sshValidationError}
|
|
||||||
sshValidationStatus={sshValidationStatus}
|
|
||||||
/>
|
|
||||||
<DialogFooter
|
|
||||||
onCancel={closeMachineDialog}
|
|
||||||
cancelLabel="Cancel"
|
|
||||||
onConfirm={() => {
|
|
||||||
void saveMachineDraft(machineDraft);
|
|
||||||
}}
|
|
||||||
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
|
||||||
confirmDisabled={
|
|
||||||
!machineDraft.name ||
|
|
||||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
|
||||||
}
|
|
||||||
secondaryAction={
|
|
||||||
machineDraft.id ? (
|
|
||||||
<Button
|
|
||||||
className="mobile-touch-target"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteMachineId(machineDraft.id as string);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(deleteMachineId)}
|
|
||||||
title="Delete machine?"
|
|
||||||
message="The machine will be removed. Action history will be deleted."
|
|
||||||
onCancel={() => setDeleteMachineId(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (deleteMachineId) {
|
|
||||||
deleteMachine.mutate(deleteMachineId);
|
|
||||||
closeMachineDialog();
|
|
||||||
}
|
|
||||||
setDeleteMachineId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1600,14 +765,15 @@ function ServiceConfigEditor({
|
|||||||
<FormField
|
<FormField
|
||||||
key={key}
|
key={key}
|
||||||
label={
|
label={
|
||||||
instance.service_type === "ssh_tasks" && key === "ssh_key_id"
|
instance.service_type === "remote_machine" &&
|
||||||
|
key === "ssh_key_id"
|
||||||
? "SSH key"
|
? "SSH key"
|
||||||
: key
|
: key
|
||||||
}
|
}
|
||||||
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
||||||
helperText={schema.description}
|
helperText={schema.description}
|
||||||
>
|
>
|
||||||
{instance.service_type === "ssh_tasks" &&
|
{instance.service_type === "remote_machine" &&
|
||||||
key === "ssh_key_id" ? (
|
key === "ssh_key_id" ? (
|
||||||
<Select
|
<Select
|
||||||
value={String(draftConfig[key] ?? "") || NONE}
|
value={String(draftConfig[key] ?? "") || NONE}
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ describe("ServicePage tab skeleton", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||||
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
|
const sshInstance = { ...instance, service_type: "remote_machine", id: "ssh-1" };
|
||||||
(
|
(
|
||||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||||
).__svcInstances = [sshInstance];
|
).__svcInstances = [sshInstance];
|
||||||
renderServicePage("/services/ssh_tasks/ssh-1");
|
renderServicePage("/services/remote_machine/ssh-1");
|
||||||
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ vi.mock("../../hooks/useServices", () => ({
|
|||||||
widget_kinds: [],
|
widget_kinds: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
service_type: "ssh_tasks",
|
service_type: "remote_machine",
|
||||||
name: "SSH task runner",
|
name: "SSH task runner",
|
||||||
description: "Run saved tasks over SSH",
|
description: "Run saved tasks over SSH",
|
||||||
config_schema: {
|
config_schema: {
|
||||||
|
|||||||
@@ -1,125 +1,39 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Settings } from "../Settings";
|
import { Settings } from "../Settings";
|
||||||
import type { MonitoringMachine } from "../../types";
|
|
||||||
|
|
||||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
|
||||||
const deleteMachineMutate = vi.fn();
|
|
||||||
const testSSHMutate = vi.fn().mockResolvedValue({
|
|
||||||
message: "SSH auth succeeded",
|
|
||||||
known_hosts_updated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let machines: MonitoringMachine[] = [];
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: machines }),
|
|
||||||
useSSHKeys: () => ({ data: [] }),
|
useSSHKeys: () => ({ data: [] }),
|
||||||
useSaveMonitoringMachine: () => ({
|
|
||||||
mutateAsync: saveMachineMutate,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
|
|
||||||
useTestMonitoringMachineSSH: () => ({
|
|
||||||
mutateAsync: testSSHMutate,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
useResetLocalDatabase: () => ({}),
|
useResetLocalDatabase: () => ({}),
|
||||||
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
||||||
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function localMachine(
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
overrides: Partial<MonitoringMachine> = {},
|
useServiceInstances: () => ({ data: [] }),
|
||||||
): MonitoringMachine {
|
useServiceTypes: () => ({ data: [] }),
|
||||||
return {
|
useSaveServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
id: "m1",
|
useDeleteServiceInstance: () => ({ mutate: vi.fn() }),
|
||||||
name: "This machine",
|
useTestServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
mode: "local",
|
}));
|
||||||
enabled: true,
|
|
||||||
services: ["monitoring", "files", "jellyfin"],
|
|
||||||
host: "",
|
|
||||||
port: 22,
|
|
||||||
username: "",
|
|
||||||
key_directory: "",
|
|
||||||
key_name: "",
|
|
||||||
ssh_key_id: "",
|
|
||||||
ssh_private_key_set: false,
|
|
||||||
ssh_private_key_passphrase_set: false,
|
|
||||||
password_set: false,
|
|
||||||
notes: "Primary node",
|
|
||||||
...overrides,
|
|
||||||
} as MonitoringMachine;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
saveMachineMutate.mockClear();
|
|
||||||
deleteMachineMutate.mockClear();
|
|
||||||
testSSHMutate.mockClear();
|
|
||||||
machines = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Settings", () => {
|
describe("Settings", () => {
|
||||||
it("renders the machine list from the mocked store", () => {
|
it("uses Services instead of a standalone Machines tab", () => {
|
||||||
machines = [localMachine()];
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Settings />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
|
||||||
machines = [localMachine()];
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Settings />
|
<Settings />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
expect(screen.getByRole("tab", { name: "Services" })).toBeInTheDocument();
|
||||||
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
expect(screen.getByRole("tab", { name: "SSH Keys" })).toBeInTheDocument();
|
||||||
const detailEdit = screen
|
expect(
|
||||||
.getAllByRole("button", { name: "Edit" })
|
screen.queryByRole("tab", { name: "Machines" }),
|
||||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
).not.toBeInTheDocument();
|
||||||
await userEvent.click(detailEdit);
|
expect(
|
||||||
|
screen.getByText("No service instances configured yet."),
|
||||||
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Rename through the labeled field, then save.
|
|
||||||
const nameInput = screen.getByLabelText("Name");
|
|
||||||
await userEvent.clear(nameInput);
|
|
||||||
await userEvent.type(nameInput, "Worker node");
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
|
||||||
|
|
||||||
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
|
||||||
const saved = saveMachineMutate.mock.calls[0][0];
|
|
||||||
expect(saved.name).toBe("Worker node");
|
|
||||||
expect(saved.mode).toBe("local");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes a machine through the confirm dialog", async () => {
|
|
||||||
machines = [localMachine()];
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Settings />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detail-pane "Delete" opens the confirm dialog.
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
|
||||||
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Confirm (the confirm dialog's "Delete" is the last one rendered).
|
|
||||||
const deletes = screen.getAllByRole("button", { name: "Delete" });
|
|
||||||
await userEvent.click(deletes[deletes.length - 1]);
|
|
||||||
|
|
||||||
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ActionsTab — operational content for the ssh_tasks service page.
|
* ActionsTab — operational content for the remote_machine service page.
|
||||||
*
|
*
|
||||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||||
* provides the active ssh_tasks service id, which is used as the default run
|
* provides the active remote_machine service id, which is used as the default run
|
||||||
* service. The page-level header is removed (the service page provides it).
|
* service. The page-level header is removed (the service page provides it).
|
||||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||||
*/
|
*/
|
||||||
@@ -77,7 +77,7 @@ function emptyTask(): SavedTaskInput {
|
|||||||
task_type: "shell",
|
task_type: "shell",
|
||||||
content: "",
|
content: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
default_service_id: "",
|
service_id: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
|||||||
a.task_type === b.task_type &&
|
a.task_type === b.task_type &&
|
||||||
a.content === b.content &&
|
a.content === b.content &&
|
||||||
a.enabled === b.enabled &&
|
a.enabled === b.enabled &&
|
||||||
a.default_service_id === b.default_service_id &&
|
a.service_id === b.service_id &&
|
||||||
a.notes === b.notes
|
a.notes === b.notes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
|||||||
task_type: task.task_type,
|
task_type: task.task_type,
|
||||||
content: task.content,
|
content: task.content,
|
||||||
enabled: task.enabled,
|
enabled: task.enabled,
|
||||||
default_service_id: task.default_service_id,
|
service_id: task.service_id,
|
||||||
notes: task.notes,
|
notes: task.notes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -242,15 +242,17 @@ function TaskDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: tasks = [] } = useTasks(instance.id);
|
||||||
const saveTask = useSaveTask();
|
const saveTask = useSaveTask();
|
||||||
const deleteTask = useDeleteTask();
|
const deleteTask = useDeleteTask();
|
||||||
const runTask = useRunTask();
|
const runTask = useRunTask();
|
||||||
const [tab, setTab] = useState<ActionTab>("new");
|
const [tab, setTab] = useState<ActionTab>("new");
|
||||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
const [draft, setDraft] = useState<SavedTaskInput>(() => ({
|
||||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
...emptyTask(),
|
||||||
emptyTask(),
|
service_id: instance.id,
|
||||||
);
|
}));
|
||||||
|
const [draftBaseline, setDraftBaseline] =
|
||||||
|
useState<SavedTaskInput>(emptyTask());
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
// Default to this instance's service id for task runs.
|
// Default to this instance's service id for task runs.
|
||||||
@@ -260,7 +262,7 @@ export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
() => tasks.find((task) => task.id === tab) ?? null,
|
() => tasks.find((task) => task.id === tab) ?? null,
|
||||||
[tasks, tab],
|
[tasks, tab],
|
||||||
);
|
);
|
||||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
const selectedRuns = useTaskRuns(selectedTask?.id, instance.id);
|
||||||
|
|
||||||
const openEdit = (initial: SavedTaskInput) => {
|
const openEdit = (initial: SavedTaskInput) => {
|
||||||
setDraft(initial);
|
setDraft(initial);
|
||||||
@@ -269,7 +271,10 @@ export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
const saved = await saveTask.mutateAsync(draft);
|
const saved = await saveTask.mutateAsync({
|
||||||
|
...draft,
|
||||||
|
service_id: instance.id,
|
||||||
|
});
|
||||||
setTab(saved.id);
|
setTab(saved.id);
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
const nextDraft = {
|
const nextDraft = {
|
||||||
@@ -278,7 +283,7 @@ export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
task_type: saved.task_type,
|
task_type: saved.task_type,
|
||||||
content: saved.content,
|
content: saved.content,
|
||||||
enabled: saved.enabled,
|
enabled: saved.enabled,
|
||||||
default_service_id: saved.default_service_id,
|
service_id: saved.service_id,
|
||||||
notes: saved.notes,
|
notes: saved.notes,
|
||||||
};
|
};
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
@@ -313,7 +318,9 @@ export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => openEdit(emptyTask())}
|
onClick={() =>
|
||||||
|
openEdit({ ...emptyTask(), service_id: instance.id })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Add action
|
Add action
|
||||||
</Button>
|
</Button>
|
||||||
@@ -448,7 +455,13 @@ export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
onSave={saveDraft}
|
onSave={saveDraft}
|
||||||
onDelete={
|
onDelete={
|
||||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
draft.id
|
||||||
|
? () =>
|
||||||
|
deleteTask.mutate({
|
||||||
|
taskId: String(draft.id),
|
||||||
|
serviceId: instance.id,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/** ApplicationsTab — read-only Authentik application directory. */
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAuthentikApplications } from "../../hooks/useAuthentik";
|
||||||
|
import type { ServiceInstance } from "../../types";
|
||||||
|
|
||||||
|
export function ApplicationsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
|
const { data, isLoading } = useAuthentikApplications(instance.id);
|
||||||
|
const applications = data?.items ?? [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Application metadata only; providers, outposts, policies, and
|
||||||
|
effective access evaluation are not shown.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Application</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Launch URL</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && applications.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3}>
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{!isLoading && applications.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-muted-foreground">
|
||||||
|
No applications found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{applications.map((application) => (
|
||||||
|
<TableRow
|
||||||
|
key={application.id || application.slug || application.name}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{application.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{application.slug || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-sm truncate text-muted-foreground">
|
||||||
|
{application.launch_url || "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{data && data.total > applications.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing the first {applications.length} of {data.total} applications.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* FilesTab — operational content for the ssh_tasks service page.
|
* FilesTab — operational content for the remote_machine service page.
|
||||||
*
|
*
|
||||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
* selector and `useMonitoringSettings` are removed; the active remote_machine
|
||||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
* instance id (from the `instance` prop) replaces the service_id. The initial
|
||||||
* path is read from `?path=` search param for deep-link support (resolves the
|
* path is read from `?path=` search param for deep-link support (resolves the
|
||||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/** GroupsTab — read-only Authentik group directory. */
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAuthentikGroups } from "../../hooks/useAuthentik";
|
||||||
|
import type { ServiceInstance } from "../../types";
|
||||||
|
|
||||||
|
export function GroupsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
|
const { data, isLoading } = useAuthentikGroups(instance.id);
|
||||||
|
const groups = data?.items ?? [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Group</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && groups.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2}>
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{!isLoading && groups.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="text-muted-foreground">
|
||||||
|
No groups found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="font-medium">{group.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{group.id}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{data && data.total > groups.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing the first {groups.length} of {data.total} groups.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -202,7 +202,7 @@ function BuildProgress({ value }: { value: number | null }) {
|
|||||||
|
|
||||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
const { data: sshServices = [] } = useServiceInstances("remote_machine");
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const serviceId = instance.id;
|
const serviceId = instance.id;
|
||||||
@@ -278,13 +278,13 @@ export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
}, [mediaState.columnVisibility, isSmall]);
|
}, [mediaState.columnVisibility, isSmall]);
|
||||||
|
|
||||||
const handleRowClick = (row: MediaItem) => {
|
const handleRowClick = (row: MediaItem) => {
|
||||||
// Navigate to the ssh_tasks service page with the path query param.
|
// Navigate to the remote_machine service page with the path query param.
|
||||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
// If an remote_machine instance exists, open its Files tab; otherwise land
|
||||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
// on the remote_machine type page (empty state / ServiceTypePage resolver).
|
||||||
const sshInstance = sshServices.find((s) => s.enabled);
|
const sshInstance = sshServices.find((s) => s.enabled);
|
||||||
const base = sshInstance
|
const base = sshInstance
|
||||||
? `/services/ssh_tasks/${sshInstance.id}`
|
? `/services/remote_machine/${sshInstance.id}`
|
||||||
: "/services/ssh_tasks";
|
: "/services/remote_machine";
|
||||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +1,27 @@
|
|||||||
/**
|
|
||||||
* Prometheus Metrics tab (spec R2.4, R8.2).
|
|
||||||
*
|
|
||||||
* Instance-scoped tab showing Prometheus service health.
|
|
||||||
* usePrometheusStatus is scoped by instance.id; usePrometheusTargets
|
|
||||||
* stays global (returns Node Exporter scrape targets for external Prom).
|
|
||||||
*/
|
|
||||||
import { Radio } from "lucide-react";
|
import { Radio } from "lucide-react";
|
||||||
import {
|
import { usePrometheusStatus } from "../../hooks/useObservability";
|
||||||
usePrometheusStatus,
|
|
||||||
usePrometheusTargets,
|
|
||||||
} from "../../hooks/useObservability";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import type { ServiceInstance } from "../../types";
|
||||||
import type { PrometheusTarget, ServiceInstance } from "../../types";
|
|
||||||
|
|
||||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{targets.map((target, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border p-3">
|
|
||||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
|
||||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
|
||||||
{Object.entries(target.labels).map(([key, value]) => (
|
|
||||||
<Badge key={key} variant="outline" className="text-[10px]">
|
|
||||||
{key}: {value}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const {
|
const { data: status, isLoading, error } = usePrometheusStatus(instance.id);
|
||||||
data: status,
|
const detail = status?.up
|
||||||
isLoading: statusLoading,
|
|
||||||
error: statusError,
|
|
||||||
} = usePrometheusStatus(instance.id);
|
|
||||||
const {
|
|
||||||
data: targets,
|
|
||||||
isLoading: targetsLoading,
|
|
||||||
error: targetsError,
|
|
||||||
} = usePrometheusTargets();
|
|
||||||
|
|
||||||
const statusDetail = status?.up
|
|
||||||
? status.version
|
? status.version
|
||||||
? `version ${status.version}`
|
? `version ${status.version}`
|
||||||
: "reachable"
|
: "reachable"
|
||||||
: statusLoading
|
: isLoading
|
||||||
? "checking…"
|
? "checking…"
|
||||||
: "unreachable";
|
: "unreachable";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Radio className="h-4 w-4" />
|
<Radio className="h-4 w-4" />
|
||||||
Prometheus {statusDetail}
|
Prometheus {detail}
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
{statusError && (
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||||
<AlertDescription>{statusError.message}</AlertDescription>
|
<AlertDescription>{error.message}</AlertDescription>
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Radio className="h-4 w-4" />
|
|
||||||
Node Exporter Targets ({targets?.length ?? 0})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{targetsLoading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
|
||||||
) : !targets || targets.length === 0 ? (
|
|
||||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
|
||||||
<Radio className="h-8 w-8 text-muted-foreground" />
|
|
||||||
<div className="font-medium">No Node Exporter targets</div>
|
|
||||||
<div className="max-w-md text-sm text-muted-foreground">
|
|
||||||
Enable Node Exporter on an SSH machine in Settings to populate
|
|
||||||
Prometheus scrape targets.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TargetsTable targets={targets} />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{targetsError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Failed to load targets</AlertTitle>
|
|
||||||
<AlertDescription>{targetsError.message}</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
|
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LineSeriesChart } from "../../components/LineSeriesChart";
|
import { LineSeriesChart } from "../../components/LineSeriesChart";
|
||||||
import { chartRangesThrough } from "../../components/chartRanges";
|
import {
|
||||||
|
chartRangesThrough,
|
||||||
|
type ChartRangeValue,
|
||||||
|
} from "../../components/chartRanges";
|
||||||
import {
|
import {
|
||||||
useRunSchedulerAction,
|
useRunSchedulerAction,
|
||||||
useSchedulerRuns,
|
useSchedulerRuns,
|
||||||
@@ -29,9 +32,9 @@ function statusVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const [windowSeconds, setWindowSeconds] = useState(1800);
|
const [selectedRange, setSelectedRange] = useState<ChartRangeValue>(1800);
|
||||||
const status = useSchedulerStatus(instance.id);
|
const status = useSchedulerStatus(instance.id);
|
||||||
const samples = useSchedulerSamples(instance.id, windowSeconds);
|
const samples = useSchedulerSamples(instance.id, selectedRange);
|
||||||
const runs = useSchedulerRuns(instance.id);
|
const runs = useSchedulerRuns(instance.id);
|
||||||
const runNow = useRunSchedulerAction();
|
const runNow = useRunSchedulerAction();
|
||||||
const stale = Boolean(status.data?.enabled && status.data.is_stale);
|
const stale = Boolean(status.data?.enabled && status.data.is_stale);
|
||||||
@@ -111,9 +114,9 @@ export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
series={chartSeries}
|
series={chartSeries}
|
||||||
unit="bytes"
|
unit="bytes"
|
||||||
height={300}
|
height={300}
|
||||||
rangeOptions={chartRangesThrough(86400)}
|
rangeOptions={chartRangesThrough(86_400)}
|
||||||
rangeSeconds={windowSeconds}
|
rangeSeconds={selectedRange}
|
||||||
onRangeChange={setWindowSeconds}
|
onRangeChange={setSelectedRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** UsersTab — Authentik user directory for the Authentik service page. */
|
/** UsersTab — Authentik access metadata, not an effective-permissions calculation. */
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import type { ServiceInstance } from "../../types";
|
import type { ServiceInstance } from "../../types";
|
||||||
import { useAuthentikUsers } from "../../hooks/useAuthentik";
|
import { useAuthentikAccessSummary } from "../../hooks/useAuthentik";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -21,14 +21,11 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [committedSearch, setCommittedSearch] = useState("");
|
const [committedSearch, setCommittedSearch] = useState("");
|
||||||
|
const { data, isLoading } = useAuthentikAccessSummary(instance.id, {
|
||||||
const { data, isLoading } = useAuthentikUsers(instance.id, {
|
|
||||||
search: committedSearch,
|
search: committedSearch,
|
||||||
page,
|
page,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const error = data?.error;
|
|
||||||
const users = data?.items ?? [];
|
const users = data?.items ?? [];
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
@@ -40,19 +37,25 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{error ? (
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Shows Authentik group membership and explicit staff/superuser flags.
|
||||||
|
This is access metadata, not a complete effective-authorization
|
||||||
|
calculation.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{data?.error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search users…"
|
placeholder="Search users…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === "Enter") handleSearch();
|
if (event.key === "Enter") handleSearch();
|
||||||
}}
|
}}
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
@@ -60,39 +63,64 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Username</TableHead>
|
<TableHead>Username</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Groups</TableHead>
|
||||||
<TableHead className="w-24">Status</TableHead>
|
<TableHead>Privileges</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && users.length === 0 ? (
|
{isLoading && users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-muted-foreground">
|
<TableCell colSpan={5} className="text-muted-foreground">
|
||||||
Loading…
|
Loading…
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : users.length === 0 ? (
|
) : null}
|
||||||
|
{!isLoading && users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-muted-foreground">
|
<TableCell colSpan={5} className="text-muted-foreground">
|
||||||
No users found.
|
No users found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : null}
|
||||||
users.map((user) => (
|
{users.map((user) => (
|
||||||
<TableRow key={user.pk}>
|
<TableRow key={user.id || user.username}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{user.name || "—"}
|
{user.name || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{user.username}</TableCell>
|
<TableCell>{user.username || "—"}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>
|
||||||
{user.email || "—"}
|
<div className="flex max-w-sm flex-wrap gap-1">
|
||||||
|
{user.groups.length ? (
|
||||||
|
user.groups.map((group) => (
|
||||||
|
<Badge
|
||||||
|
key={group.id}
|
||||||
|
variant={group.known ? "secondary" : "destructive"}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.is_superuser ? (
|
||||||
|
<Badge variant="destructive">Superuser</Badge>
|
||||||
|
) : null}
|
||||||
|
{user.is_staff ? <Badge>Staff</Badge> : null}
|
||||||
|
{!user.is_superuser && !user.is_staff ? (
|
||||||
|
<span className="text-muted-foreground">None</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={user.is_active ? "default" : "secondary"}>
|
<Badge variant={user.is_active ? "default" : "secondary"}>
|
||||||
@@ -100,12 +128,10 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{total > 0 ? (
|
{total > 0 ? (
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
@@ -115,7 +141,7 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
@@ -123,7 +149,9 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() =>
|
||||||
|
setPage((current) => Math.min(totalPages, current + 1))
|
||||||
|
}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
|||||||
|
|
||||||
const instance: ServiceInstance = {
|
const instance: ServiceInstance = {
|
||||||
id: "ssh-1",
|
id: "ssh-1",
|
||||||
service_type: "ssh_tasks",
|
service_type: "remote_machine",
|
||||||
name: "Storage Server",
|
name: "Storage Server",
|
||||||
config: {},
|
config: {},
|
||||||
secrets_set: {},
|
secrets_set: {},
|
||||||
@@ -24,7 +24,7 @@ vi.mock("../../../hooks/useSettings", () => ({
|
|||||||
task_type: "shell",
|
task_type: "shell",
|
||||||
content: "df -h",
|
content: "df -h",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
default_service_id: "",
|
service_id: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
|||||||
|
|
||||||
const instance: ServiceInstance = {
|
const instance: ServiceInstance = {
|
||||||
id: "ssh-1",
|
id: "ssh-1",
|
||||||
service_type: "ssh_tasks",
|
service_type: "remote_machine",
|
||||||
name: "Storage Server",
|
name: "Storage Server",
|
||||||
config: {},
|
config: {},
|
||||||
secrets_set: {},
|
secrets_set: {},
|
||||||
@@ -40,7 +40,7 @@ vi.mock("../../../hooks/usePersistentState", () => ({
|
|||||||
]),
|
]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
function renderTab(path = "/services/remote_machine/ssh-1") {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<FilesTab instance={instance} />
|
<FilesTab instance={instance} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MetricsTab } from "../MetricsTab";
|
import { MetricsTab } from "../MetricsTab";
|
||||||
import type { ServiceInstance } from "../../../types";
|
import type { ServiceInstance } from "../../../types";
|
||||||
@@ -25,27 +25,13 @@ vi.mock("../../../hooks/useObservability", () => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
usePrometheusTargets: () => ({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
targets: ["10.0.0.5:9100"],
|
|
||||||
labels: { instance: "storage", job: "node_exporter" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("MetricsTab", () => {
|
describe("MetricsTab", () => {
|
||||||
it("renders the Prometheus version and target list", () => {
|
it("renders the Prometheus version without Manage-owned target discovery", () => {
|
||||||
render(<MetricsTab instance={instance} />);
|
render(<MetricsTab instance={instance} />);
|
||||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the target count in the heading", () => {
|
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||||
render(<MetricsTab instance={instance} />);
|
expect(screen.queryByText(/Node Exporter Targets/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,22 +15,28 @@ const instance: ServiceInstance = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||||
useAuthentikUsers: vi.fn(() => ({
|
useAuthentikAccessSummary: vi.fn(() => ({
|
||||||
data: {
|
data: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
pk: 1,
|
id: "1",
|
||||||
username: "alice",
|
username: "alice",
|
||||||
name: "Alice",
|
name: "Alice",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
is_superuser: true,
|
||||||
|
is_staff: false,
|
||||||
|
groups: [{ id: "admins", name: "Admins", known: true }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pk: 2,
|
id: "2",
|
||||||
username: "bob",
|
username: "bob",
|
||||||
name: "Bob",
|
name: "Bob",
|
||||||
email: "bob@example.com",
|
email: "bob@example.com",
|
||||||
is_active: false,
|
is_active: false,
|
||||||
|
is_superuser: false,
|
||||||
|
is_staff: true,
|
||||||
|
groups: [{ id: "gone", name: "Unknown group (gone)", known: false }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total: 2,
|
total: 2,
|
||||||
@@ -42,13 +48,17 @@ vi.mock("../../../hooks/useAuthentik", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("UsersTab", () => {
|
describe("UsersTab", () => {
|
||||||
it("renders the directory table with users", () => {
|
it("renders group membership and explicit privilege metadata", () => {
|
||||||
render(<UsersTab instance={instance} />);
|
render(<UsersTab instance={instance} />);
|
||||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
expect(screen.getByText("bob")).toBeInTheDocument();
|
expect(screen.getByText("bob")).toBeInTheDocument();
|
||||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
expect(screen.getByText("Admins")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
expect(screen.getByText("Unknown group (gone)")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Inactive")).toBeInTheDocument();
|
expect(screen.getByText("Superuser")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Staff")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/not a complete effective-authorization calculation/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders search input and pagination", () => {
|
it("renders search input and pagination", () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { FilesTab } from "./FilesTab";
|
|||||||
import { ActionsTab } from "./ActionsTab";
|
import { ActionsTab } from "./ActionsTab";
|
||||||
import { JobsTab } from "./JobsTab";
|
import { JobsTab } from "./JobsTab";
|
||||||
import { UsersTab } from "./UsersTab";
|
import { UsersTab } from "./UsersTab";
|
||||||
|
import { GroupsTab } from "./GroupsTab";
|
||||||
|
import { ApplicationsTab } from "./ApplicationsTab";
|
||||||
import { MessagingTab } from "./MessagingTab";
|
import { MessagingTab } from "./MessagingTab";
|
||||||
import { QbittorrentTab } from "./QbittorrentTab";
|
import { QbittorrentTab } from "./QbittorrentTab";
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
|||||||
{ label: "Media", Component: MediaTab },
|
{ label: "Media", Component: MediaTab },
|
||||||
{ label: "Requests", Component: RequestsTab },
|
{ label: "Requests", Component: RequestsTab },
|
||||||
];
|
];
|
||||||
case "ssh_tasks":
|
case "remote_machine":
|
||||||
return [
|
return [
|
||||||
{ label: "Files", Component: FilesTab },
|
{ label: "Files", Component: FilesTab },
|
||||||
{ label: "Actions", Component: ActionsTab },
|
{ label: "Actions", Component: ActionsTab },
|
||||||
@@ -52,6 +54,8 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
|||||||
case "authentik":
|
case "authentik":
|
||||||
return [
|
return [
|
||||||
{ label: "Users", Component: UsersTab },
|
{ label: "Users", Component: UsersTab },
|
||||||
|
{ label: "Groups", Component: GroupsTab },
|
||||||
|
{ label: "Applications", Component: ApplicationsTab },
|
||||||
{ label: "Messaging", Component: MessagingTab },
|
{ label: "Messaging", Component: MessagingTab },
|
||||||
];
|
];
|
||||||
case "alertmanager":
|
case "alertmanager":
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export interface SavedTask {
|
|||||||
task_type: "shell" | "python";
|
task_type: "shell" | "python";
|
||||||
content: string;
|
content: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
default_service_id: string;
|
service_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
@@ -138,7 +138,7 @@ export interface SavedTaskInput {
|
|||||||
task_type: "shell" | "python";
|
task_type: "shell" | "python";
|
||||||
content: string;
|
content: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
default_service_id: string;
|
service_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,42 +155,6 @@ export interface SavedTaskRun {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringMachine {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mode: "local" | "ssh";
|
|
||||||
enabled: boolean;
|
|
||||||
services: string[];
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
key_directory: string;
|
|
||||||
key_name: string;
|
|
||||||
ssh_key_id: string;
|
|
||||||
ssh_private_key_set: boolean;
|
|
||||||
ssh_private_key_passphrase_set: boolean;
|
|
||||||
password_set: boolean;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonitoringMachineInput {
|
|
||||||
id?: string | null;
|
|
||||||
name: string;
|
|
||||||
mode: "local" | "ssh";
|
|
||||||
enabled: boolean;
|
|
||||||
services: string[];
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
key_directory: string;
|
|
||||||
key_name: string;
|
|
||||||
ssh_key_id: string;
|
|
||||||
ssh_private_key: string;
|
|
||||||
ssh_private_key_passphrase: string;
|
|
||||||
password: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResetLocalDatabaseInput {
|
export interface ResetLocalDatabaseInput {
|
||||||
confirm_phrase: string;
|
confirm_phrase: string;
|
||||||
acknowledge_settings_loss: boolean;
|
acknowledge_settings_loss: boolean;
|
||||||
@@ -206,14 +170,6 @@ export interface ResetLocalDatabaseResponse {
|
|||||||
media_index_files: string[];
|
media_index_files: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHValidationResult {
|
|
||||||
status: string;
|
|
||||||
message: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
known_hosts_updated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppVersionInfo {
|
export interface AppVersionInfo {
|
||||||
app: string;
|
app: string;
|
||||||
backend_version: string;
|
backend_version: string;
|
||||||
@@ -394,11 +350,6 @@ export interface PrometheusStatus {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrometheusTarget {
|
|
||||||
labels: Record<string, string>;
|
|
||||||
targets: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WidgetInstance {
|
export interface WidgetInstance {
|
||||||
id: string;
|
id: string;
|
||||||
service_id: string | null;
|
service_id: string | null;
|
||||||
@@ -505,7 +456,8 @@ export interface SchedulerRunsResponse {
|
|||||||
|
|
||||||
export interface SchedulerSamplesResponse {
|
export interface SchedulerSamplesResponse {
|
||||||
service_id: string;
|
service_id: string;
|
||||||
window_seconds: number;
|
window_seconds: number | null;
|
||||||
|
all_values: boolean;
|
||||||
samples: Array<{
|
samples: Array<{
|
||||||
ts: number;
|
ts: number;
|
||||||
dl_speed: number;
|
dl_speed: number;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { AuthentikAccessSummary } from "../api/authentik";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthentikAccessSummaryWidget({
|
||||||
|
widget,
|
||||||
|
refreshIntervalMs,
|
||||||
|
description,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
|
const payload = data?.data as
|
||||||
|
| { items?: AuthentikAccessSummary[] }
|
||||||
|
| undefined;
|
||||||
|
const users = payload?.items ?? [];
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : users.length ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{users.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.id || user.username}
|
||||||
|
className="rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{user.name || user.username || "Unknown user"}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
{user.is_superuser ? (
|
||||||
|
<Badge variant="destructive">Superuser</Badge>
|
||||||
|
) : null}
|
||||||
|
{user.is_staff ? <Badge>Staff</Badge> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{user.groups.length ? (
|
||||||
|
user.groups.map((group) => (
|
||||||
|
<Badge
|
||||||
|
key={group.id}
|
||||||
|
variant={group.known ? "secondary" : "destructive"}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
No group references
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No user access metadata found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { AuthentikApplication } from "../api/authentik";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthentikApplicationsWidget({
|
||||||
|
widget,
|
||||||
|
refreshIntervalMs,
|
||||||
|
description,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
|
const payload = data?.data as { items?: AuthentikApplication[] } | undefined;
|
||||||
|
const applications = payload?.items ?? [];
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : applications.length ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{applications.map((application) => (
|
||||||
|
<li
|
||||||
|
key={application.id || application.slug || application.name}
|
||||||
|
className="rounded-md border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{application.name}</span>
|
||||||
|
{application.slug ? (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{application.slug}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No applications found.</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { AuthentikGroup } from "../api/authentik";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthentikGroupsWidget({
|
||||||
|
widget,
|
||||||
|
refreshIntervalMs,
|
||||||
|
description,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
|
const payload = data?.data as { items?: AuthentikGroup[] } | undefined;
|
||||||
|
const groups = payload?.items ?? [];
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : groups.length ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<li key={group.id} className="rounded-md border px-2 py-1 text-sm">
|
||||||
|
{group.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No groups found.</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||||
import {
|
|
||||||
chartRangesThrough,
|
|
||||||
rangeSecondsFromWindow,
|
|
||||||
} from "../components/chartRanges";
|
|
||||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||||
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -24,7 +20,6 @@ export function MetricChartWidget({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const series = data?.data?.series as ChartSeries[] | undefined;
|
const series = data?.data?.series as ChartSeries[] | undefined;
|
||||||
const maxRangeSeconds = rangeSecondsFromWindow(widget.config.window);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
@@ -39,8 +34,7 @@ export function MetricChartWidget({
|
|||||||
series={series}
|
series={series}
|
||||||
unit={widget.config.unit as MetricUnit}
|
unit={widget.config.unit as MetricUnit}
|
||||||
scale={widget.config.scale as MetricScale}
|
scale={widget.config.scale as MetricScale}
|
||||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
showRangeSelector={false}
|
||||||
defaultRangeSeconds={maxRangeSeconds}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ActiveTorrent {
|
|||||||
direction?: "downloading" | "uploading";
|
direction?: "downloading" | "uploading";
|
||||||
size: number | null;
|
size: number | null;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
ratio: number | null;
|
||||||
dl_speed: number | null;
|
dl_speed: number | null;
|
||||||
up_speed: number | null;
|
up_speed: number | null;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,12 @@ function formatProgress(progress: number | null): string {
|
|||||||
return `${Math.round(progress * 100)}% complete`;
|
return `${Math.round(progress * 100)}% complete`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRatio(ratio: number | null): string {
|
||||||
|
if (ratio === null || !Number.isFinite(ratio) || ratio < 0)
|
||||||
|
return "Ratio unknown";
|
||||||
|
return `Ratio ${ratio.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatState(
|
function formatState(
|
||||||
state: string | null,
|
state: string | null,
|
||||||
direction?: ActiveTorrent["direction"],
|
direction?: ActiveTorrent["direction"],
|
||||||
@@ -103,7 +110,8 @@ export function QbittorrentActiveTorrentsWidget({
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatSize(torrent.size)} ·{" "}
|
{formatSize(torrent.size)} ·{" "}
|
||||||
{formatProgress(torrent.progress)}
|
{formatProgress(torrent.progress)} ·{" "}
|
||||||
|
{formatRatio(torrent.ratio)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||||
import { chartRangesThrough } from "../components/chartRanges";
|
|
||||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||||
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -24,7 +23,6 @@ export function QbittorrentSpeedWidget({
|
|||||||
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
|
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
|
||||||
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
|
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
|
||||||
const scale = (widget.config.scale as MetricScale) || "auto";
|
const scale = (widget.config.scale as MetricScale) || "auto";
|
||||||
const maxRangeSeconds = Number(widget.config.window_seconds) || 1800;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
@@ -40,8 +38,7 @@ export function QbittorrentSpeedWidget({
|
|||||||
unit={unit}
|
unit={unit}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
height={220}
|
height={220}
|
||||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
showRangeSelector={false}
|
||||||
defaultRangeSeconds={maxRangeSeconds}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ describe("MetricChartWidget", () => {
|
|||||||
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
|
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||||
// recharts renders an SVG; the title from SectionCard should be present.
|
// recharts renders an SVG; the title from SectionCard should be present.
|
||||||
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
|
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error Alert on error", () => {
|
it("shows error Alert on error", () => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
state: "downloading",
|
state: "downloading",
|
||||||
size: 1000,
|
size: 1000,
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
|
ratio: 1.25,
|
||||||
dl_speed: 500000,
|
dl_speed: 500000,
|
||||||
up_speed: 1000,
|
up_speed: 1000,
|
||||||
},
|
},
|
||||||
@@ -77,6 +78,7 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Downloading")).toBeInTheDocument();
|
expect(screen.getByText("Downloading")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Ratio 1\.25/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state when no active torrents", () => {
|
it("shows empty state when no active torrents", () => {
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ describe("QbittorrentSpeedWidget", () => {
|
|||||||
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||||
|
export { AuthentikAccessSummaryWidget } from "./AuthentikAccessSummaryWidget";
|
||||||
|
export { AuthentikApplicationsWidget } from "./AuthentikApplicationsWidget";
|
||||||
|
export { AuthentikGroupsWidget } from "./AuthentikGroupsWidget";
|
||||||
export { BackupsWidget } from "./BackupsWidget";
|
export { BackupsWidget } from "./BackupsWidget";
|
||||||
export { MetricChartWidget } from "./MetricChartWidget";
|
export { MetricChartWidget } from "./MetricChartWidget";
|
||||||
export { MetricGaugeWidget } from "./MetricGaugeWidget";
|
export { MetricGaugeWidget } from "./MetricGaugeWidget";
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ A Grafana gateway timeout, connection error, HTTP 401/403 (auth), datasource-not
|
|||||||
|
|
||||||
### Requirement: SC-104 — Step is derived from the window preset
|
### Requirement: SC-104 — Step is derived from the window preset
|
||||||
|
|
||||||
Given a window preset (1h / 6h / 24h / 7d), the backend MUST reuse the existing `WINDOW_PRESETS` and `step_for_window` math to derive the gateway request's `intervalMs` (`step * 1000`), `maxDataPoints`, and `from`/`to` time bounds, landing the resulting point count in the same ~100–300 band as the pre-change direct-Prom path. Users do not configure `from`/`to`/`step`/`intervalMs` directly.
|
Given a window preset (5m / 15m / 30m / 1h / 3h / 6h / 12h / 24h / 2d / 7d / 14d / 30d), the backend MUST reuse the existing `WINDOW_PRESETS` and `step_for_window` math to derive the gateway request's `intervalMs` (`step * 1000`), `maxDataPoints`, and `from`/`to` time bounds. Windows of 30 minutes or more must land in the ~100–300 point band; 5m and 15m may return 20 and 60 points respectively because Prometheus resolution is never set below 15 seconds. Users do not configure `from`/`to`/`step`/`intervalMs` directly.
|
||||||
|
|
||||||
### Requirement: SC-105 — Chart widget moves from grafana to prometheus
|
### Requirement: SC-105 — Chart widget moves from grafana to prometheus
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ The `chart` widget MUST render all series returned by the gateway range query, e
|
|||||||
|
|
||||||
### Requirement: SC-108 — Chart window is a preset
|
### Requirement: SC-108 — Chart window is a preset
|
||||||
|
|
||||||
The `chart` widget config MUST expose the time window as a preset selector (`1h`, `6h`, `24h`, `7d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side.
|
The `chart` widget config MUST expose the time window as a preset selector (`5m`, `15m`, `30m`, `1h`, `3h`, `6h`, `12h`, `24h`, `2d`, `7d`, `14d`, `30d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side. The shared chart renderer also offers an **All values** display option that removes the client-side cutoff from the values returned by that configured query.
|
||||||
|
|
||||||
### Requirement: SC-109 — Gauge renders an instant scalar
|
### Requirement: SC-109 — Gauge renders an instant scalar
|
||||||
|
|
||||||
|
|||||||
Executable
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# land-branch.sh — Solo-local integrate: squash-merge feature branch onto main and push.
|
||||||
|
# Requires GIT_BIGPOWERS_LAND=1 for hook exceptions on commit/push to protected branches.
|
||||||
|
# Usage: bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"
|
||||||
|
# Run from the primary repository root (not a linked worktree).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONVENTIONAL_REGEX='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+'
|
||||||
|
|
||||||
|
usage_land() {
|
||||||
|
echo "Usage: $0 <feature-branch> \"<conventional commit message>\" [--skip-verify]" >&2
|
||||||
|
echo " Run from primary repo root after release-branch gates (solo-local mode)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
land_branch_deny() {
|
||||||
|
echo "ERROR: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_VERIFY=false
|
||||||
|
ARGS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--skip-verify" ]; then
|
||||||
|
SKIP_VERIFY=true
|
||||||
|
else
|
||||||
|
ARGS+=("$arg")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_BRANCH="${ARGS[0]:-}"
|
||||||
|
COMMIT_MSG="${ARGS[1]:-}"
|
||||||
|
|
||||||
|
[ -n "$FEATURE_BRANCH" ] && [ -n "$COMMIT_MSG" ] || usage_land
|
||||||
|
|
||||||
|
if [[ ! "$COMMIT_MSG" =~ $CONVENTIONAL_REGEX ]]; then
|
||||||
|
land_branch_deny "Commit message must follow Conventional Commits: <type>(<scope>): <subject>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#COMMIT_MSG} -gt 72 ]; then
|
||||||
|
land_branch_deny "Commit subject line must be 72 characters or less"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Block AI agent attribution (P1 — CONVENTIONS.md § Git Attribution)
|
||||||
|
if echo "$COMMIT_MSG" | grep -qiE '^co[- ]authored[- ]by:' || echo "$COMMIT_MSG" | grep -qiE '\nco[- ]authored[- ]by:'; then
|
||||||
|
land_branch_deny "Commit must not include Co-authored-by: footer. All commits must appear as if authored solely by the human user."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Primary worktree only (.git is a directory, not a gitdir pointer file)
|
||||||
|
if [ -f .git ]; then
|
||||||
|
land_branch_deny "Run from the primary repository root, not a linked worktree (cd to main repo first)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
detect_default_branch() {
|
||||||
|
local remote_head
|
||||||
|
remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || true)
|
||||||
|
if [ -n "$remote_head" ]; then
|
||||||
|
echo "$remote_head"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if git show-ref --verify --quiet refs/heads/main; then
|
||||||
|
echo "main"
|
||||||
|
elif git show-ref --verify --quiet refs/heads/master; then
|
||||||
|
echo "master"
|
||||||
|
else
|
||||||
|
land_branch_deny "Could not detect default branch (main/master)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_BRANCH=$(detect_default_branch)
|
||||||
|
REPO_ROOT=$(pwd)
|
||||||
|
|
||||||
|
echo "==> Land branch: $FEATURE_BRANCH -> $DEFAULT_BRANCH"
|
||||||
|
echo " Repo root: $REPO_ROOT"
|
||||||
|
|
||||||
|
if ! git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' does not exist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scan all commits in feature branch for Co-authored-by: footers
|
||||||
|
if git log "$DEFAULT_BRANCH..$FEATURE_BRANCH" --format="%B" 2>/dev/null | grep -qiE '^co[- ]authored[- ]by:'; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' contains Co-authored-by: footer(s). Amend commits to remove all AI agent attribution before landing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
for protected in main master; do
|
||||||
|
if [ "$FEATURE_BRANCH" = "$protected" ]; then
|
||||||
|
land_branch_deny "Cannot land protected branch '$FEATURE_BRANCH'"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
run_verify_suite() {
|
||||||
|
echo "==> Running pre-land verification..."
|
||||||
|
if [ -f package.json ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
if jq -e '.scripts.compliance' package.json >/dev/null 2>&1; then
|
||||||
|
npm run compliance
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if jq -e '.scripts.test' package.json >/dev/null 2>&1; then
|
||||||
|
local test_script
|
||||||
|
test_script=$(jq -r '.scripts.test' package.json)
|
||||||
|
if [ "$test_script" = "echo \"Error: no test specified\" && exit 1" ]; then
|
||||||
|
:
|
||||||
|
elif [ "$test_script" = "false" ]; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
npm test
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if jq -e '.scripts.lint' package.json >/dev/null 2>&1; then
|
||||||
|
npm run lint
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -f scripts/sync-skills.sh ]; then
|
||||||
|
bash scripts/sync-skills.sh
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SKIP_VERIFY" = false ]; then
|
||||||
|
run_verify_suite
|
||||||
|
else
|
||||||
|
echo "==> Skipping verification (--skip-verify)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Updating $DEFAULT_BRANCH"
|
||||||
|
git checkout "$DEFAULT_BRANCH"
|
||||||
|
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
land_branch_deny "Working tree on $DEFAULT_BRANCH is not clean. Stash or commit first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git pull --ff-only origin "$DEFAULT_BRANCH" || land_branch_deny "git pull --ff-only failed; resolve before landing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git merge-base --is-ancestor "$DEFAULT_BRANCH" "$FEATURE_BRANCH" 2>/dev/null; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' is not based on current $DEFAULT_BRANCH (rebase or recreate branch)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GIT_BIGPOWERS_LAND=1
|
||||||
|
|
||||||
|
echo "==> Squash merge $FEATURE_BRANCH"
|
||||||
|
git merge --squash "$FEATURE_BRANCH"
|
||||||
|
if git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
land_branch_deny "Squash merge produced no changes (already merged?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
LAND_SHA=$(git rev-parse --short HEAD)
|
||||||
|
echo "==> Land commit: $LAND_SHA"
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
echo "==> Pushing $DEFAULT_BRANCH to origin"
|
||||||
|
git push origin "$DEFAULT_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Epic capsule archival (evolved bigpowers v4.0.0+)
|
||||||
|
# Move completed epic capsules to archive when all stories are done
|
||||||
|
echo "==> Checking for completed epic capsules to archive..."
|
||||||
|
if [ -d specs/epics ] && [ -f specs/execution-status.yaml ]; then
|
||||||
|
for capsule in specs/epics/e[0-9]*-*/; do
|
||||||
|
[ -d "$capsule" ] || continue
|
||||||
|
capsule_name=$(basename "$capsule")
|
||||||
|
epic_id=$(echo "$capsule_name" | grep -o '^e[0-9]*' || true)
|
||||||
|
[ -n "$epic_id" ] || continue
|
||||||
|
# Check if all stories in this epic are done
|
||||||
|
ALL_DONE=true
|
||||||
|
if [ -f "$capsule/epic.yaml" ]; then
|
||||||
|
for story_id in $(grep -o 'e[0-9]*s[0-9]*' "$capsule/epic.yaml" 2>/dev/null || true); do
|
||||||
|
STATUS=$(grep "$story_id:" specs/execution-status.yaml 2>/dev/null | awk '{print $2}' || echo "todo")
|
||||||
|
if [ "$STATUS" != "done" ]; then
|
||||||
|
ALL_DONE=false
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "$ALL_DONE" = true ]; then
|
||||||
|
mkdir -p specs/epics/archive
|
||||||
|
echo " Archiving completed epic: $capsule_name → specs/epics/archive/"
|
||||||
|
mv "$capsule" "specs/epics/archive/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Worktree cleanup
|
||||||
|
WORKTREE_PATH="../$FEATURE_BRANCH"
|
||||||
|
if git worktree list --porcelain 2>/dev/null | grep -q "^worktree $WORKTREE_PATH$"; then
|
||||||
|
echo "==> Removing worktree $WORKTREE_PATH"
|
||||||
|
git worktree remove "$WORKTREE_PATH" 2>/dev/null || git worktree remove -f "$WORKTREE_PATH"
|
||||||
|
fi
|
||||||
|
git worktree prune 2>/dev/null || true
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then
|
||||||
|
git branch -d "$FEATURE_BRANCH" 2>/dev/null || {
|
||||||
|
echo "WARN: Could not delete branch $FEATURE_BRANCH (not fully merged? use -D manually if intended)"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
git checkout "$DEFAULT_BRANCH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Land complete."
|
||||||
|
echo " Branch: $FEATURE_BRANCH (removed)"
|
||||||
|
echo " Commit: $LAND_SHA on $DEFAULT_BRANCH"
|
||||||
|
echo " Message: $COMMIT_MSG"
|
||||||
|
echo " cwd: $(pwd)"
|
||||||
|
echo " current: $(git branch --show-current)"
|
||||||
|
echo ""
|
||||||
|
echo "semantic-release will pick up the push to $DEFAULT_BRANCH when configured."
|
||||||
Reference in New Issue
Block a user