Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 |
@@ -1,3 +1,7 @@
|
|||||||
|
# Manage environment template
|
||||||
|
# Copy this file to .env, fill in the required values, and export them in your shell
|
||||||
|
# before running docker compose. Compose files use interpolation, not env_file.
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=0.1.0
|
||||||
APP_BUILD_INFO=dev
|
APP_BUILD_INFO=dev
|
||||||
@@ -44,6 +48,7 @@ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
VITE_GRAFANA_URL=https://grafana.example.com
|
VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ The project consists of two subprojects:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
|
||||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
- Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
- SQLite-indexed media table with full-library sort/filter
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||||
- Remote file browser with ffprobe preview and job execution
|
- Remote file browser with ffprobe preview and job execution
|
||||||
- Jellyfin API integration for library metadata and user identity data
|
- Jellyfin API integration for library metadata and user identity data
|
||||||
- SSH-based file inspection and remote job templates
|
- SSH-based file inspection and safe remote job templates
|
||||||
|
- Addon pages for Grafana, Prometheus, and SSH tasks at `/addons/:addonId`
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -39,7 +40,9 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at http://localhost:8080.
|
Open the app at <http://localhost:8080>.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
Local development with hot reload:
|
Local development with hot reload:
|
||||||
|
|
||||||
@@ -47,9 +50,9 @@ Local development with hot reload:
|
|||||||
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.
|
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
||||||
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
|
|
||||||
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts.
|
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
||||||
|
|
||||||
### Manual backend/frontend development
|
### Manual backend/frontend development
|
||||||
|
|
||||||
@@ -76,13 +79,16 @@ The Compose files use environment-variable interpolation. Export the required va
|
|||||||
Production-style example with shell exports:
|
Production-style example with shell exports:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export BACKEND_APP_HOST=manage.example.com
|
export BACKEND_APP_HOST=api.manage.example.com
|
||||||
export FRONTEND_APP_HOST=manage.example.com
|
export FRONTEND_APP_HOST=manage.example.com
|
||||||
|
export GRAFANA_APP_HOST=grafana.manage.example.com
|
||||||
export CERT_RESOLVER=letsencrypt
|
export CERT_RESOLVER=letsencrypt
|
||||||
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/
|
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
export VITE_OIDC_CLIENT_ID=manage
|
export VITE_OIDC_CLIENT_ID=manage
|
||||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
export VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||||
|
export VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
@@ -90,7 +96,7 @@ docker compose up --build
|
|||||||
Inline one-liner example:
|
Inline one-liner example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com GRAFANA_APP_HOST=grafana.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/ VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_PROMETHEUS_URL=https://prometheus.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:
|
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
||||||
@@ -124,27 +130,33 @@ SMTP_TIMEOUT=30
|
|||||||
|
|
||||||
# Authentik / OIDC
|
# Authentik / OIDC
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||||
OIDC_AUDIENCE=media-library-viewer
|
OIDC_AUDIENCE=manage
|
||||||
OIDC_JWKS_URL=
|
OIDC_JWKS_URL=
|
||||||
OIDC_CLOCK_SKEW_SECONDS=30
|
OIDC_CLOCK_SKEW_SECONDS=30
|
||||||
|
|
||||||
# Frontend OIDC settings
|
# Frontend OIDC settings
|
||||||
VITE_OIDC_ENABLED=true
|
VITE_OIDC_ENABLED=true
|
||||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
VITE_OIDC_CLIENT_ID=manage
|
||||||
VITE_OIDC_SCOPE=openid profile email
|
VITE_OIDC_SCOPE=openid profile email
|
||||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
|
||||||
|
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links
|
||||||
|
GRAFANA_URL=http://grafana:3000
|
||||||
|
PROMETHEUS_URL=http://prometheus:9090
|
||||||
|
VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remote server requirements
|
## Remote server requirements
|
||||||
|
|
||||||
The remote server needs:
|
The remote server needs:
|
||||||
|
|
||||||
- Linux `/proc` and `/sys/block` for monitoring
|
|
||||||
- `/bin/sh` (POSIX shell)
|
- `/bin/sh` (POSIX shell)
|
||||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
|
- `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:
|
The SSH client rejects unknown host keys. Connect manually once first:
|
||||||
|
|
||||||
@@ -167,5 +179,6 @@ cd frontend && npx tsc --noEmit && npm run build
|
|||||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
||||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
- 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`.
|
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
||||||
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
|
|
||||||
- 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.
|
- 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.
|
||||||
|
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
||||||
|
- Grafana and Prometheus widget adapters use `GRAFANA_URL` and `PROMETHEUS_URL` (backend) and `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` (frontend) for deep-links; no credentials are stored in widget config.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ services:
|
|||||||
VITE_OIDC_ENABLED: "false"
|
VITE_OIDC_ENABLED: "false"
|
||||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
||||||
VITE_GRAFANA_URL: "http://localhost:3000"
|
VITE_GRAFANA_URL: "http://localhost:3000"
|
||||||
|
VITE_PROMETHEUS_URL: "http://localhost:9090"
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ services:
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
||||||
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
||||||
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
||||||
|
VITE_PROMETHEUS_URL: ${VITE_PROMETHEUS_URL:-http://localhost:9090}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
||||||
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -256,6 +256,54 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
- Job templates should remain centralized in `jobs.py` for future extension.
|
- Job templates should remain centralized in `jobs.py` for future extension.
|
||||||
- Remote job template values must be shell-quoted before execution.
|
- Remote job template values must be shell-quoted before execution.
|
||||||
|
|
||||||
|
## Configurable Dashboard Widgets
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The dashboard is composed of persisted widget instances stored in the backend SQLite
|
||||||
|
settings database. Each widget has a type, title, configuration, enabled flag, and
|
||||||
|
sort order. The frontend renders enabled widgets in sort order and fetches data
|
||||||
|
independently through the backend source adapters.
|
||||||
|
|
||||||
|
### Widget types
|
||||||
|
|
||||||
|
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
|
||||||
|
- **Backups** — backup job summary and active alerts.
|
||||||
|
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
|
||||||
|
- **Prometheus metric** — result of a PromQL instant query.
|
||||||
|
- **SSH task output** — output of a saved task run on a machine.
|
||||||
|
- **Static text** — plain text or markdown note.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Widget `config` may not contain credential keys such as `password`, `token`,
|
||||||
|
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
|
||||||
|
secrets (e.g., base64 blobs, `sk-` prefixes).
|
||||||
|
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
|
||||||
|
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
|
||||||
|
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
|
||||||
|
are not accepted.
|
||||||
|
|
||||||
|
### Addon pages
|
||||||
|
|
||||||
|
Each non-core addon gets a dedicated page at `/addons/:addonId`:
|
||||||
|
|
||||||
|
- `/addons/grafana`
|
||||||
|
- `/addons/prometheus`
|
||||||
|
- `/addons/ssh-tasks`
|
||||||
|
|
||||||
|
Unknown addons render a "not installed" alert.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `GET /api/widgets/sources` — list source types.
|
||||||
|
- `GET /api/widgets/types` — list widget type metadata.
|
||||||
|
- `GET /api/widgets/instances` — list widget instances.
|
||||||
|
- `POST /api/widgets/instances` — create instance.
|
||||||
|
- `PUT /api/widgets/instances/{id}` — update instance.
|
||||||
|
- `DELETE /api/widgets/instances/{id}` — delete instance.
|
||||||
|
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ARG VITE_OIDC_REDIRECT_URI=
|
|||||||
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||||
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
ARG VITE_PROMETHEUS_URL=http://localhost:9090
|
||||||
ARG VITE_APP_VERSION=0.1.0
|
ARG VITE_APP_VERSION=0.1.0
|
||||||
ARG VITE_APP_BUILD_INFO=dev
|
ARG VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ ENV VITE_API_URL=${VITE_API_URL} \
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
||||||
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
||||||
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
||||||
|
VITE_PROMETHEUS_URL=${VITE_PROMETHEUS_URL} \
|
||||||
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
||||||
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ ENV VITE_API_URL=/api \
|
|||||||
VITE_OIDC_ENABLED=false \
|
VITE_OIDC_ENABLED=false \
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
||||||
VITE_GRAFANA_URL=http://localhost:3000 \
|
VITE_GRAFANA_URL=http://localhost:3000 \
|
||||||
|
VITE_PROMETHEUS_URL=http://localhost:9090 \
|
||||||
VITE_APP_VERSION=0.1.0 \
|
VITE_APP_VERSION=0.1.0 \
|
||||||
VITE_APP_BUILD_INFO=dev
|
VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
|
|||||||
import { Actions } from "./pages/Actions";
|
import { Actions } from "./pages/Actions";
|
||||||
import BackupsPage from "./components/BackupsPage";
|
import BackupsPage from "./components/BackupsPage";
|
||||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||||
|
import { AddonPage } from "./pages/AddonPage";
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
@@ -449,6 +450,7 @@ function AppInner() {
|
|||||||
<Route path="/backups" element={<BackupsPage />} />
|
<Route path="/backups" element={<BackupsPage />} />
|
||||||
<Route path="/observability" element={<ObservabilityPage />} />
|
<Route path="/observability" element={<ObservabilityPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -480,6 +482,7 @@ function AppInner() {
|
|||||||
<Route path="/backups" element={<BackupsPage />} />
|
<Route path="/backups" element={<BackupsPage />} />
|
||||||
<Route path="/observability" element={<ObservabilityPage />} />
|
<Route path="/observability" element={<ObservabilityPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function GrafanaAddonPage() {
|
||||||
|
const grafanaUrl =
|
||||||
|
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Grafana</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metrics & logs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Open the full Grafana instance for dashboards, metrics, and log
|
||||||
|
exploration.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={grafanaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
>
|
||||||
|
Open Grafana
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function PrometheusAddonPage() {
|
||||||
|
const prometheusUrl =
|
||||||
|
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
|
||||||
|
"http://localhost:9090";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Prometheus</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metrics explorer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={prometheusUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
>
|
||||||
|
Open Prometheus
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Terminal } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function SshTasksAddonPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">SSH tasks</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Saved actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create, edit, and run saved shell or Python tasks against local or
|
||||||
|
remote machines.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate("/actions")}>
|
||||||
|
<Terminal className="mr-2 h-4 w-4" />
|
||||||
|
Open Actions
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
||||||
|
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
||||||
|
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useDeleteWidgetInstance,
|
||||||
|
useSaveWidgetInstance,
|
||||||
|
useWidgetInstances,
|
||||||
|
useWidgetTypes,
|
||||||
|
} from "../hooks/useWidgets";
|
||||||
|
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
|
||||||
|
import type {
|
||||||
|
MonitoringMachine,
|
||||||
|
SavedTask,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
type WidgetDefinition,
|
||||||
|
} from "../widgets/registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(widgetType: string): WidgetInstanceInput {
|
||||||
|
const def = getWidgetDefinition(widgetType);
|
||||||
|
return {
|
||||||
|
addon_id: def?.addonId ?? "",
|
||||||
|
widget_type: widgetType,
|
||||||
|
title: def?.name ?? "",
|
||||||
|
config: { ...(def?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetConfigFields({
|
||||||
|
definition,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
machines,
|
||||||
|
tasks,
|
||||||
|
}: {
|
||||||
|
definition: WidgetDefinition;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
machines: MonitoringMachine[];
|
||||||
|
tasks: SavedTask[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{definition.configFields.map((field) => {
|
||||||
|
const value = config[field.key] ?? "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
definition.widgetType === "jellyfin" &&
|
||||||
|
field.key === "machine_id"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={String(value)}
|
||||||
|
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.key}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Default</SelectItem>
|
||||||
|
{machines
|
||||||
|
.filter((m) => m.enabled && m.services.includes("jellyfin"))
|
||||||
|
.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={String(value)}
|
||||||
|
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.key}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tasks
|
||||||
|
.filter((t) => t.enabled)
|
||||||
|
.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "number") {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type="number"
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[field.key]:
|
||||||
|
e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[field.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||||
|
const { data: instances = [] } = useWidgetInstances();
|
||||||
|
const { data: types = [] } = useWidgetTypes();
|
||||||
|
const { data: machines = [] } = useMonitoringSettings();
|
||||||
|
const { data: tasks = [] } = useTasks();
|
||||||
|
const saveWidget = useSaveWidgetInstance();
|
||||||
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
|
||||||
|
|
||||||
|
const sortedInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
[...instances].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
),
|
||||||
|
[instances],
|
||||||
|
);
|
||||||
|
|
||||||
|
function startAdd(widgetType: string) {
|
||||||
|
setDraft(emptyDraft(widgetType));
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(instance: WidgetInstance) {
|
||||||
|
setDraft({
|
||||||
|
id: instance.id,
|
||||||
|
addon_id: instance.addon_id,
|
||||||
|
widget_type: instance.widget_type,
|
||||||
|
title: instance.title,
|
||||||
|
config: instance.config,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
sort_order: instance.sort_order,
|
||||||
|
});
|
||||||
|
setEditingId(instance.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDraft(null);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!draft) return;
|
||||||
|
await saveWidget.mutateAsync(draft);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(instance: WidgetInstance) {
|
||||||
|
await saveWidget.mutateAsync({
|
||||||
|
...instance,
|
||||||
|
enabled: !instance.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveInstance(index: number, direction: -1 | 1) {
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
||||||
|
const a = sortedInstances[index];
|
||||||
|
const b = sortedInstances[targetIndex];
|
||||||
|
await Promise.all([
|
||||||
|
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
|
||||||
|
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeInstance(instance: WidgetInstance) {
|
||||||
|
await deleteWidget.mutateAsync(instance.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(next: boolean) {
|
||||||
|
if (!next) {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{draft
|
||||||
|
? editingId
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{draft && definition ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{definition.description}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, title: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sort_order)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sort_order:
|
||||||
|
e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigFields
|
||||||
|
definition={definition}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
machines={machines}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No widgets yet. Add one below.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const typeDef = getWidgetDefinition(instance.widget_type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{typeDef?.name ?? instance.widget_type}
|
||||||
|
</Badge>
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{registryDefinitions.map((def) => (
|
||||||
|
<Button
|
||||||
|
key={def.widgetType}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAdd(def.widgetType)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{def.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{types.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Widget registry is empty. Backend may not be running.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { getWidgetDefinition } from "../widgets/registry";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetInstance({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
if (!def) {
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title}>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Unknown widget type: {widget.widget_type}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = def.component;
|
||||||
|
return <Component widget={widget} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
GrafanaAddonPage,
|
||||||
|
PrometheusAddonPage,
|
||||||
|
SshTasksAddonPage,
|
||||||
|
} from "../addons";
|
||||||
|
|
||||||
|
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||||
|
grafana: GrafanaAddonPage,
|
||||||
|
prometheus: PrometheusAddonPage,
|
||||||
|
"ssh-tasks": SshTasksAddonPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddonPage() {
|
||||||
|
const { addonId } = useParams<{ addonId: string }>();
|
||||||
|
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||||
|
|
||||||
|
if (!Page) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Page />;
|
||||||
|
}
|
||||||
@@ -21,18 +21,17 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
useActivity,
|
|
||||||
useDashboardShortcuts,
|
useDashboardShortcuts,
|
||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
import { WidgetInstance } from "../components/WidgetInstance";
|
||||||
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
@@ -327,19 +326,6 @@ function ShortcutCard({
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: machines = [] } = useMonitoringSettings();
|
|
||||||
const jellyfinMachines = useMemo(
|
|
||||||
() =>
|
|
||||||
machines.filter(
|
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
|
||||||
),
|
|
||||||
[machines],
|
|
||||||
);
|
|
||||||
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
|
|
||||||
useState<string>("");
|
|
||||||
const selectedJellyfinId =
|
|
||||||
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
|
|
||||||
const { data: activity } = useActivity(selectedJellyfinId || undefined);
|
|
||||||
const { data: shortcuts = [] } = useDashboardShortcuts();
|
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||||
const saveShortcut = useSaveDashboardShortcut();
|
const saveShortcut = useSaveDashboardShortcut();
|
||||||
const deleteShortcut = useDeleteDashboardShortcut();
|
const deleteShortcut = useDeleteDashboardShortcut();
|
||||||
@@ -348,6 +334,16 @@ export function Dashboard() {
|
|||||||
emptyShortcut(),
|
emptyShortcut(),
|
||||||
);
|
);
|
||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
|
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||||
|
|
||||||
|
const visibleWidgets = useMemo(
|
||||||
|
() =>
|
||||||
|
widgetInstances
|
||||||
|
.filter((w) => w.enabled)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
[widgetInstances],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
@@ -382,9 +378,14 @@ export function Dashboard() {
|
|||||||
title="Shortcuts"
|
title="Shortcuts"
|
||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" onClick={openCreateShortcut}>
|
<div className="flex gap-2">
|
||||||
Add shortcut
|
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
||||||
</Button>
|
Edit dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={openCreateShortcut}>
|
||||||
|
Add shortcut
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{shortcuts.length ? (
|
{shortcuts.length ? (
|
||||||
@@ -416,42 +417,9 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
{visibleWidgets.map((widget) => (
|
||||||
title="Jellyfin activity"
|
<WidgetInstance key={widget.id} widget={widget} />
|
||||||
description="Live sessions and idle users from Jellyfin."
|
))}
|
||||||
action={
|
|
||||||
jellyfinMachines.length > 1 ? (
|
|
||||||
<Select
|
|
||||||
value={selectedJellyfinId}
|
|
||||||
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{jellyfinMachines.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : jellyfinMachines.length === 1 ? (
|
|
||||||
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{activity ? (
|
|
||||||
<NowPlaying
|
|
||||||
sessions={activity}
|
|
||||||
onSelectSession={(session) =>
|
|
||||||
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<BackupDashboardWidget />
|
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
@@ -473,6 +441,10 @@ export function Dashboard() {
|
|||||||
setDeleteShortcutId(null);
|
setDeleteShortcutId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<WidgetConfigDialog
|
||||||
|
open={widgetDialogOpen}
|
||||||
|
onClose={() => setWidgetDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,9 +141,55 @@ npm run test -- src/widgets/registry.test.ts # 3 passed
|
|||||||
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
|
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
|
||||||
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
|
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
|
||||||
|
|
||||||
|
## Completed tasks (Slice 4)
|
||||||
|
|
||||||
|
All Slice 4 tasks are marked `- [x]` in `tasks.md`:
|
||||||
|
|
||||||
|
- [x] 4.1 Refactor `Dashboard.tsx` to render enabled widget instances in sort order
|
||||||
|
- [x] 4.2 Create `WidgetInstance` renderer component
|
||||||
|
- [x] 4.3 Create `WidgetConfigDialog` for add/edit/reorder/delete widgets
|
||||||
|
- [x] 4.4 Create addon pages (`AddonPage`, `GrafanaAddonPage`, `PrometheusAddonPage`, `SshTasksAddonPage`)
|
||||||
|
- [x] 4.5 Register `/addons/:addonId` route in `App.tsx`
|
||||||
|
- [x] 4.6 Update `docs/REQUIREMENTS.md` with widget system documentation
|
||||||
|
|
||||||
|
## Files changed (Slice 4)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `frontend/src/components/WidgetInstance.tsx` — Renders a widget instance by looking up its definition and dispatching to the registered component.
|
||||||
|
- `frontend/src/components/WidgetConfigDialog.tsx` — Dashboard widget configuration UI: list, add, edit, delete, reorder, enable/disable.
|
||||||
|
- `frontend/src/pages/AddonPage.tsx` — Route mapper for `/addons/:addonId`.
|
||||||
|
- `frontend/src/addons/GrafanaAddonPage.tsx` — Grafana addon landing page (deep-link only).
|
||||||
|
- `frontend/src/addons/PrometheusAddonPage.tsx` — Prometheus addon landing page.
|
||||||
|
- `frontend/src/addons/SshTasksAddonPage.tsx` — SSH tasks addon landing page.
|
||||||
|
- `frontend/src/addons/index.ts` — Barrel exports.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `frontend/src/pages/Dashboard.tsx` — Replaced hard-coded Jellyfin/Backups sections with widget instance loop; kept Shortcuts section; added "Edit dashboard" button.
|
||||||
|
- `frontend/src/App.tsx` — Registered `/addons/:addonId` route in both OIDC and non-OIDC route trees.
|
||||||
|
- `docs/REQUIREMENTS.md` — Added Configurable Dashboard Widgets section.
|
||||||
|
|
||||||
|
## Verification (Slice 4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv/bin/python -m ruff check . # All checks passed
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint # 2 pre-existing warnings, 0 errors
|
||||||
|
npm run build # Built successfully
|
||||||
|
npm run test -- src/widgets/registry.test.ts # 3 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from design (Slice 4)
|
||||||
|
|
||||||
|
- The "Edit dashboard" button lives in the Shortcuts section action area for now. A future UI pass can move it to a dedicated dashboard header.
|
||||||
|
- Machine/task selectors in the config dialog filter to enabled Jellyfin machines / enabled tasks, which is slightly stricter than the design's generic string field.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
- Slice 4: Dashboard loop + configuration UI + addon pages
|
- Phase 1 widget system is complete. Future work could include widget grid layout, drag-and-drop reorder, richer Prometheus visualizations, or migrating shortcuts into the widget system.
|
||||||
|
|
||||||
## PR boundary
|
## PR boundary
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user