feat(charting): unify configurable time windows

This commit is contained in:
Developer
2026-07-15 18:55:57 +00:00
parent 3871f24724
commit e0f66a51f7
25 changed files with 1607 additions and 1146 deletions
@@ -86,7 +86,7 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
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
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
unit: Literal[
@@ -116,7 +116,7 @@ class PrometheusMeanWidgetConfig(WidgetConfigBase):
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
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
@@ -9,7 +9,7 @@ from __future__ import annotations
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.integrations.base import (
@@ -83,7 +83,7 @@ class QbittorrentWidgetConfig(WidgetConfigBase):
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
"""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[
"none",
"bytes",
@@ -95,6 +95,16 @@ class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
] = "bytes_per_sec"
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(
service_type="qbittorrent",
@@ -54,7 +54,8 @@ class SchedulerSample(BaseModel):
class SchedulerSamplesResponse(BaseModel):
service_id: str
window_seconds: int
window_seconds: int | None
all_values: bool = False
samples: list[SchedulerSample]
@@ -103,14 +103,22 @@ def run_scheduler_action(
def get_scheduler_samples(
service_id: str,
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
all_values: bool = Query(default=False),
store: SettingsStore = Depends(get_settings_store),
) -> SchedulerSamplesResponse:
_require_qbittorrent(service_id, store)
since_ts = _safe_int(time.time()) - window_seconds
samples = QbittorrentSampleStore().window(service_id, since_ts=since_ts)
sample_store = QbittorrentSampleStore()
if all_values:
samples = sample_store.window(service_id)
response_window: int | None = None
else:
since_ts = _safe_int(time.time()) - window_seconds
samples = sample_store.window(service_id, since_ts=since_ts)
response_window = window_seconds
return SchedulerSamplesResponse(
service_id=service_id,
window_seconds=window_seconds,
window_seconds=response_window,
all_values=all_values,
samples=samples,
)
@@ -21,10 +21,18 @@ from typing import Any
#: 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.
WINDOW_PRESETS: dict[str, int] = {
"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,
}
#: 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.
Clamped to a minimum of 15 seconds so Prometheus does not reject
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
the resulting point count to land in the 100300 band; with
``target_points=200`` every preset yields 200 points.
sub-15s resolutions on high-cardinality queries. The 5m and 15m presets
therefore return 20 and 60 points respectively; all longer presets stay
in the target 100300 point band.
"""
return max(15, round(window_seconds / target_points))
@@ -480,12 +480,16 @@ class QbittorrentWidgetSource:
return {"error": "qBittorrent widget is missing its service"}
if widget_kind == "speed":
window_seconds = _safe_int(
config.get("window_seconds") 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)
configured_window = config.get("window_seconds")
if configured_window == "all":
samples = QbittorrentSampleStore().window(service.id)
else:
window_seconds = _safe_int(
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 = [
{
"label": "download",