Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b |
@@ -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,69 @@
|
|||||||
|
import type {
|
||||||
|
WidgetDataResponse,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
WidgetTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
export async function fetchWidgetSources(): Promise<string[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/sources`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget sources");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/types`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget types");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget instances");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWidgetInstance(
|
||||||
|
input: WidgetInstanceInput,
|
||||||
|
): Promise<WidgetInstance> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWidgetInstance(
|
||||||
|
input: WidgetInstanceInput,
|
||||||
|
): Promise<WidgetInstance> {
|
||||||
|
if (!input.id) throw new Error("Widget ID is required for update");
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWidgetInstance(
|
||||||
|
widgetId: string,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetData(
|
||||||
|
widgetId: string,
|
||||||
|
): Promise<WidgetDataResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget data");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createWidgetInstance,
|
||||||
|
deleteWidgetInstance,
|
||||||
|
fetchWidgetData,
|
||||||
|
fetchWidgetInstances,
|
||||||
|
fetchWidgetSources,
|
||||||
|
fetchWidgetTypes,
|
||||||
|
updateWidgetInstance,
|
||||||
|
} from "../api/widgets";
|
||||||
|
import type { WidgetInstanceInput } from "../types";
|
||||||
|
|
||||||
|
export function useWidgetInstances() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "instances"],
|
||||||
|
queryFn: fetchWidgetInstances,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetData(widgetId: string, refreshInterval: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "data", widgetId],
|
||||||
|
queryFn: () => fetchWidgetData(widgetId),
|
||||||
|
refetchInterval: refreshInterval || false,
|
||||||
|
enabled: !!widgetId,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveWidgetInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: WidgetInstanceInput) =>
|
||||||
|
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWidgetInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetSources() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "sources"],
|
||||||
|
queryFn: fetchWidgetSources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetTypes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "types"],
|
||||||
|
queryFn: fetchWidgetTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
|
|||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
targets: string[];
|
targets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetInstance {
|
||||||
|
id: string;
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetInstanceInput {
|
||||||
|
id?: string | null;
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetTypeInfo {
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
source_type: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDataResponse {
|
||||||
|
widget_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
error: string | null;
|
||||||
|
fetched_at: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 { BackupDashboardSummary } from "../types/backups";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupsWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Jobs</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{summary.success_rate_24h}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">24h Success</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{summary.active_alerts > 0 ? (
|
||||||
|
<Badge variant="destructive">{summary.active_alerts}</Badge>
|
||||||
|
) : (
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Alerts</div>
|
||||||
|
</div>
|
||||||
|
{summary.last_failed_at ? (
|
||||||
|
<div className="self-center text-xs text-destructive">
|
||||||
|
Last failed:{" "}
|
||||||
|
{new Date(summary.last_failed_at * 1000).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrafanaLinkWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const url = data?.data?.url as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-10 w-48" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : url ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
Open Grafana
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JellyfinWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : Array.isArray(sessions) ? (
|
||||||
|
<SessionActivityPanel
|
||||||
|
sessions={sessions}
|
||||||
|
emptyMessage="No recent user activity sessions right now."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromQLResult = {
|
||||||
|
resultType?: string;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromQLVectorSample = {
|
||||||
|
metric?: Record<string, string>;
|
||||||
|
value?: [number, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrometheusValue(result: PromQLResult | undefined): string {
|
||||||
|
if (!result) return "No data";
|
||||||
|
if (result.resultType === "scalar" && Array.isArray(result.result)) {
|
||||||
|
return String(result.result[1] ?? "No data");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.resultType === "vector" &&
|
||||||
|
Array.isArray(result.result) &&
|
||||||
|
result.result.length > 0
|
||||||
|
) {
|
||||||
|
const first = result.result[0] as PromQLVectorSample;
|
||||||
|
if (first.value) return String(first.value[1]);
|
||||||
|
}
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrometheusMetricWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const result = data?.data?.result as PromQLResult | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap text-sm">
|
||||||
|
{formatPrometheusValue(result)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SshTaskResult = {
|
||||||
|
exit_status: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SshTaskWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const result = data?.data as SshTaskResult | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : result ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Exit status:{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
result.exit_status === 0 ? "text-green-600" : "text-destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result.exit_status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.stdout ? (
|
||||||
|
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||||
|
{result.stdout}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{result.stderr ? (
|
||||||
|
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
|
||||||
|
{result.stderr}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
||||||
|
const text = data?.data?.text as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{text ? (
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No content configured.</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { BackupsWidget } from "./BackupsWidget";
|
||||||
|
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
|
export { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
|
export { StaticWidget } from "./StaticWidget";
|
||||||
|
export {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
WIDGET_REGISTRY,
|
||||||
|
} from "./registry";
|
||||||
|
export type { WidgetConfigField, WidgetDefinition } from "./registry";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
WIDGET_REGISTRY,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
describe("widget registry", () => {
|
||||||
|
it("contains exactly six Phase 1 types", () => {
|
||||||
|
const types = listWidgetTypes();
|
||||||
|
expect(types).toHaveLength(6);
|
||||||
|
expect(types.map((t) => t.widgetType).sort()).toEqual([
|
||||||
|
"backups",
|
||||||
|
"grafana-link",
|
||||||
|
"jellyfin",
|
||||||
|
"prometheus-metric",
|
||||||
|
"ssh-task",
|
||||||
|
"static",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has refresh intervals matching the spec", () => {
|
||||||
|
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
|
||||||
|
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
|
||||||
|
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
|
||||||
|
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
|
||||||
|
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines required metadata for every widget", () => {
|
||||||
|
for (const def of Object.values(WIDGET_REGISTRY)) {
|
||||||
|
expect(def.widgetType).toBeTruthy();
|
||||||
|
expect(def.addonId).toBeTruthy();
|
||||||
|
expect(def.name).toBeTruthy();
|
||||||
|
expect(def.sourceType).toBeTruthy();
|
||||||
|
expect(def.component).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { BackupsWidget } from "./BackupsWidget";
|
||||||
|
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
|
import { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
|
import { SshTaskWidget } from "./SshTaskWidget";
|
||||||
|
import { StaticWidget } from "./StaticWidget";
|
||||||
|
|
||||||
|
export interface WidgetConfigField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "select" | "boolean" | "number";
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
helper?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDefinition {
|
||||||
|
widgetType: string;
|
||||||
|
addonId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sourceType: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
defaultConfig: Record<string, unknown>;
|
||||||
|
configFields: WidgetConfigField[];
|
||||||
|
component: ComponentType<{ widget: WidgetInstance }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
||||||
|
jellyfin: {
|
||||||
|
widgetType: "jellyfin",
|
||||||
|
addonId: "core",
|
||||||
|
name: "Jellyfin activity",
|
||||||
|
description: "Live sessions and idle users from a Jellyfin server.",
|
||||||
|
sourceType: "jellyfin",
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
defaultConfig: { machine_id: "" },
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "machine_id",
|
||||||
|
label: "Machine ID",
|
||||||
|
type: "string",
|
||||||
|
helper: "Jellyfin machine id (empty = default)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
component: JellyfinWidget,
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
widgetType: "backups",
|
||||||
|
addonId: "backups",
|
||||||
|
name: "Backups",
|
||||||
|
description: "Backup job summary and active alerts.",
|
||||||
|
sourceType: "backups",
|
||||||
|
refreshInterval: 60_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configFields: [],
|
||||||
|
component: BackupsWidget,
|
||||||
|
},
|
||||||
|
"grafana-link": {
|
||||||
|
widgetType: "grafana-link",
|
||||||
|
addonId: "grafana",
|
||||||
|
name: "Grafana link",
|
||||||
|
description: "Deep-link to a Grafana dashboard or panel.",
|
||||||
|
sourceType: "grafana",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { dashboard_uid: "" },
|
||||||
|
configFields: [
|
||||||
|
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
|
||||||
|
{
|
||||||
|
key: "panel_id",
|
||||||
|
label: "Panel ID",
|
||||||
|
type: "number",
|
||||||
|
helper: "Optional",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
component: GrafanaLinkWidget,
|
||||||
|
},
|
||||||
|
"prometheus-metric": {
|
||||||
|
widgetType: "prometheus-metric",
|
||||||
|
addonId: "prometheus",
|
||||||
|
name: "Prometheus metric",
|
||||||
|
description: "Instant query result rendered as a metric.",
|
||||||
|
sourceType: "prometheus",
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
defaultConfig: { promql: "" },
|
||||||
|
configFields: [{ key: "promql", label: "PromQL query", type: "string" }],
|
||||||
|
component: PrometheusMetricWidget,
|
||||||
|
},
|
||||||
|
"ssh-task": {
|
||||||
|
widgetType: "ssh-task",
|
||||||
|
addonId: "ssh-tasks",
|
||||||
|
name: "SSH task output",
|
||||||
|
description: "Output of a saved task run on a machine.",
|
||||||
|
sourceType: "ssh_task",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { task_id: "" },
|
||||||
|
configFields: [{ key: "task_id", label: "Saved task ID", type: "string" }],
|
||||||
|
component: SshTaskWidget,
|
||||||
|
},
|
||||||
|
static: {
|
||||||
|
widgetType: "static",
|
||||||
|
addonId: "core",
|
||||||
|
name: "Static text",
|
||||||
|
description: "Plain text or markdown note.",
|
||||||
|
sourceType: "static",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { text: "" },
|
||||||
|
configFields: [{ key: "text", label: "Text", type: "string" }],
|
||||||
|
component: StaticWidget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWidgetDefinition(
|
||||||
|
widgetType: string,
|
||||||
|
): WidgetDefinition | undefined {
|
||||||
|
return WIDGET_REGISTRY[widgetType];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWidgetTypes(): WidgetDefinition[] {
|
||||||
|
return Object.values(WIDGET_REGISTRY);
|
||||||
|
}
|
||||||
@@ -93,10 +93,103 @@ Focused widget test output: `27 passed`.
|
|||||||
|
|
||||||
- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability.
|
- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability.
|
||||||
|
|
||||||
|
## Completed tasks (Slice 3)
|
||||||
|
|
||||||
|
All Slice 3 tasks are marked `- [x]` in `tasks.md`:
|
||||||
|
|
||||||
|
- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`)
|
||||||
|
- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`)
|
||||||
|
- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`)
|
||||||
|
- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`)
|
||||||
|
- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`)
|
||||||
|
- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`)
|
||||||
|
|
||||||
|
## Files changed (Slice 3)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data.
|
||||||
|
- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations.
|
||||||
|
- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields.
|
||||||
|
- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity.
|
||||||
|
- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary.
|
||||||
|
- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe).
|
||||||
|
- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result.
|
||||||
|
- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output.
|
||||||
|
- `frontend/src/widgets/StaticWidget.tsx` — Renders static text.
|
||||||
|
- `frontend/src/widgets/index.ts` — Barrel exports.
|
||||||
|
- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `frontend/src/types/index.ts` — Added widget TypeScript interfaces.
|
||||||
|
|
||||||
|
## Verification (Slice 3)
|
||||||
|
|
||||||
|
```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 3)
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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 3: Frontend types/API/hooks/registry/components
|
- 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.
|
||||||
- Slice 4: Dashboard loop + configuration UI + addon pages
|
|
||||||
|
|
||||||
## PR boundary
|
## PR boundary
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user