From 05a9faca3e23d435767d0a085d710a2c4b4e5ad0 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 12 Jul 2026 11:45:25 +0000 Subject: [PATCH] feat(widgets): add unit/scale config to chart widget kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph widgets need consistent value scaling (kB/MB/GB, kbps/Mbps, …). Add shared `unit` (none/bytes/bytes_per_sec/bits_per_sec/bits/percent/seconds) and `scale` (auto/k/m/g/t) enum fields to: - PrometheusChartWidgetConfig (alongside promql/window) - new QbittorrentSpeedWidgetConfig — the speed widget previously had NO config options at all; totals/active keep their empty config. Declared as Pydantic Literal enums so the widget-kind JSON schema exposes `enum`, which the frontend config dialog renders as a dropdown. The data sources are unchanged (raw values); scaling is a display concern handled client-side. qBittorrent speed defaults to bytes/sec. Test: chart widget kinds expose the shared unit/scale enums; speed defaults to bytes_per_sec; totals/active stay option-less. 389/389 backend tests pass. --- .../integrations/.pi-map.index.md | 2 +- .../integrations/.pi-map.md | 10 ++++---- .../integrations/prometheus.py | 14 ++++++++++- .../integrations/qbittorrent.py | 23 +++++++++++++++---- backend/tests/test_services.py | 23 +++++++++++++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/backend/src/media_library_viewer_api/integrations/.pi-map.index.md b/backend/src/media_library_viewer_api/integrations/.pi-map.index.md index 75bb825..cb8da48 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/integrations ## role -Provides a plugin-based integration layer connecting the media library viewer API to various external services (Alertmanager, Authentik, Jellyfin, Prometheus, qBittorrent, etc.) via a centralized registry. +Provides pluggable external service integrations (Alertmanager, Jellyfin, Nextcloud, Prometheus, qBittorrent, Authentik, Backups, SSH) with standardized config schemas, widgets, connection testing, and a central registry for discovery. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/integrations/.pi-map.md b/backend/src/media_library_viewer_api/integrations/.pi-map.md index f353c3a..923f006 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.md @@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api/integrations index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md ## role -Provides a plugin-based integration layer connecting the media library viewer API to various external services (Alertmanager, Authentik, Jellyfin, Prometheus, qBittorrent, etc.) via a centralized registry. +Provides pluggable external service integrations (Alertmanager, Jellyfin, Nextcloud, Prometheus, qBittorrent, Authentik, Backups, SSH) with standardized config schemas, widgets, connection testing, and a central registry for discovery. ## files - __init__.py | Defines a closed registry module for service integrations. - alertmanager.py | Defines a service integration for Prometheus Alertmanager, providing configuration models, connection testing, alert summarization, and widget definitions for displaying active alerts. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:int, call:secrets.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get("versionInfo", {}).get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store @@ -13,14 +13,14 @@ Provides a plugin-based integration layer connecting the media library viewer AP - base.py | Provides base classes and utility functions for defining external service integrations, including config schemas, secrets, widgets, and connection error translation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:TestResult, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump, func:translate_connection_error(exc: Exception, context) → TestResult, call:str, call:message.lower, call:isinstance, call:TestResult | dep: asyncio, dataclasses, typing, requests, pydantic, media_library_viewer_api.services.settings_store - jellyfin.py | Defines the Jellyfin media server service integration, including connection testing, configuration models, and widget definitions for activity monitoring. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore - nextcloud.py | Defines a Nextcloud service integration with connection testing and configuration for a media library viewer API. | exp: class:NextcloudConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store -- prometheus.py | Defines a Prometheus service integration that queries metrics through a Grafana gateway and provides configuration for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store -- qbittorrent.py | Defines the qBittorrent service integration including config models, connection testing, and widget definitions for displaying torrent activity and speeds. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore +- prometheus.py | Defines the Prometheus service integration for a media library viewer API, including connection testing via a Grafana gateway and configuration models for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store +- qbittorrent.py | Defines the qBittorrent service integration, including connection config, secret fields, three widget kinds (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore - registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks - ssh_tasks.py | Defines a service plugin that runs reusable saved tasks over SSH by managing connection configuration, secrets, and connection testing. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("host") or "").strip, call:config.get, call:int, call:ServiceRecord, call:build_ssh_client, call:client.connect, call:str(exc).lower, call:TestResult, call:translate_connection_error, call:client.close | dep: typing, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources ## arch -Registry pattern with a shared base class defining config schemas, secrets, widgets, and connection-testing contracts; each integration is a self-contained module registered in a closed registry for runtime discovery. +Plugin/registry pattern with abstract base classes defining config models, secrets, widgets, and connection tests; each integration is a self-contained module registered in a closed registry for lookup and dynamic loading. ## tags -config, connection, widget, media_library_viewer_api, service, error, integrations, call:str +config, connection, widget, media_library_viewer_api, service, error, integrations, test ## symbols - AlertmanagerConfig - AlertmanagerAlertsWidgetConfig diff --git a/backend/src/media_library_viewer_api/integrations/prometheus.py b/backend/src/media_library_viewer_api/integrations/prometheus.py index e76c2a2..f39eb7b 100644 --- a/backend/src/media_library_viewer_api/integrations/prometheus.py +++ b/backend/src/media_library_viewer_api/integrations/prometheus.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import requests @@ -87,6 +87,18 @@ class PrometheusChartWidgetConfig(WidgetConfigBase): promql: str window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) + # Display scaling for the Y axis + tooltip. "none" shows raw values; the + # others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.). + unit: Literal[ + "none", + "bytes", + "bytes_per_sec", + "bits_per_sec", + "bits", + "percent", + "seconds", + ] = "none" + scale: Literal["auto", "k", "m", "g", "t"] = "auto" class PrometheusGaugeWidgetConfig(WidgetConfigBase): diff --git a/backend/src/media_library_viewer_api/integrations/qbittorrent.py b/backend/src/media_library_viewer_api/integrations/qbittorrent.py index f620a79..942dc09 100644 --- a/backend/src/media_library_viewer_api/integrations/qbittorrent.py +++ b/backend/src/media_library_viewer_api/integrations/qbittorrent.py @@ -7,7 +7,7 @@ password), and three widget kinds (totals, active, speed). Models on from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from media_library_viewer_api.clients.qbittorrent import QbittorrentClient from media_library_viewer_api.integrations.base import ( @@ -59,11 +59,26 @@ class QbittorrentConfig(ServiceConfigBase): class QbittorrentWidgetConfig(WidgetConfigBase): - """Per-widget config (empty — all three kinds derive from the service connection).""" + """Per-widget config for totals/active (empty — derived from the service connection).""" pass +class QbittorrentSpeedWidgetConfig(WidgetConfigBase): + """Speed chart config. The source returns raw bytes/sec; the frontend scales.""" + + unit: Literal[ + "none", + "bytes", + "bytes_per_sec", + "bits_per_sec", + "bits", + "percent", + "seconds", + ] = "bytes_per_sec" + scale: Literal["auto", "k", "m", "g", "t"] = "auto" + + DEFINITION = ServiceDefinition( service_type="qbittorrent", name="qBittorrent", @@ -94,8 +109,8 @@ DEFINITION = ServiceDefinition( kind="speed", name="Speed chart", description="Live download/upload speed over a short window.", - model_cls=QbittorrentWidgetConfig, - default_config={}, + model_cls=QbittorrentSpeedWidgetConfig, + default_config={"unit": "bytes_per_sec", "scale": "auto"}, refresh_interval_ms=5_000, ), ], diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 8ad44fb..4057e04 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -114,6 +114,29 @@ def test_widget_kind_lookup(): assert get_widget_kind("unknown", "metric") is None +def test_chart_widget_kinds_expose_unit_and_scale_options(): + """Graph widgets share unit/scale options so axes/tooltips can be scaled.""" + units = ["none", "bytes", "bytes_per_sec", "bits_per_sec", "bits", "percent", "seconds"] + scales = ["auto", "k", "m", "g", "t"] + + prom_chart = get_widget_kind("prometheus", "chart") + assert prom_chart is not None + prom_props = prom_chart.config_schema["properties"] + assert prom_props["unit"]["enum"] == units + assert prom_props["scale"]["enum"] == scales + + qbit_speed = get_widget_kind("qbittorrent", "speed") + assert qbit_speed is not None + qbit_props = qbit_speed.config_schema["properties"] + assert qbit_props["unit"]["enum"] == units + assert qbit_props["scale"]["enum"] == scales + # qBittorrent speed data is bytes/sec by default. + assert qbit_speed.default_config["unit"] == "bytes_per_sec" + # totals/active are not graphs and stay option-less. + assert "unit" not in get_widget_kind("qbittorrent", "totals").config_schema["properties"] + assert "unit" not in get_widget_kind("qbittorrent", "active").config_schema["properties"] + + def test_service_config_schema_is_json_schema(): schema = get_service_definition("prometheus").config_schema assert schema["type"] == "object"