Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e6177d86 | |||
| 0866dc4136 | |||
| 11f093cd2c | |||
| 1bf8a34a97 | |||
| e0f66a51f7 |
@@ -17,6 +17,7 @@
|
|||||||
- Focused frontend typecheck: `npx tsc --noEmit`
|
- Focused frontend typecheck: `npx tsc --noEmit`
|
||||||
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
||||||
- Production stack: `docker compose up --build`
|
- Production stack: `docker compose up --build`
|
||||||
|
- Solo landing: after review and verification, squash-land a feature branch with `bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"`; do not commit directly on `main`.
|
||||||
|
|
||||||
## Repo-Specific Gotchas
|
## Repo-Specific Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,67 @@
|
|||||||
# Manage
|
# Manage
|
||||||
|
|
||||||
Manage is a media and server operations tool with Jellyfin integration, SSH file inspection, server monitoring, and safe remote job templates.
|
Manage is a media and server-operations application with Jellyfin integration, SSH file inspection, monitoring integrations, safe remote-job templates, a FastAPI backend, and a React single-page application.
|
||||||
|
|
||||||
See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history.
|
It includes a configurable dashboard, service registry, per-machine settings, a SQLite-indexed media library, a read-only Users view with optional Jellyseerr enrichment, remote file browsing with `ffprobe`, and SSH-based job execution.
|
||||||
See `docs/MIGRATION_PLAN.md` for the FastAPI + React architecture plan.
|
|
||||||
|
|
||||||
Project policy/docs:
|
## Architecture and scope
|
||||||
|
|
||||||
- License: `LICENSE` (MIT)
|
- `backend/` is the FastAPI API.
|
||||||
- Contributing guide: `CONTRIBUTING.md`
|
- `frontend/` is the React and TypeScript SPA.
|
||||||
|
- `archive/` retains the original Streamlit prototype for reference.
|
||||||
|
|
||||||
## Architecture
|
The root Compose files deploy **only** Manage's backend and frontend. Manage can expose `/metrics` and optional Alertmanager proxy endpoints, but it does not deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter as part of its normal stack. Configure service instances in the app's Services page.
|
||||||
|
|
||||||
The project consists of two subprojects:
|
## Prerequisites
|
||||||
|
|
||||||
- **`backend/`** — FastAPI Python API (see `backend/README.md`)
|
- Docker and Docker Compose for the supplied Compose stacks.
|
||||||
- **`frontend/`** — React + TypeScript SPA (see `frontend/README.md`)
|
- Python 3.11 or newer for manual backend development.
|
||||||
- **`archive/`** — Original Streamlit prototype (preserved for reference)
|
- Node.js and npm for manual frontend development.
|
||||||
|
- A valid Fernet key for `MANAGE_ENCRYPTION_KEY`, including in development Compose.
|
||||||
|
- For production: an existing external Docker network named `web`, Traefik, DNS/TLS configuration, and an OIDC provider.
|
||||||
|
|
||||||
## Features
|
## Local development with Compose
|
||||||
|
|
||||||
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts
|
1. Create `.env` from the template and set a valid `MANAGE_ENCRYPTION_KEY`. Docker Compose automatically reads `.env` for interpolation; alternatively, export the same variables in the shell.
|
||||||
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
|
||||||
- Service registry: configure Jellyfin, Jellyseerr, Alertmanager, Grafana, Prometheus, Nextcloud, and SSH task runner instances in the UI
|
|
||||||
- Per-machine settings for SSH, monitoring targets, and file browsing
|
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
|
||||||
- Remote file browser with ffprobe preview and job execution
|
|
||||||
- Jellyfin API integration for library metadata and user identity data
|
|
||||||
- SSH-based file inspection and safe remote job templates
|
|
||||||
|
|
||||||
## Quick Start
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
Generate a Fernet key if needed:
|
||||||
|
|
||||||
Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend. The compose files rely on environment-variable interpolation, so export the required values in your shell before running them (no `env_file` is needed):
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the development stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The development frontend is available at <http://localhost:5173> and the backend at <http://localhost:8000>. Development Compose sets `AUTH_ENABLED=false` and `VITE_OIDC_ENABLED=false`, but still requires `MANAGE_ENCRYPTION_KEY`. The backend cache, settings database, media index, saved SSH keys, tasks, and dashboard widgets persist outside rebuilt containers.
|
||||||
|
|
||||||
|
## Production-style deployment
|
||||||
|
|
||||||
|
The root [`docker-compose.yml`](docker-compose.yml) is designed for deployment behind Traefik; it does not publish localhost ports. Before starting it, configure `.env` (or shell variables) with the required values:
|
||||||
|
|
||||||
|
- `BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, and `CERT_RESOLVER` for Traefik routing and certificates.
|
||||||
|
- `OIDC_ISSUER_URL` and `OIDC_AUDIENCE` for backend authentication.
|
||||||
|
- `VITE_OIDC_ISSUER`, `VITE_OIDC_CLIENT_ID`, `VITE_OIDC_REDIRECT_URI`, and `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` for the frontend build.
|
||||||
|
- `MANAGE_ENCRYPTION_KEY`, a valid Fernet key used to encrypt service secrets at rest.
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at <http://localhost:8080>.
|
The production Compose file requires its external `web` network to exist. It is not a standalone local deployment; access is through the configured Traefik hostnames.
|
||||||
|
|
||||||
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
## Manual development
|
||||||
|
|
||||||
> **Observability is external.** Manage only ships its **backend** and **frontend**. It does **not** deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter. The backend exposes a `/metrics` endpoint and optional Alertmanager proxy endpoints so an *existing* observability deployment can scrape and consume them. For a ready-to-run example stack you can deploy alongside Manage, see [`docker-compose.observability.yml`](docker-compose.observability.yml) and [`docs/observability-runbooks.md`](docs/observability-runbooks.md).
|
### Backend
|
||||||
|
|
||||||
Local development with hot reload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
|
||||||
|
|
||||||
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
|
||||||
|
|
||||||
### Manual backend/frontend development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -66,126 +71,75 @@ pip install -e '.[dev]'
|
|||||||
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Tests and quality checks
|
||||||
|
|
||||||
The Compose files use environment-variable interpolation. Export the required variables in your shell or pass them inline; a `.env` file is optional, not required.
|
|
||||||
|
|
||||||
### Compose examples
|
|
||||||
|
|
||||||
Production-style example with shell exports:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export BACKEND_APP_HOST=api.manage.example.com
|
# Backend
|
||||||
export FRONTEND_APP_HOST=manage.example.com
|
cd backend
|
||||||
export CERT_RESOLVER=letsencrypt
|
ruff check .
|
||||||
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
python -m pytest
|
||||||
export VITE_OIDC_CLIENT_ID=manage
|
|
||||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|
||||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
|
||||||
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
|
||||||
|
|
||||||
docker compose up --build
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
> Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
A focused frontend typecheck can be run with `npx tsc --noEmit` from `frontend/`.
|
||||||
> the app on the **Services** page — no env vars for them.
|
|
||||||
|
|
||||||
Inline one-liner example:
|
## Configuration and operations
|
||||||
|
|
||||||
```bash
|
[`.env.example`](.env.example) is a template; do not commit real credentials or encryption keys. The Compose files interpolate environment values directly. Some template entries are for the optional observability example and are not consumed by the normal Manage Compose stack.
|
||||||
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
Optional SMTP settings (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`, `SMTP_USE_SSL`, and `SMTP_TIMEOUT`) support the Users message popup.
|
||||||
|
|
||||||
```bash
|
### Remote servers
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Example environment variables:
|
A managed remote server needs a POSIX `/bin/sh`, `python3`, `ffprobe`, `find`, `stat`, `df`, and `awk`. Configure its SSH credentials in Manage's Settings. Unknown SSH host keys are rejected; establish trust first, for example:
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optional backend logging level
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
||||||
# Optional SMTP settings for the Users -> message popup
|
|
||||||
SMTP_HOST=smtp.example.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your-smtp-username
|
|
||||||
SMTP_PASSWORD=your-smtp-password
|
|
||||||
SMTP_FROM_ADDRESS=no-reply@example.com
|
|
||||||
SMTP_FROM_NAME=Manage
|
|
||||||
SMTP_USE_TLS=true
|
|
||||||
SMTP_USE_SSL=false
|
|
||||||
SMTP_TIMEOUT=30
|
|
||||||
|
|
||||||
# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab.
|
|
||||||
# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required.
|
|
||||||
#
|
|
||||||
# Remote SSH machines can store their private key and optional passphrase directly in Settings,
|
|
||||||
# so no SSH key mount is required for normal use.
|
|
||||||
|
|
||||||
# Authentik / OIDC
|
|
||||||
AUTH_ENABLED=true
|
|
||||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
|
||||||
OIDC_AUDIENCE=manage
|
|
||||||
OIDC_JWKS_URL=
|
|
||||||
OIDC_CLOCK_SKEW_SECONDS=30
|
|
||||||
|
|
||||||
# Frontend OIDC settings
|
|
||||||
VITE_OIDC_ENABLED=true
|
|
||||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
|
||||||
VITE_OIDC_CLIENT_ID=manage
|
|
||||||
VITE_OIDC_SCOPE=openid profile email
|
|
||||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
|
||||||
|
|
||||||
# Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
|
||||||
# the app on the Services page. The only observability env var is the optional
|
|
||||||
# PROMETHEUS_ENABLED toggle (defaults on) for Manage's own /metrics endpoint.
|
|
||||||
|
|
||||||
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
|
||||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
||||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Remote server requirements
|
|
||||||
|
|
||||||
The remote server needs:
|
|
||||||
|
|
||||||
- `/bin/sh` (POSIX shell)
|
|
||||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
|
||||||
- SSH access with a key configured in the app's Settings tab
|
|
||||||
|
|
||||||
The SSH client rejects unknown host keys. Connect manually once first:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh user@host
|
ssh user@host
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
SSH commands run through `/bin/sh -c` regardless of the remote login shell.
|
||||||
|
|
||||||
|
### Optional observability example
|
||||||
|
|
||||||
|
[`docker-compose.observability.yml`](docker-compose.observability.yml) is a separate, optional stack for Grafana, Prometheus, Loki, Alertmanager, Alloy, and Node Exporter. It is not required by Manage. Its header documents required `*_ROOT` persistence directories, `CERT_RESOLVER`, and Grafana/Prometheus/Alertmanager host variables. With those prepared, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (lint + tests)
|
docker compose -f docker-compose.observability.yml up -d
|
||||||
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
|
|
||||||
|
|
||||||
# Frontend (lint + typecheck/build + tests)
|
|
||||||
cd frontend && npm run lint && npm run build && npm run test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused frontend typecheck: `npx tsc --noEmit`.
|
Set a non-default `GRAFANA_ADMIN_USER` and a strong, secret `GRAFANA_ADMIN_PASSWORD` before deploying this stack. Do not expose the example observability services with their defaults.
|
||||||
|
|
||||||
## Notes
|
See [`docs/observability-runbooks.md`](docs/observability-runbooks.md) for its operational documentation.
|
||||||
|
|
||||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
## Repository layout
|
||||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
|
||||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
```text
|
||||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. They deploy **only** the backend and frontend; Manage never deploys its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
.
|
||||||
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
├── backend/ # FastAPI API and tests
|
||||||
- Grafana, Prometheus, and Alertmanager are configured as **service instances** in the app (Services page); their widget adapters resolve URLs from service records, and no observability URLs/credentials live in env vars. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`. When no alertmanager service is configured, the alert proxy endpoints return graceful "not configured" responses.
|
├── frontend/ # React/TypeScript SPA and tests
|
||||||
|
├── archive/ # Preserved Streamlit prototype
|
||||||
|
├── docs/ # Requirements, migration, and operations docs
|
||||||
|
├── docker-compose.yml # Traefik-backed production-style stack
|
||||||
|
├── docker-compose.dev.yml # Local hot-reload development stack
|
||||||
|
└── docker-compose.observability.yml # Optional standalone observability example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project documents
|
||||||
|
|
||||||
|
- [Requirements and planning history](docs/REQUIREMENTS.md)
|
||||||
|
- [FastAPI + React migration plan](docs/MIGRATION_PLAN.md)
|
||||||
|
- [Contributing guide](CONTRIBUTING.md)
|
||||||
|
- [MIT license](LICENSE)
|
||||||
|
|||||||
@@ -220,7 +220,12 @@ class QbittorrentClient:
|
|||||||
if fields is None:
|
if fields is None:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
else:
|
else:
|
||||||
snap["torrents"][hash_] = fields
|
previous = snap["torrents"].get(hash_)
|
||||||
|
snap["torrents"][hash_] = (
|
||||||
|
{**previous, **fields}
|
||||||
|
if isinstance(previous, dict) and isinstance(fields, dict)
|
||||||
|
else fields
|
||||||
|
)
|
||||||
for hash_ in update.get("torrents_removed") or []:
|
for hash_ in update.get("torrents_removed") or []:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
categories = update.get("categories")
|
categories = update.get("categories")
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
|
|||||||
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
||||||
|
|
||||||
promql: str
|
promql: str
|
||||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||||
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
|
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
|
||||||
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
||||||
unit: Literal[
|
unit: Literal[
|
||||||
@@ -116,7 +116,7 @@ class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
|||||||
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
||||||
|
|
||||||
promql: str
|
promql: str
|
||||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||||
unit: str | None = None
|
unit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
@@ -83,7 +83,7 @@ class QbittorrentWidgetConfig(WidgetConfigBase):
|
|||||||
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
||||||
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
||||||
|
|
||||||
window_seconds: int = Field(default=1_800, ge=60, le=86_400)
|
window_seconds: int | Literal["all"] = 1_800
|
||||||
unit: Literal[
|
unit: Literal[
|
||||||
"none",
|
"none",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -95,6 +95,16 @@ class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
|||||||
] = "bytes_per_sec"
|
] = "bytes_per_sec"
|
||||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||||
|
|
||||||
|
@field_validator("window_seconds")
|
||||||
|
@classmethod
|
||||||
|
def validate_window_seconds(cls, value: int | str) -> int | str:
|
||||||
|
"""Allow all retained samples while bounding explicit numeric windows."""
|
||||||
|
if value == "all":
|
||||||
|
return value
|
||||||
|
if not isinstance(value, int) or not 60 <= value <= 86_400:
|
||||||
|
raise ValueError("window_seconds must be between 60 and 86400, or 'all'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="qbittorrent",
|
service_type="qbittorrent",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class SchedulerSample(BaseModel):
|
|||||||
|
|
||||||
class SchedulerSamplesResponse(BaseModel):
|
class SchedulerSamplesResponse(BaseModel):
|
||||||
service_id: str
|
service_id: str
|
||||||
window_seconds: int
|
window_seconds: int | None
|
||||||
|
all_values: bool = False
|
||||||
samples: list[SchedulerSample]
|
samples: list[SchedulerSample]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,14 +103,22 @@ def run_scheduler_action(
|
|||||||
def get_scheduler_samples(
|
def get_scheduler_samples(
|
||||||
service_id: str,
|
service_id: str,
|
||||||
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
|
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
|
||||||
|
all_values: bool = Query(default=False),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> SchedulerSamplesResponse:
|
) -> SchedulerSamplesResponse:
|
||||||
_require_qbittorrent(service_id, store)
|
_require_qbittorrent(service_id, store)
|
||||||
since_ts = _safe_int(time.time()) - window_seconds
|
sample_store = QbittorrentSampleStore()
|
||||||
samples = QbittorrentSampleStore().window(service_id, since_ts=since_ts)
|
if all_values:
|
||||||
|
samples = sample_store.window(service_id)
|
||||||
|
response_window: int | None = None
|
||||||
|
else:
|
||||||
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
|
samples = sample_store.window(service_id, since_ts=since_ts)
|
||||||
|
response_window = window_seconds
|
||||||
return SchedulerSamplesResponse(
|
return SchedulerSamplesResponse(
|
||||||
service_id=service_id,
|
service_id=service_id,
|
||||||
window_seconds=window_seconds,
|
window_seconds=response_window,
|
||||||
|
all_values=all_values,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ from typing import Any
|
|||||||
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
||||||
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
||||||
WINDOW_PRESETS: dict[str, int] = {
|
WINDOW_PRESETS: dict[str, int] = {
|
||||||
|
"5m": 300,
|
||||||
|
"15m": 900,
|
||||||
|
"30m": 1_800,
|
||||||
"1h": 3_600,
|
"1h": 3_600,
|
||||||
|
"3h": 10_800,
|
||||||
"6h": 21_600,
|
"6h": 21_600,
|
||||||
|
"12h": 43_200,
|
||||||
"24h": 86_400,
|
"24h": 86_400,
|
||||||
|
"2d": 172_800,
|
||||||
"7d": 604_800,
|
"7d": 604_800,
|
||||||
|
"14d": 1_209_600,
|
||||||
|
"30d": 2_592_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
||||||
@@ -36,9 +44,9 @@ def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
|||||||
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
||||||
|
|
||||||
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
||||||
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
|
sub-15s resolutions on high-cardinality queries. The 5m and 15m presets
|
||||||
the resulting point count to land in the 100–300 band; with
|
therefore return 20 and 60 points respectively; all longer presets stay
|
||||||
``target_points=200`` every preset yields 200 points.
|
in the target 100–300 point band.
|
||||||
"""
|
"""
|
||||||
return max(15, round(window_seconds / target_points))
|
return max(15, round(window_seconds / target_points))
|
||||||
|
|
||||||
|
|||||||
@@ -480,12 +480,16 @@ class QbittorrentWidgetSource:
|
|||||||
return {"error": "qBittorrent widget is missing its service"}
|
return {"error": "qBittorrent widget is missing its service"}
|
||||||
|
|
||||||
if widget_kind == "speed":
|
if widget_kind == "speed":
|
||||||
window_seconds = _safe_int(
|
configured_window = config.get("window_seconds")
|
||||||
config.get("window_seconds") or service.config.get("sample_retention_seconds") or 1_800
|
if configured_window == "all":
|
||||||
)
|
samples = QbittorrentSampleStore().window(service.id)
|
||||||
window_seconds = max(60, min(window_seconds, 86_400))
|
else:
|
||||||
since_ts = _safe_int(time.time()) - window_seconds
|
window_seconds = _safe_int(
|
||||||
samples = QbittorrentSampleStore().window(service.id, since_ts=since_ts)
|
configured_window or service.config.get("sample_retention_seconds") or 1_800
|
||||||
|
)
|
||||||
|
window_seconds = max(60, min(window_seconds, 86_400))
|
||||||
|
since_ts = _safe_int(time.time()) - window_seconds
|
||||||
|
samples = QbittorrentSampleStore().window(service.id, since_ts=since_ts)
|
||||||
series = [
|
series = [
|
||||||
{
|
{
|
||||||
"label": "download",
|
"label": "download",
|
||||||
@@ -543,6 +547,7 @@ class QbittorrentWidgetSource:
|
|||||||
"direction": direction,
|
"direction": direction,
|
||||||
"size": torrent.get("size"),
|
"size": torrent.get("size"),
|
||||||
"progress": torrent.get("progress"),
|
"progress": torrent.get("progress"),
|
||||||
|
"ratio": torrent.get("ratio"),
|
||||||
"dl_speed": torrent.get("dlspeed"),
|
"dl_speed": torrent.get("dlspeed"),
|
||||||
"up_speed": torrent.get("upspeed"),
|
"up_speed": torrent.get("upspeed"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,33 @@ from media_library_viewer_api.widgets.prometheus_range import (
|
|||||||
|
|
||||||
|
|
||||||
class TestStepForWindow:
|
class TestStepForWindow:
|
||||||
"""SC-104: every preset must yield 100–300 points."""
|
"""SC-104: presets preserve usable resolution without sub-15s steps."""
|
||||||
|
|
||||||
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
||||||
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
|
def test_presets_yield_supported_point_counts(self, preset: str) -> None:
|
||||||
window = WINDOW_PRESETS[preset]
|
window = WINDOW_PRESETS[preset]
|
||||||
step = step_for_window(window)
|
step = step_for_window(window)
|
||||||
# Clamped minimum.
|
# The Prometheus-safe 15-second floor limits the two short presets to
|
||||||
|
# 20 and 60 points; all longer windows stay in the 100–300 target band.
|
||||||
assert step >= 15
|
assert step >= 15
|
||||||
point_count = window // step
|
point_count = window // step
|
||||||
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
assert min(100, window // 15) <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
||||||
|
|
||||||
|
def test_window_presets_cover_the_shared_chart_windows(self) -> None:
|
||||||
|
assert WINDOW_PRESETS == {
|
||||||
|
"5m": 300,
|
||||||
|
"15m": 900,
|
||||||
|
"30m": 1_800,
|
||||||
|
"1h": 3_600,
|
||||||
|
"3h": 10_800,
|
||||||
|
"6h": 21_600,
|
||||||
|
"12h": 43_200,
|
||||||
|
"24h": 86_400,
|
||||||
|
"2d": 172_800,
|
||||||
|
"7d": 604_800,
|
||||||
|
"14d": 1_209_600,
|
||||||
|
"30d": 2_592_000,
|
||||||
|
}
|
||||||
|
|
||||||
def test_floor_of_fifteen_seconds(self) -> None:
|
def test_floor_of_fifteen_seconds(self) -> None:
|
||||||
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
||||||
|
|||||||
@@ -125,13 +125,21 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
"rid": 10,
|
"rid": 10,
|
||||||
"full_update": True,
|
"full_update": True,
|
||||||
"server_state": {"dl_info_speed": 100},
|
"server_state": {"dl_info_speed": 100},
|
||||||
"torrents": {"a": {"name": "A", "state": "downloading"}},
|
"torrents": {
|
||||||
|
"a": {
|
||||||
|
"name": "A",
|
||||||
|
"state": "downloading",
|
||||||
|
"size": 1_024,
|
||||||
|
"progress": 0.5,
|
||||||
|
"dlspeed": 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
partial = {
|
partial = {
|
||||||
"rid": 11,
|
"rid": 11,
|
||||||
"full_update": False,
|
"full_update": False,
|
||||||
"server_state": {"dl_info_speed": 200},
|
"server_state": {"dl_info_speed": 200},
|
||||||
"torrents": {"a": {"name": "A", "state": "pausedDL"}},
|
"torrents": {"a": {"dlspeed": 200}},
|
||||||
}
|
}
|
||||||
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
||||||
|
|
||||||
@@ -143,7 +151,11 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
r2 = self.client.maindata()
|
r2 = self.client.maindata()
|
||||||
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
||||||
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
|
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
|
||||||
self.assertEqual(r2["torrents"]["a"]["state"], "pausedDL") # merged
|
self.assertEqual(r2["torrents"]["a"]["dlspeed"], 200)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["name"], "A")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["state"], "downloading")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["size"], 1_024)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["progress"], 0.5)
|
||||||
|
|
||||||
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
||||||
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
||||||
@@ -227,8 +239,8 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
self.session.post.return_value = self._login_response()
|
self.session.post.return_value = self._login_response()
|
||||||
self.client._login()
|
self.client._login()
|
||||||
call_kwargs = self.session.post.call_args.kwargs
|
call_kwargs = self.session.post.call_args.kwargs
|
||||||
assert call_kwargs["timeout"] == (5.0, 5.0)
|
self.assertEqual(call_kwargs["timeout"], (5.0, 5.0))
|
||||||
assert not isinstance(call_kwargs["timeout"], int)
|
self.assertNotIsInstance(call_kwargs["timeout"], int)
|
||||||
|
|
||||||
def test_login_fails_message_names_bad_credentials(self) -> None:
|
def test_login_fails_message_names_bad_credentials(self) -> None:
|
||||||
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
||||||
|
|||||||
@@ -87,6 +87,32 @@ def test_scheduler_routes_expose_status_history_and_disabled_manual_run(schedule
|
|||||||
assert manual.status_code == 400
|
assert manual.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_samples_all_values_reads_all_retained_samples(scheduler_client):
|
||||||
|
client, store = scheduler_client
|
||||||
|
service = store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "qbittorrent",
|
||||||
|
"name": "qbit",
|
||||||
|
"config": {"base_url": "http://qbit:8080"},
|
||||||
|
"secrets": {},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
retained = [{"ts": 10, "dl_speed": 20, "up_speed": 30}]
|
||||||
|
with patch("media_library_viewer_api.routers.scheduler.QbittorrentSampleStore") as store_cls:
|
||||||
|
store_cls.return_value.window.return_value = retained
|
||||||
|
response = client.get(f"/api/scheduler/services/{service['id']}/samples?all_values=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"service_id": service["id"],
|
||||||
|
"window_seconds": None,
|
||||||
|
"all_values": True,
|
||||||
|
"samples": retained,
|
||||||
|
}
|
||||||
|
store_cls.return_value.window.assert_called_once_with(service["id"])
|
||||||
|
|
||||||
|
|
||||||
def test_sample_store_applies_time_and_row_limits(tmp_path):
|
def test_sample_store_applies_time_and_row_limits(tmp_path):
|
||||||
harness = ServiceDataHarness(tmp_path)
|
harness = ServiceDataHarness(tmp_path)
|
||||||
harness.register(QBITTORRENT_CONCERN)
|
harness.register(QBITTORRENT_CONCERN)
|
||||||
|
|||||||
@@ -1085,6 +1085,7 @@ def _fake_qbit_maindata():
|
|||||||
"state": "downloading",
|
"state": "downloading",
|
||||||
"size": 1000,
|
"size": 1000,
|
||||||
"progress": 0.5,
|
"progress": 0.5,
|
||||||
|
"ratio": 1.25,
|
||||||
"dlspeed": 500,
|
"dlspeed": 500,
|
||||||
"upspeed": 10,
|
"upspeed": 10,
|
||||||
},
|
},
|
||||||
@@ -1093,6 +1094,7 @@ def _fake_qbit_maindata():
|
|||||||
"state": "uploading",
|
"state": "uploading",
|
||||||
"size": 2000,
|
"size": 2000,
|
||||||
"progress": 1.0,
|
"progress": 1.0,
|
||||||
|
"ratio": 0.5,
|
||||||
"dlspeed": 0,
|
"dlspeed": 0,
|
||||||
"upspeed": 100,
|
"upspeed": 100,
|
||||||
},
|
},
|
||||||
@@ -1178,6 +1180,7 @@ async def test_qbittorrent_active_filters_current_transfers_only():
|
|||||||
assert len(active) == 2
|
assert len(active) == 2
|
||||||
names = [torrent["name"] for torrent in active]
|
names = [torrent["name"] for torrent in active]
|
||||||
assert names == ["Movie.mkv", "Show.mkv"]
|
assert names == ["Movie.mkv", "Show.mkv"]
|
||||||
|
assert [torrent["ratio"] for torrent in active] == [1.25, 0.5]
|
||||||
assert all((torrent["dl_speed"] or 0) > 0 or (torrent["up_speed"] or 0) > 0 for torrent in active)
|
assert all((torrent["dl_speed"] or 0) > 0 or (torrent["up_speed"] or 0) > 0 for torrent in active)
|
||||||
|
|
||||||
|
|
||||||
@@ -1224,6 +1227,21 @@ async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path):
|
|||||||
assert dl_points[-1]["v"] == 500000
|
assert dl_points[-1]["v"] == 500000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qbittorrent_speed_all_values_reads_all_retained_samples():
|
||||||
|
"""The all-values speed setting intentionally omits the time cutoff."""
|
||||||
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
|
|
||||||
|
adapter = QbittorrentWidgetSource()
|
||||||
|
service = ServiceRecord(id="svc-speed", service_type="qbittorrent", name="qbit", config={}, secrets={})
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as store_cls:
|
||||||
|
store_cls.return_value.window.return_value = [{"ts": 10, "dl_speed": 20, "up_speed": 30}]
|
||||||
|
result = await adapter.fetch(service, "speed", {"window_seconds": "all"})
|
||||||
|
|
||||||
|
store_cls.return_value.window.assert_called_once_with("svc-speed")
|
||||||
|
assert result["series"][0]["points"] == [{"t": 10_000, "v": 20}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qbittorrent_adapter_missing_service():
|
async def test_qbittorrent_adapter_missing_service():
|
||||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
- All in-app time-series widgets should use the shared range-aware `LineSeriesChart` component so range controls, filtering, and display formatting remain consistent across Prometheus and qBittorrent charts.
|
- All in-app time-series widgets should use the shared range-aware `LineSeriesChart` component so filtering and display formatting remain consistent across Prometheus and qBittorrent charts. A dashboard widget's configured window is its single source of range selection and the card renders the complete configured response; the standalone qBittorrent service-history page retains an interactive selector with **All values** for all retained samples.
|
||||||
|
|
||||||
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
||||||
wrapper (`components/ui/data-table.tsx`).
|
wrapper (`components/ui/data-table.tsx`).
|
||||||
@@ -321,7 +321,12 @@ These do not reference a service.
|
|||||||
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
|
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
|
||||||
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
|
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
|
||||||
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
||||||
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, selectable chart windows, and paginated scheduled-action history.
|
- The qBittorrent client must merge incremental torrent patches with the prior
|
||||||
|
snapshot so active-transfer rows retain their name, size, progress, and state
|
||||||
|
when only throughput changes.
|
||||||
|
- Active-torrent entries must show each torrent's qBittorrent share ratio
|
||||||
|
(uploaded ÷ downloaded) alongside its size and completion progress.
|
||||||
|
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history.
|
||||||
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
|
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
|
||||||
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
|
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
|
||||||
- Persistent polling failures should be visible in the service UI and application metrics; a new notification channel is not required for the first release.
|
- Persistent polling failures should be visible in the service UI and application metrics; a new notification channel is not required for the first release.
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ export function fetchSchedulerRuns(
|
|||||||
|
|
||||||
export function fetchSchedulerSamples(
|
export function fetchSchedulerSamples(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
windowSeconds: number,
|
window: number | "all",
|
||||||
): Promise<SchedulerSamplesResponse> {
|
): Promise<SchedulerSamplesResponse> {
|
||||||
return get<SchedulerSamplesResponse>(
|
return get<SchedulerSamplesResponse>(
|
||||||
`/api/scheduler/services/${serviceId}/samples`,
|
`/api/scheduler/services/${serviceId}/samples`,
|
||||||
{ window_seconds: String(windowSeconds) },
|
window === "all"
|
||||||
|
? { all_values: "true" }
|
||||||
|
: { window_seconds: String(window) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges";
|
import {
|
||||||
|
DEFAULT_CHART_RANGES,
|
||||||
|
type ChartRangeOption,
|
||||||
|
type ChartRangeValue,
|
||||||
|
} from "./chartRanges";
|
||||||
import {
|
import {
|
||||||
formatScaled,
|
formatScaled,
|
||||||
metricScaleInfo,
|
metricScaleInfo,
|
||||||
@@ -72,11 +76,13 @@ interface LineSeriesChartProps {
|
|||||||
scale?: MetricScale;
|
scale?: MetricScale;
|
||||||
/** Available displayed time ranges. Defaults to the shared range choices. */
|
/** Available displayed time ranges. Defaults to the shared range choices. */
|
||||||
rangeOptions?: readonly ChartRangeOption[];
|
rangeOptions?: readonly ChartRangeOption[];
|
||||||
/** Initial uncontrolled range. Defaults to the largest available option. */
|
/** Whether to render the interactive range selector. */
|
||||||
defaultRangeSeconds?: number;
|
showRangeSelector?: boolean;
|
||||||
|
/** Initial uncontrolled range. Defaults to the largest numeric option. */
|
||||||
|
defaultRangeSeconds?: ChartRangeValue;
|
||||||
/** Controlled range for consumers that refetch when the selection changes. */
|
/** Controlled range for consumers that refetch when the selection changes. */
|
||||||
rangeSeconds?: number;
|
rangeSeconds?: ChartRangeValue;
|
||||||
onRangeChange?: (rangeSeconds: number) => void;
|
onRangeChange?: (range: ChartRangeValue) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
||||||
@@ -86,13 +92,18 @@ export function LineSeriesChart({
|
|||||||
unit = "none",
|
unit = "none",
|
||||||
scale = "auto",
|
scale = "auto",
|
||||||
rangeOptions = DEFAULT_CHART_RANGES,
|
rangeOptions = DEFAULT_CHART_RANGES,
|
||||||
|
showRangeSelector = true,
|
||||||
defaultRangeSeconds,
|
defaultRangeSeconds,
|
||||||
rangeSeconds,
|
rangeSeconds,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
}: LineSeriesChartProps) {
|
}: LineSeriesChartProps) {
|
||||||
const initialRange =
|
const initialRange =
|
||||||
defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value;
|
defaultRangeSeconds ??
|
||||||
const [localRangeSeconds, setLocalRangeSeconds] = useState(initialRange);
|
[...rangeOptions].reverse().find((range) => typeof range.value === "number")
|
||||||
|
?.value;
|
||||||
|
const [localRangeSeconds, setLocalRangeSeconds] = useState<
|
||||||
|
ChartRangeValue | undefined
|
||||||
|
>(initialRange);
|
||||||
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
||||||
const latestTimestamp = series.reduce(
|
const latestTimestamp = series.reduce(
|
||||||
(max, seriesItem) =>
|
(max, seriesItem) =>
|
||||||
@@ -102,9 +113,10 @@ export function LineSeriesChart({
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const cutoff = selectedRangeSeconds
|
const cutoff =
|
||||||
? latestTimestamp - selectedRangeSeconds * 1000
|
typeof selectedRangeSeconds === "number"
|
||||||
: null;
|
? latestTimestamp - selectedRangeSeconds * 1000
|
||||||
|
: null;
|
||||||
const visibleSeries =
|
const visibleSeries =
|
||||||
cutoff !== null && latestTimestamp > 0
|
cutoff !== null && latestTimestamp > 0
|
||||||
? series.map((seriesItem) => ({
|
? series.map((seriesItem) => ({
|
||||||
@@ -125,18 +137,20 @@ export function LineSeriesChart({
|
|||||||
formatScaled(value, scaleInfo, unit);
|
formatScaled(value, scaleInfo, unit);
|
||||||
|
|
||||||
function handleRangeChange(value: string) {
|
function handleRangeChange(value: string) {
|
||||||
const nextRange = Number(value);
|
const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value);
|
||||||
setLocalRangeSeconds(nextRange);
|
setLocalRangeSeconds(nextRange);
|
||||||
onRangeChange?.(nextRange);
|
onRangeChange?.(nextRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rangeOptions.length > 0 && (
|
{showRangeSelector && rangeOptions.length > 0 && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
|
selectedRangeSeconds === undefined
|
||||||
|
? undefined
|
||||||
|
: String(selectedRangeSeconds)
|
||||||
}
|
}
|
||||||
onValueChange={handleRangeChange}
|
onValueChange={handleRangeChange}
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { LineSeriesChart } from "../LineSeriesChart";
|
import { LineSeriesChart } from "../LineSeriesChart";
|
||||||
import type { ChartSeries } from "../LineSeriesChart";
|
import type { ChartSeries } from "../LineSeriesChart";
|
||||||
import { chartRangesThrough } from "../chartRanges";
|
import { chartRangesThrough } from "../chartRanges";
|
||||||
@@ -38,6 +38,29 @@ describe("LineSeriesChart", () => {
|
|||||||
).toHaveTextContent("2 hours");
|
).toHaveTextContent("2 hours");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can hide the interactive selector for configured widgets", () => {
|
||||||
|
render(<LineSeriesChart series={[]} showRangeSelector={false} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all loaded values and reports that selection", () => {
|
||||||
|
const onRangeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<LineSeriesChart
|
||||||
|
series={[]}
|
||||||
|
rangeOptions={chartRangesThrough(3600)}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Chart range" }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "All values" }));
|
||||||
|
|
||||||
|
expect(onRangeChange).toHaveBeenCalledWith("all");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders with custom height", () => {
|
it("renders with custom height", () => {
|
||||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
chartRangesThrough,
|
||||||
|
PROMETHEUS_WINDOW_VALUES,
|
||||||
|
rangeSecondsFromWindow,
|
||||||
|
} from "../chartRanges";
|
||||||
|
|
||||||
|
describe("chart ranges", () => {
|
||||||
|
it("maps every persisted Prometheus window to its display duration", () => {
|
||||||
|
expect(PROMETHEUS_WINDOW_VALUES).toEqual([
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"3h",
|
||||||
|
"6h",
|
||||||
|
"12h",
|
||||||
|
"24h",
|
||||||
|
"2d",
|
||||||
|
"7d",
|
||||||
|
"14d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(PROMETHEUS_WINDOW_VALUES.map(rangeSecondsFromWindow)).toEqual([
|
||||||
|
300, 900, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 604800, 1209600,
|
||||||
|
2592000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all values after every finite range through the available history", () => {
|
||||||
|
expect(chartRangesThrough(86_400).at(-1)).toEqual({
|
||||||
|
value: "all",
|
||||||
|
label: "All values",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,45 @@
|
|||||||
|
export type ChartRangeValue = number | "all";
|
||||||
|
|
||||||
export interface ChartRangeOption {
|
export interface ChartRangeOption {
|
||||||
value: number;
|
value: ChartRangeValue;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared range choices used by every time-series chart. */
|
interface FiniteChartRange extends ChartRangeOption {
|
||||||
export const DEFAULT_CHART_RANGES: ChartRangeOption[] = [
|
key: string;
|
||||||
{ value: 900, label: "15 minutes" },
|
value: number;
|
||||||
{ value: 1800, label: "30 minutes" },
|
}
|
||||||
{ value: 3600, label: "1 hour" },
|
|
||||||
{ value: 21600, label: "6 hours" },
|
/** Canonical finite windows for chart configuration and display filtering. */
|
||||||
{ value: 86400, label: "24 hours" },
|
const FINITE_CHART_RANGES: readonly FiniteChartRange[] = [
|
||||||
{ value: 604800, label: "7 days" },
|
{ key: "5m", value: 300, label: "5 minutes" },
|
||||||
|
{ key: "15m", value: 900, label: "15 minutes" },
|
||||||
|
{ key: "30m", value: 1800, label: "30 minutes" },
|
||||||
|
{ key: "1h", value: 3600, label: "1 hour" },
|
||||||
|
{ key: "3h", value: 10800, label: "3 hours" },
|
||||||
|
{ key: "6h", value: 21600, label: "6 hours" },
|
||||||
|
{ key: "12h", value: 43200, label: "12 hours" },
|
||||||
|
{ key: "24h", value: 86400, label: "24 hours" },
|
||||||
|
{ key: "2d", value: 172800, label: "2 days" },
|
||||||
|
{ key: "7d", value: 604800, label: "7 days" },
|
||||||
|
{ key: "14d", value: 1209600, label: "14 days" },
|
||||||
|
{ key: "30d", value: 2592000, label: "30 days" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Shared range choices used by every time-series chart. */
|
||||||
|
export const DEFAULT_CHART_RANGES: readonly ChartRangeOption[] =
|
||||||
|
FINITE_CHART_RANGES;
|
||||||
|
|
||||||
|
/** Symbolic range values persisted by Prometheus chart and mean widgets. */
|
||||||
|
export const PROMETHEUS_WINDOW_VALUES = FINITE_CHART_RANGES.map(
|
||||||
|
(range) => range.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ALL_VALUES_CHART_RANGE: ChartRangeOption = {
|
||||||
|
value: "all",
|
||||||
|
label: "All values",
|
||||||
|
};
|
||||||
|
|
||||||
function formatRangeLabel(seconds: number): string {
|
function formatRangeLabel(seconds: number): string {
|
||||||
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
||||||
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
||||||
@@ -22,27 +49,33 @@ function formatRangeLabel(seconds: number): string {
|
|||||||
|
|
||||||
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
||||||
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
||||||
return [DEFAULT_CHART_RANGES[0]];
|
return [DEFAULT_CHART_RANGES[0], ALL_VALUES_CHART_RANGE];
|
||||||
}
|
}
|
||||||
const ranges = DEFAULT_CHART_RANGES.filter(
|
const ranges = FINITE_CHART_RANGES.filter(
|
||||||
(range) => range.value < maxSeconds,
|
(range) => range.value < maxSeconds,
|
||||||
);
|
);
|
||||||
const exact = DEFAULT_CHART_RANGES.find(
|
const exact = FINITE_CHART_RANGES.find((range) => range.value === maxSeconds);
|
||||||
(range) => range.value === maxSeconds,
|
return [
|
||||||
);
|
...(exact
|
||||||
return exact
|
? [...ranges, exact]
|
||||||
? [...ranges, exact]
|
: [
|
||||||
: [...ranges, { value: maxSeconds, label: formatRangeLabel(maxSeconds) }];
|
...ranges,
|
||||||
|
{ value: maxSeconds, label: formatRangeLabel(maxSeconds) },
|
||||||
|
]),
|
||||||
|
ALL_VALUES_CHART_RANGE,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeSecondsFromWindow(window: unknown): number {
|
export function rangeSecondsFromWindow(window: unknown): number {
|
||||||
const values: Record<string, number> = {
|
return (
|
||||||
"15m": 900,
|
FINITE_CHART_RANGES.find((range) => range.key === String(window))?.value ??
|
||||||
"30m": 1800,
|
3600
|
||||||
"1h": 3600,
|
);
|
||||||
"6h": 21600,
|
}
|
||||||
"24h": 86400,
|
|
||||||
"7d": 604800,
|
/** Numeric chart windows suitable for sources with bounded local retention. */
|
||||||
};
|
export function numericChartRangesThrough(maxSeconds: number): number[] {
|
||||||
return values[String(window)] ?? 3600;
|
return FINITE_CHART_RANGES.flatMap((range) =>
|
||||||
|
range.value <= maxSeconds ? [range.value] : [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fetchSchedulerStatus,
|
fetchSchedulerStatus,
|
||||||
runSchedulerAction,
|
runSchedulerAction,
|
||||||
} from "../api/scheduler";
|
} from "../api/scheduler";
|
||||||
|
import type { ChartRangeValue } from "../components/chartRanges";
|
||||||
|
|
||||||
export function useSchedulerStatus(serviceId: string) {
|
export function useSchedulerStatus(serviceId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -24,10 +25,13 @@ export function useSchedulerRuns(serviceId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchedulerSamples(serviceId: string, windowSeconds: number) {
|
export function useSchedulerSamples(
|
||||||
|
serviceId: string,
|
||||||
|
window: ChartRangeValue,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheduler", "samples", serviceId, windowSeconds],
|
queryKey: ["scheduler", "samples", serviceId, window],
|
||||||
queryFn: () => fetchSchedulerSamples(serviceId, windowSeconds),
|
queryFn: () => fetchSchedulerSamples(serviceId, window),
|
||||||
enabled: Boolean(serviceId),
|
enabled: Boolean(serviceId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,44 @@ describe("service registry", () => {
|
|||||||
expect(speed?.defaultConfig.unit).toBe("bytes_per_sec");
|
expect(speed?.defaultConfig.unit).toBe("bytes_per_sec");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shares expanded chart windows and an all-retained option", () => {
|
||||||
|
const propertiesOf = (kind: string) => {
|
||||||
|
const binding = SERVICE_REGISTRY[
|
||||||
|
kind === "speed" ? "qbittorrent" : "prometheus"
|
||||||
|
].widgets.find((widget) => widget.kind === kind);
|
||||||
|
const schema = binding?.configSchema as
|
||||||
|
| { properties?: Record<string, { enum?: string[] }> }
|
||||||
|
| undefined;
|
||||||
|
return schema?.properties ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(propertiesOf("chart").window?.enum).toEqual([
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"3h",
|
||||||
|
"6h",
|
||||||
|
"12h",
|
||||||
|
"24h",
|
||||||
|
"2d",
|
||||||
|
"7d",
|
||||||
|
"14d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(propertiesOf("speed").window_seconds?.enum).toEqual([
|
||||||
|
"300",
|
||||||
|
"900",
|
||||||
|
"1800",
|
||||||
|
"3600",
|
||||||
|
"10800",
|
||||||
|
"21600",
|
||||||
|
"43200",
|
||||||
|
"86400",
|
||||||
|
"all",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves a prometheus metric widget via the services list", () => {
|
it("resolves a prometheus metric widget via the services list", () => {
|
||||||
const widget: WidgetInstance = {
|
const widget: WidgetInstance = {
|
||||||
id: "w1",
|
id: "w1",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { RequestStatWidget } from "../widgets/RequestStatWidget";
|
|||||||
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
|
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
|
||||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
import { StaticWidget } from "../widgets/StaticWidget";
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
|
import {
|
||||||
|
numericChartRangesThrough,
|
||||||
|
PROMETHEUS_WINDOW_VALUES,
|
||||||
|
} from "../components/chartRanges";
|
||||||
import type {
|
import type {
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
ServiceTypeInfo,
|
ServiceTypeInfo,
|
||||||
@@ -64,6 +68,10 @@ const UNIT_VALUES = [
|
|||||||
"seconds",
|
"seconds",
|
||||||
];
|
];
|
||||||
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
|
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
|
||||||
|
const SPEED_WINDOW_VALUES = [
|
||||||
|
...numericChartRangesThrough(86_400).map(String),
|
||||||
|
"all",
|
||||||
|
];
|
||||||
const AXIS_FORMAT_PROPERTIES = {
|
const AXIS_FORMAT_PROPERTIES = {
|
||||||
unit: {
|
unit: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -186,7 +194,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
enum: PROMETHEUS_WINDOW_VALUES,
|
||||||
|
description: "Maximum history fetched for the chart",
|
||||||
},
|
},
|
||||||
...AXIS_FORMAT_PROPERTIES,
|
...AXIS_FORMAT_PROPERTIES,
|
||||||
},
|
},
|
||||||
@@ -233,7 +242,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
enum: PROMETHEUS_WINDOW_VALUES,
|
||||||
|
description: "Time window used to calculate the average",
|
||||||
},
|
},
|
||||||
unit: { type: "string" },
|
unit: { type: "string" },
|
||||||
},
|
},
|
||||||
@@ -282,7 +292,9 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
properties: {
|
properties: {
|
||||||
window_seconds: {
|
window_seconds: {
|
||||||
type: "integer",
|
type: "integer",
|
||||||
description: "Maximum data window available to the chart",
|
enum: SPEED_WINDOW_VALUES,
|
||||||
|
description:
|
||||||
|
"Maximum history fetched for the chart, or all retained samples",
|
||||||
},
|
},
|
||||||
...AXIS_FORMAT_PROPERTIES,
|
...AXIS_FORMAT_PROPERTIES,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
|
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LineSeriesChart } from "../../components/LineSeriesChart";
|
import { LineSeriesChart } from "../../components/LineSeriesChart";
|
||||||
import { chartRangesThrough } from "../../components/chartRanges";
|
import {
|
||||||
|
chartRangesThrough,
|
||||||
|
type ChartRangeValue,
|
||||||
|
} from "../../components/chartRanges";
|
||||||
import {
|
import {
|
||||||
useRunSchedulerAction,
|
useRunSchedulerAction,
|
||||||
useSchedulerRuns,
|
useSchedulerRuns,
|
||||||
@@ -29,9 +32,9 @@ function statusVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const [windowSeconds, setWindowSeconds] = useState(1800);
|
const [selectedRange, setSelectedRange] = useState<ChartRangeValue>(1800);
|
||||||
const status = useSchedulerStatus(instance.id);
|
const status = useSchedulerStatus(instance.id);
|
||||||
const samples = useSchedulerSamples(instance.id, windowSeconds);
|
const samples = useSchedulerSamples(instance.id, selectedRange);
|
||||||
const runs = useSchedulerRuns(instance.id);
|
const runs = useSchedulerRuns(instance.id);
|
||||||
const runNow = useRunSchedulerAction();
|
const runNow = useRunSchedulerAction();
|
||||||
const stale = Boolean(status.data?.enabled && status.data.is_stale);
|
const stale = Boolean(status.data?.enabled && status.data.is_stale);
|
||||||
@@ -111,9 +114,9 @@ export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
series={chartSeries}
|
series={chartSeries}
|
||||||
unit="bytes"
|
unit="bytes"
|
||||||
height={300}
|
height={300}
|
||||||
rangeOptions={chartRangesThrough(86400)}
|
rangeOptions={chartRangesThrough(86_400)}
|
||||||
rangeSeconds={windowSeconds}
|
rangeSeconds={selectedRange}
|
||||||
onRangeChange={setWindowSeconds}
|
onRangeChange={setSelectedRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+354
-353
@@ -3,492 +3,493 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MediaCounts {
|
export interface MediaCounts {
|
||||||
movies: number;
|
movies: number;
|
||||||
series: number;
|
series: number;
|
||||||
episodes: number;
|
episodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryCount {
|
export interface LibraryCount {
|
||||||
library: string;
|
library: string;
|
||||||
type: string;
|
type: string;
|
||||||
movies: number;
|
movies: number;
|
||||||
series: number;
|
series: number;
|
||||||
episodes: number;
|
episodes: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDirectoryItem {
|
export interface UserDirectoryItem {
|
||||||
jellyfin_id: string;
|
jellyfin_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
email_source: string;
|
email_source: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
avatar_source: string;
|
avatar_source: string;
|
||||||
contactable: boolean;
|
contactable: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
source_summary: string;
|
source_summary: string;
|
||||||
name_source: string;
|
name_source: string;
|
||||||
access_source: string;
|
access_source: string;
|
||||||
jellyseerr_user_id: number | null;
|
jellyseerr_user_id: number | null;
|
||||||
jellyseerr_username: string;
|
jellyseerr_username: string;
|
||||||
user_type: number | null;
|
user_type: number | null;
|
||||||
user_type_label: string;
|
user_type_label: string;
|
||||||
role: string;
|
role: string;
|
||||||
permissions: number;
|
permissions: number;
|
||||||
permissions_label: string;
|
permissions_label: string;
|
||||||
request_count: number | null;
|
request_count: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDirectoryResponse {
|
export interface UserDirectoryResponse {
|
||||||
items: UserDirectoryItem[];
|
items: UserDirectoryItem[];
|
||||||
total: number;
|
total: number;
|
||||||
jellyseerr_configured: boolean;
|
jellyseerr_configured: boolean;
|
||||||
jellyseerr_available: boolean;
|
jellyseerr_available: boolean;
|
||||||
jellyseerr_error: string;
|
jellyseerr_error: string;
|
||||||
jellyseerr_jellyfin_user_count: number;
|
jellyseerr_jellyfin_user_count: number;
|
||||||
jellyseerr_user_count: number;
|
jellyseerr_user_count: number;
|
||||||
enriched_count: number;
|
enriched_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMessageResponse {
|
export interface UserMessageResponse {
|
||||||
status: string;
|
status: string;
|
||||||
request_id: string;
|
request_id: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
from_address: string;
|
from_address: string;
|
||||||
recipient_count: number;
|
recipient_count: number;
|
||||||
attachment_count: number;
|
attachment_count: number;
|
||||||
recipient_labels: string[];
|
recipient_labels: string[];
|
||||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMessageQueueStatus {
|
export interface UserMessageQueueStatus {
|
||||||
state: "idle" | "busy" | "error" | "stopped";
|
state: "idle" | "busy" | "error" | "stopped";
|
||||||
worker_running: boolean;
|
worker_running: boolean;
|
||||||
stop_requested: boolean;
|
stop_requested: boolean;
|
||||||
pending_count: number;
|
pending_count: number;
|
||||||
active_request_id: string | null;
|
active_request_id: string | null;
|
||||||
last_request_id: string | null;
|
last_request_id: string | null;
|
||||||
last_result: string | null;
|
last_result: string | null;
|
||||||
last_error: string;
|
last_error: string;
|
||||||
last_error_at: number | null;
|
last_error_at: number | null;
|
||||||
last_success_at: number | null;
|
last_success_at: number | null;
|
||||||
last_activity_at: number | null;
|
last_activity_at: number | null;
|
||||||
sent_count: number;
|
sent_count: number;
|
||||||
failed_count: number;
|
failed_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NowPlayingSession {
|
export interface NowPlayingSession {
|
||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
state: string;
|
state: string;
|
||||||
transcoding: string;
|
transcoding: string;
|
||||||
transcoding_type: string;
|
transcoding_type: string;
|
||||||
device: string;
|
device: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHKey {
|
export interface SSHKey {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
private_key_set: boolean;
|
private_key_set: boolean;
|
||||||
passphrase_set: boolean;
|
passphrase_set: boolean;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
usage_count: number;
|
usage_count: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHKeyInput {
|
export interface SSHKeyInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
private_key: string;
|
private_key: string;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHKeyGenerated {
|
export interface SSHKeyGenerated {
|
||||||
name: string;
|
name: string;
|
||||||
private_key: string;
|
private_key: string;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
usage_count: number;
|
usage_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedTask {
|
export interface SavedTask {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
task_type: "shell" | "python";
|
task_type: "shell" | "python";
|
||||||
content: string;
|
content: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedTaskInput {
|
export interface SavedTaskInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
task_type: "shell" | "python";
|
task_type: "shell" | "python";
|
||||||
content: string;
|
content: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedTaskRun {
|
export interface SavedTaskRun {
|
||||||
id: string;
|
id: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
status: "success" | "failure" | "error" | "timeout" | string;
|
status: "success" | "failure" | "error" | "timeout" | string;
|
||||||
exit_status: number | null;
|
exit_status: number | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
stdout_tail: string;
|
stdout_tail: string;
|
||||||
stderr_tail: string;
|
stderr_tail: string;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResetLocalDatabaseInput {
|
export interface ResetLocalDatabaseInput {
|
||||||
confirm_phrase: string;
|
confirm_phrase: string;
|
||||||
acknowledge_settings_loss: boolean;
|
acknowledge_settings_loss: boolean;
|
||||||
acknowledge_media_index_loss: boolean;
|
acknowledge_media_index_loss: boolean;
|
||||||
acknowledge_irreversible: boolean;
|
acknowledge_irreversible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResetLocalDatabaseResponse {
|
export interface ResetLocalDatabaseResponse {
|
||||||
status: string;
|
status: string;
|
||||||
settings_db_removed: boolean;
|
settings_db_removed: boolean;
|
||||||
media_index_removed: boolean;
|
media_index_removed: boolean;
|
||||||
settings_files: string[];
|
settings_files: string[];
|
||||||
media_index_files: string[];
|
media_index_files: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppVersionInfo {
|
export interface AppVersionInfo {
|
||||||
app: string;
|
app: string;
|
||||||
backend_version: string;
|
backend_version: string;
|
||||||
backend_build: string;
|
backend_build: string;
|
||||||
backend_label: string;
|
backend_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaIndexStatus {
|
export interface MediaIndexStatus {
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
item_count: number;
|
item_count: number;
|
||||||
updated_at: number | null;
|
updated_at: number | null;
|
||||||
updated_at_label: string;
|
updated_at_label: string;
|
||||||
build_duration_seconds: number | null;
|
build_duration_seconds: number | null;
|
||||||
build_running: boolean;
|
build_running: boolean;
|
||||||
build_stage: string;
|
build_stage: string;
|
||||||
build_message: string;
|
build_message: string;
|
||||||
build_progress: number | null;
|
build_progress: number | null;
|
||||||
build_items_processed: number;
|
build_items_processed: number;
|
||||||
build_items_total: number;
|
build_items_total: number;
|
||||||
build_current_library: string;
|
build_current_library: string;
|
||||||
build_library_index: number;
|
build_library_index: number;
|
||||||
build_libraries_total: number;
|
build_libraries_total: number;
|
||||||
build_library_progress: number | null;
|
build_library_progress: number | null;
|
||||||
build_library_items_processed: number;
|
build_library_items_processed: number;
|
||||||
build_library_items_total: number;
|
build_library_items_total: number;
|
||||||
build_elapsed_seconds: number | null;
|
build_elapsed_seconds: number | null;
|
||||||
build_eta_seconds: number | null;
|
build_eta_seconds: number | null;
|
||||||
build_library_elapsed_seconds: number | null;
|
build_library_elapsed_seconds: number | null;
|
||||||
build_library_eta_seconds: number | null;
|
build_library_eta_seconds: number | null;
|
||||||
build_cancel_requested: boolean;
|
build_cancel_requested: boolean;
|
||||||
build_pid: number | null;
|
build_pid: number | null;
|
||||||
build_error: string;
|
build_error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaIndexActionResponse {
|
export interface MediaIndexActionResponse {
|
||||||
status: string;
|
status: string;
|
||||||
build_running: boolean;
|
build_running: boolean;
|
||||||
build_stage: string;
|
build_stage: string;
|
||||||
build_message: string;
|
build_message: string;
|
||||||
build_progress: number | null;
|
build_progress: number | null;
|
||||||
build_items_processed: number;
|
build_items_processed: number;
|
||||||
build_items_total: number;
|
build_items_total: number;
|
||||||
build_current_library: string;
|
build_current_library: string;
|
||||||
build_library_index: number;
|
build_library_index: number;
|
||||||
build_libraries_total: number;
|
build_libraries_total: number;
|
||||||
build_library_progress: number | null;
|
build_library_progress: number | null;
|
||||||
build_library_items_processed: number;
|
build_library_items_processed: number;
|
||||||
build_library_items_total: number;
|
build_library_items_total: number;
|
||||||
build_elapsed_seconds: number | null;
|
build_elapsed_seconds: number | null;
|
||||||
build_eta_seconds: number | null;
|
build_eta_seconds: number | null;
|
||||||
build_library_elapsed_seconds: number | null;
|
build_library_elapsed_seconds: number | null;
|
||||||
build_library_eta_seconds: number | null;
|
build_library_eta_seconds: number | null;
|
||||||
build_cancel_requested: boolean;
|
build_cancel_requested: boolean;
|
||||||
build_pid: number | null;
|
build_pid: number | null;
|
||||||
build_error: string;
|
build_error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
series: string;
|
series: string;
|
||||||
season: string;
|
season: string;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
type: string;
|
type: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
runtime_min: number | null;
|
runtime_min: number | null;
|
||||||
size: string;
|
size: string;
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
hdr: string;
|
hdr: string;
|
||||||
video: string;
|
video: string;
|
||||||
resolution: string;
|
resolution: string;
|
||||||
date_added: string;
|
date_added: string;
|
||||||
library: string;
|
library: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaQueryResponse {
|
export interface MediaQueryResponse {
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
type: string;
|
type: string;
|
||||||
size: number;
|
size: number;
|
||||||
mtime: number;
|
mtime: number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryListing {
|
export interface DirectoryListing {
|
||||||
path: string;
|
path: string;
|
||||||
entries: FileEntry[];
|
entries: FileEntry[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobTemplate {
|
export interface JobTemplate {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobResult {
|
export interface JobResult {
|
||||||
job_key: string;
|
job_key: string;
|
||||||
path: string;
|
path: string;
|
||||||
exit_status: number;
|
exit_status: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedPath {
|
export interface ResolvedPath {
|
||||||
original: string;
|
original: string;
|
||||||
resolved: string;
|
resolved: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardShortcut {
|
export interface DashboardShortcut {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
shortcut_type: "website" | "action" | "user";
|
shortcut_type: "website" | "action" | "user";
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
icon: string;
|
icon: string;
|
||||||
url: string;
|
url: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
machine_id: string;
|
machine_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardShortcutInput {
|
export interface DashboardShortcutInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
shortcut_type: "website" | "action" | "user";
|
shortcut_type: "website" | "action" | "user";
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
icon: string;
|
icon: string;
|
||||||
url: string;
|
url: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
machine_id: string;
|
machine_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertmanagerAlert {
|
export interface AlertmanagerAlert {
|
||||||
name: string;
|
name: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
category: string;
|
category: string;
|
||||||
job_name: string;
|
job_name: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
description: string;
|
description: string;
|
||||||
active_since: string;
|
active_since: string;
|
||||||
state: string;
|
state: string;
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertmanagerAlertSummary {
|
export interface AlertmanagerAlertSummary {
|
||||||
total: number;
|
total: number;
|
||||||
by_severity: Record<string, number>;
|
by_severity: Record<string, number>;
|
||||||
alerts: AlertmanagerAlert[];
|
alerts: AlertmanagerAlert[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertmanagerStatus {
|
export interface AlertmanagerStatus {
|
||||||
up: boolean;
|
up: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
uptime: string;
|
uptime: string;
|
||||||
name: string;
|
name: string;
|
||||||
peers: string[];
|
peers: string[];
|
||||||
service_id?: string;
|
service_id?: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrometheusStatus {
|
export interface PrometheusStatus {
|
||||||
up: boolean;
|
up: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetInstance {
|
export interface WidgetInstance {
|
||||||
id: string;
|
id: string;
|
||||||
service_id: string | null;
|
service_id: string | null;
|
||||||
widget_kind: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetInstanceInput {
|
export interface WidgetInstanceInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
service_id: string | null;
|
service_id: string | null;
|
||||||
widget_kind: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetDataResponse {
|
export interface WidgetDataResponse {
|
||||||
widget_id: string;
|
widget_id: string;
|
||||||
data: Record<string, unknown> | null;
|
data: Record<string, unknown> | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetched_at: number;
|
fetched_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecretFieldInfo {
|
export interface SecretFieldInfo {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
helper?: string | null;
|
helper?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceWidgetKindInfo {
|
export interface ServiceWidgetKindInfo {
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
config_schema: Record<string, unknown>;
|
config_schema: Record<string, unknown>;
|
||||||
default_config: Record<string, unknown>;
|
default_config: Record<string, unknown>;
|
||||||
refresh_interval_ms: number;
|
refresh_interval_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceTypeInfo {
|
export interface ServiceTypeInfo {
|
||||||
service_type: string;
|
service_type: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
config_schema: Record<string, unknown>;
|
config_schema: Record<string, unknown>;
|
||||||
secret_fields: SecretFieldInfo[];
|
secret_fields: SecretFieldInfo[];
|
||||||
widget_kinds: ServiceWidgetKindInfo[];
|
widget_kinds: ServiceWidgetKindInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceInstance {
|
export interface ServiceInstance {
|
||||||
id: string;
|
id: string;
|
||||||
service_type: string;
|
service_type: string;
|
||||||
name: string;
|
name: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
secrets_set: Record<string, boolean>;
|
secrets_set: Record<string, boolean>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerStatus {
|
export interface SchedulerStatus {
|
||||||
service_id: string;
|
service_id: string;
|
||||||
action_key: string;
|
action_key: string;
|
||||||
worker_running: boolean;
|
worker_running: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
poll_interval_seconds: number;
|
poll_interval_seconds: number;
|
||||||
sample_retention_seconds: number;
|
sample_retention_seconds: number;
|
||||||
sample_max_rows: number;
|
sample_max_rows: number;
|
||||||
next_run_at: number | null;
|
next_run_at: number | null;
|
||||||
last_attempt_at: number | null;
|
last_attempt_at: number | null;
|
||||||
last_success_at: number | null;
|
last_success_at: number | null;
|
||||||
last_error: string;
|
last_error: string;
|
||||||
consecutive_failures: number;
|
consecutive_failures: number;
|
||||||
backoff_until: number | null;
|
backoff_until: number | null;
|
||||||
is_stale: boolean;
|
is_stale: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerRun {
|
export interface SchedulerRun {
|
||||||
id: string;
|
id: string;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
action_key: string;
|
action_key: string;
|
||||||
trigger: "schedule" | "manual";
|
trigger: "schedule" | "manual";
|
||||||
started_at: number;
|
started_at: number;
|
||||||
finished_at: number | null;
|
finished_at: number | null;
|
||||||
status: "running" | "success" | "failure" | "cancelled";
|
status: "running" | "success" | "failure" | "cancelled";
|
||||||
attempt: number;
|
attempt: number;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
error: string;
|
error: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerRunsResponse {
|
export interface SchedulerRunsResponse {
|
||||||
items: SchedulerRun[];
|
items: SchedulerRun[];
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerSamplesResponse {
|
export interface SchedulerSamplesResponse {
|
||||||
service_id: string;
|
service_id: string;
|
||||||
window_seconds: number;
|
window_seconds: number | null;
|
||||||
samples: Array<{
|
all_values: boolean;
|
||||||
ts: number;
|
samples: Array<{
|
||||||
dl_speed: number;
|
ts: number;
|
||||||
up_speed: number;
|
dl_speed: number;
|
||||||
}>;
|
up_speed: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerManualRunResponse {
|
export interface SchedulerManualRunResponse {
|
||||||
run: SchedulerRun;
|
run: SchedulerRun;
|
||||||
status: SchedulerStatus;
|
status: SchedulerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceInstanceInput {
|
export interface ServiceInstanceInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
service_type: string;
|
service_type: string;
|
||||||
name: string;
|
name: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
secrets: Record<string, string>;
|
secrets: Record<string, string>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceTestResult {
|
export interface ServiceTestResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
detail: string;
|
detail: string;
|
||||||
evidence: string | null;
|
evidence: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuiltinWidgetKindInfo {
|
export interface BuiltinWidgetKindInfo {
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
config_schema: Record<string, unknown>;
|
config_schema: Record<string, unknown>;
|
||||||
default_config: Record<string, unknown>;
|
default_config: Record<string, unknown>;
|
||||||
refresh_interval_ms: number;
|
refresh_interval_ms: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||||
import {
|
|
||||||
chartRangesThrough,
|
|
||||||
rangeSecondsFromWindow,
|
|
||||||
} from "../components/chartRanges";
|
|
||||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||||
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -24,7 +20,6 @@ export function MetricChartWidget({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const series = data?.data?.series as ChartSeries[] | undefined;
|
const series = data?.data?.series as ChartSeries[] | undefined;
|
||||||
const maxRangeSeconds = rangeSecondsFromWindow(widget.config.window);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
@@ -39,8 +34,7 @@ export function MetricChartWidget({
|
|||||||
series={series}
|
series={series}
|
||||||
unit={widget.config.unit as MetricUnit}
|
unit={widget.config.unit as MetricUnit}
|
||||||
scale={widget.config.scale as MetricScale}
|
scale={widget.config.scale as MetricScale}
|
||||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
showRangeSelector={false}
|
||||||
defaultRangeSeconds={maxRangeSeconds}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ActiveTorrent {
|
|||||||
direction?: "downloading" | "uploading";
|
direction?: "downloading" | "uploading";
|
||||||
size: number | null;
|
size: number | null;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
ratio: number | null;
|
||||||
dl_speed: number | null;
|
dl_speed: number | null;
|
||||||
up_speed: number | null;
|
up_speed: number | null;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,12 @@ function formatProgress(progress: number | null): string {
|
|||||||
return `${Math.round(progress * 100)}% complete`;
|
return `${Math.round(progress * 100)}% complete`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRatio(ratio: number | null): string {
|
||||||
|
if (ratio === null || !Number.isFinite(ratio) || ratio < 0)
|
||||||
|
return "Ratio unknown";
|
||||||
|
return `Ratio ${ratio.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatState(
|
function formatState(
|
||||||
state: string | null,
|
state: string | null,
|
||||||
direction?: ActiveTorrent["direction"],
|
direction?: ActiveTorrent["direction"],
|
||||||
@@ -103,7 +110,8 @@ export function QbittorrentActiveTorrentsWidget({
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatSize(torrent.size)} ·{" "}
|
{formatSize(torrent.size)} ·{" "}
|
||||||
{formatProgress(torrent.progress)}
|
{formatProgress(torrent.progress)} ·{" "}
|
||||||
|
{formatRatio(torrent.ratio)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||||
import { chartRangesThrough } from "../components/chartRanges";
|
|
||||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||||
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -24,7 +23,6 @@ export function QbittorrentSpeedWidget({
|
|||||||
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
|
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
|
||||||
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
|
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
|
||||||
const scale = (widget.config.scale as MetricScale) || "auto";
|
const scale = (widget.config.scale as MetricScale) || "auto";
|
||||||
const maxRangeSeconds = Number(widget.config.window_seconds) || 1800;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
@@ -40,8 +38,7 @@ export function QbittorrentSpeedWidget({
|
|||||||
unit={unit}
|
unit={unit}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
height={220}
|
height={220}
|
||||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
showRangeSelector={false}
|
||||||
defaultRangeSeconds={maxRangeSeconds}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ describe("MetricChartWidget", () => {
|
|||||||
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
|
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||||
// recharts renders an SVG; the title from SectionCard should be present.
|
// recharts renders an SVG; the title from SectionCard should be present.
|
||||||
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
|
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error Alert on error", () => {
|
it("shows error Alert on error", () => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
state: "downloading",
|
state: "downloading",
|
||||||
size: 1000,
|
size: 1000,
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
|
ratio: 1.25,
|
||||||
dl_speed: 500000,
|
dl_speed: 500000,
|
||||||
up_speed: 1000,
|
up_speed: 1000,
|
||||||
},
|
},
|
||||||
@@ -77,6 +78,7 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Downloading")).toBeInTheDocument();
|
expect(screen.getByText("Downloading")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Ratio 1\.25/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state when no active torrents", () => {
|
it("shows empty state when no active torrents", () => {
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ describe("QbittorrentSpeedWidget", () => {
|
|||||||
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "Chart range" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ A Grafana gateway timeout, connection error, HTTP 401/403 (auth), datasource-not
|
|||||||
|
|
||||||
### Requirement: SC-104 — Step is derived from the window preset
|
### Requirement: SC-104 — Step is derived from the window preset
|
||||||
|
|
||||||
Given a window preset (1h / 6h / 24h / 7d), the backend MUST reuse the existing `WINDOW_PRESETS` and `step_for_window` math to derive the gateway request's `intervalMs` (`step * 1000`), `maxDataPoints`, and `from`/`to` time bounds, landing the resulting point count in the same ~100–300 band as the pre-change direct-Prom path. Users do not configure `from`/`to`/`step`/`intervalMs` directly.
|
Given a window preset (5m / 15m / 30m / 1h / 3h / 6h / 12h / 24h / 2d / 7d / 14d / 30d), the backend MUST reuse the existing `WINDOW_PRESETS` and `step_for_window` math to derive the gateway request's `intervalMs` (`step * 1000`), `maxDataPoints`, and `from`/`to` time bounds. Windows of 30 minutes or more must land in the ~100–300 point band; 5m and 15m may return 20 and 60 points respectively because Prometheus resolution is never set below 15 seconds. Users do not configure `from`/`to`/`step`/`intervalMs` directly.
|
||||||
|
|
||||||
### Requirement: SC-105 — Chart widget moves from grafana to prometheus
|
### Requirement: SC-105 — Chart widget moves from grafana to prometheus
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ The `chart` widget MUST render all series returned by the gateway range query, e
|
|||||||
|
|
||||||
### Requirement: SC-108 — Chart window is a preset
|
### Requirement: SC-108 — Chart window is a preset
|
||||||
|
|
||||||
The `chart` widget config MUST expose the time window as a preset selector (`1h`, `6h`, `24h`, `7d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side.
|
The `chart` widget config MUST expose the time window as a preset selector (`5m`, `15m`, `30m`, `1h`, `3h`, `6h`, `12h`, `24h`, `2d`, `7d`, `14d`, `30d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side. The shared chart renderer also offers an **All values** display option that removes the client-side cutoff from the values returned by that configured query.
|
||||||
|
|
||||||
### Requirement: SC-109 — Gauge renders an instant scalar
|
### Requirement: SC-109 — Gauge renders an instant scalar
|
||||||
|
|
||||||
|
|||||||
Executable
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# land-branch.sh — Solo-local integrate: squash-merge feature branch onto main and push.
|
||||||
|
# Requires GIT_BIGPOWERS_LAND=1 for hook exceptions on commit/push to protected branches.
|
||||||
|
# Usage: bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"
|
||||||
|
# Run from the primary repository root (not a linked worktree).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONVENTIONAL_REGEX='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+'
|
||||||
|
|
||||||
|
usage_land() {
|
||||||
|
echo "Usage: $0 <feature-branch> \"<conventional commit message>\" [--skip-verify]" >&2
|
||||||
|
echo " Run from primary repo root after release-branch gates (solo-local mode)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
land_branch_deny() {
|
||||||
|
echo "ERROR: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_VERIFY=false
|
||||||
|
ARGS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--skip-verify" ]; then
|
||||||
|
SKIP_VERIFY=true
|
||||||
|
else
|
||||||
|
ARGS+=("$arg")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_BRANCH="${ARGS[0]:-}"
|
||||||
|
COMMIT_MSG="${ARGS[1]:-}"
|
||||||
|
|
||||||
|
[ -n "$FEATURE_BRANCH" ] && [ -n "$COMMIT_MSG" ] || usage_land
|
||||||
|
|
||||||
|
if [[ ! "$COMMIT_MSG" =~ $CONVENTIONAL_REGEX ]]; then
|
||||||
|
land_branch_deny "Commit message must follow Conventional Commits: <type>(<scope>): <subject>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#COMMIT_MSG} -gt 72 ]; then
|
||||||
|
land_branch_deny "Commit subject line must be 72 characters or less"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Block AI agent attribution (P1 — CONVENTIONS.md § Git Attribution)
|
||||||
|
if echo "$COMMIT_MSG" | grep -qiE '^co[- ]authored[- ]by:' || echo "$COMMIT_MSG" | grep -qiE '\nco[- ]authored[- ]by:'; then
|
||||||
|
land_branch_deny "Commit must not include Co-authored-by: footer. All commits must appear as if authored solely by the human user."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Primary worktree only (.git is a directory, not a gitdir pointer file)
|
||||||
|
if [ -f .git ]; then
|
||||||
|
land_branch_deny "Run from the primary repository root, not a linked worktree (cd to main repo first)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
detect_default_branch() {
|
||||||
|
local remote_head
|
||||||
|
remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || true)
|
||||||
|
if [ -n "$remote_head" ]; then
|
||||||
|
echo "$remote_head"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if git show-ref --verify --quiet refs/heads/main; then
|
||||||
|
echo "main"
|
||||||
|
elif git show-ref --verify --quiet refs/heads/master; then
|
||||||
|
echo "master"
|
||||||
|
else
|
||||||
|
land_branch_deny "Could not detect default branch (main/master)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_BRANCH=$(detect_default_branch)
|
||||||
|
REPO_ROOT=$(pwd)
|
||||||
|
|
||||||
|
echo "==> Land branch: $FEATURE_BRANCH -> $DEFAULT_BRANCH"
|
||||||
|
echo " Repo root: $REPO_ROOT"
|
||||||
|
|
||||||
|
if ! git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' does not exist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scan all commits in feature branch for Co-authored-by: footers
|
||||||
|
if git log "$DEFAULT_BRANCH..$FEATURE_BRANCH" --format="%B" 2>/dev/null | grep -qiE '^co[- ]authored[- ]by:'; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' contains Co-authored-by: footer(s). Amend commits to remove all AI agent attribution before landing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
for protected in main master; do
|
||||||
|
if [ "$FEATURE_BRANCH" = "$protected" ]; then
|
||||||
|
land_branch_deny "Cannot land protected branch '$FEATURE_BRANCH'"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
run_verify_suite() {
|
||||||
|
echo "==> Running pre-land verification..."
|
||||||
|
if [ -f package.json ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
if jq -e '.scripts.compliance' package.json >/dev/null 2>&1; then
|
||||||
|
npm run compliance
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if jq -e '.scripts.test' package.json >/dev/null 2>&1; then
|
||||||
|
local test_script
|
||||||
|
test_script=$(jq -r '.scripts.test' package.json)
|
||||||
|
if [ "$test_script" = "echo \"Error: no test specified\" && exit 1" ]; then
|
||||||
|
:
|
||||||
|
elif [ "$test_script" = "false" ]; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
npm test
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if jq -e '.scripts.lint' package.json >/dev/null 2>&1; then
|
||||||
|
npm run lint
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -f scripts/sync-skills.sh ]; then
|
||||||
|
bash scripts/sync-skills.sh
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SKIP_VERIFY" = false ]; then
|
||||||
|
run_verify_suite
|
||||||
|
else
|
||||||
|
echo "==> Skipping verification (--skip-verify)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Updating $DEFAULT_BRANCH"
|
||||||
|
git checkout "$DEFAULT_BRANCH"
|
||||||
|
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
land_branch_deny "Working tree on $DEFAULT_BRANCH is not clean. Stash or commit first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git pull --ff-only origin "$DEFAULT_BRANCH" || land_branch_deny "git pull --ff-only failed; resolve before landing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git merge-base --is-ancestor "$DEFAULT_BRANCH" "$FEATURE_BRANCH" 2>/dev/null; then
|
||||||
|
land_branch_deny "Feature branch '$FEATURE_BRANCH' is not based on current $DEFAULT_BRANCH (rebase or recreate branch)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GIT_BIGPOWERS_LAND=1
|
||||||
|
|
||||||
|
echo "==> Squash merge $FEATURE_BRANCH"
|
||||||
|
git merge --squash "$FEATURE_BRANCH"
|
||||||
|
if git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
land_branch_deny "Squash merge produced no changes (already merged?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
LAND_SHA=$(git rev-parse --short HEAD)
|
||||||
|
echo "==> Land commit: $LAND_SHA"
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
echo "==> Pushing $DEFAULT_BRANCH to origin"
|
||||||
|
git push origin "$DEFAULT_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Epic capsule archival (evolved bigpowers v4.0.0+)
|
||||||
|
# Move completed epic capsules to archive when all stories are done
|
||||||
|
echo "==> Checking for completed epic capsules to archive..."
|
||||||
|
if [ -d specs/epics ] && [ -f specs/execution-status.yaml ]; then
|
||||||
|
for capsule in specs/epics/e[0-9]*-*/; do
|
||||||
|
[ -d "$capsule" ] || continue
|
||||||
|
capsule_name=$(basename "$capsule")
|
||||||
|
epic_id=$(echo "$capsule_name" | grep -o '^e[0-9]*' || true)
|
||||||
|
[ -n "$epic_id" ] || continue
|
||||||
|
# Check if all stories in this epic are done
|
||||||
|
ALL_DONE=true
|
||||||
|
if [ -f "$capsule/epic.yaml" ]; then
|
||||||
|
for story_id in $(grep -o 'e[0-9]*s[0-9]*' "$capsule/epic.yaml" 2>/dev/null || true); do
|
||||||
|
STATUS=$(grep "$story_id:" specs/execution-status.yaml 2>/dev/null | awk '{print $2}' || echo "todo")
|
||||||
|
if [ "$STATUS" != "done" ]; then
|
||||||
|
ALL_DONE=false
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "$ALL_DONE" = true ]; then
|
||||||
|
mkdir -p specs/epics/archive
|
||||||
|
echo " Archiving completed epic: $capsule_name → specs/epics/archive/"
|
||||||
|
mv "$capsule" "specs/epics/archive/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Worktree cleanup
|
||||||
|
WORKTREE_PATH="../$FEATURE_BRANCH"
|
||||||
|
if git worktree list --porcelain 2>/dev/null | grep -q "^worktree $WORKTREE_PATH$"; then
|
||||||
|
echo "==> Removing worktree $WORKTREE_PATH"
|
||||||
|
git worktree remove "$WORKTREE_PATH" 2>/dev/null || git worktree remove -f "$WORKTREE_PATH"
|
||||||
|
fi
|
||||||
|
git worktree prune 2>/dev/null || true
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then
|
||||||
|
git branch -d "$FEATURE_BRANCH" 2>/dev/null || {
|
||||||
|
echo "WARN: Could not delete branch $FEATURE_BRANCH (not fully merged? use -D manually if intended)"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
git checkout "$DEFAULT_BRANCH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Land complete."
|
||||||
|
echo " Branch: $FEATURE_BRANCH (removed)"
|
||||||
|
echo " Commit: $LAND_SHA on $DEFAULT_BRANCH"
|
||||||
|
echo " Message: $COMMIT_MSG"
|
||||||
|
echo " cwd: $(pwd)"
|
||||||
|
echo " current: $(git branch --show-current)"
|
||||||
|
echo ""
|
||||||
|
echo "semantic-release will pick up the push to $DEFAULT_BRANCH when configured."
|
||||||
Reference in New Issue
Block a user