diff --git a/backend/src/media_library_viewer_api/integrations/prometheus.py b/backend/src/media_library_viewer_api/integrations/prometheus.py index f39eb7b..8d7ca8e 100644 --- a/backend/src/media_library_viewer_api/integrations/prometheus.py +++ b/backend/src/media_library_viewer_api/integrations/prometheus.py @@ -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 diff --git a/backend/src/media_library_viewer_api/integrations/qbittorrent.py b/backend/src/media_library_viewer_api/integrations/qbittorrent.py index aaa73e1..27be6b7 100644 --- a/backend/src/media_library_viewer_api/integrations/qbittorrent.py +++ b/backend/src/media_library_viewer_api/integrations/qbittorrent.py @@ -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", diff --git a/backend/src/media_library_viewer_api/models/scheduler.py b/backend/src/media_library_viewer_api/models/scheduler.py index 584aa0d..90df193 100644 --- a/backend/src/media_library_viewer_api/models/scheduler.py +++ b/backend/src/media_library_viewer_api/models/scheduler.py @@ -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] diff --git a/backend/src/media_library_viewer_api/routers/scheduler.py b/backend/src/media_library_viewer_api/routers/scheduler.py index 93dd434..4536b56 100644 --- a/backend/src/media_library_viewer_api/routers/scheduler.py +++ b/backend/src/media_library_viewer_api/routers/scheduler.py @@ -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, ) diff --git a/backend/src/media_library_viewer_api/widgets/prometheus_range.py b/backend/src/media_library_viewer_api/widgets/prometheus_range.py index 33ce1e6..438fd0e 100644 --- a/backend/src/media_library_viewer_api/widgets/prometheus_range.py +++ b/backend/src/media_library_viewer_api/widgets/prometheus_range.py @@ -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 100–300 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 100–300 point band. """ return max(15, round(window_seconds / target_points)) diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index adeb02d..8576338 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -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", diff --git a/backend/tests/test_prometheus_range.py b/backend/tests/test_prometheus_range.py index 04a1430..422c126 100644 --- a/backend/tests/test_prometheus_range.py +++ b/backend/tests/test_prometheus_range.py @@ -14,16 +14,33 @@ from media_library_viewer_api.widgets.prometheus_range import ( 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)) - 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] 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 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: # A tiny window that would otherwise produce a sub-15s step is clamped. diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index fca0597..8db1b31 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -87,6 +87,32 @@ def test_scheduler_routes_expose_status_history_and_disabled_manual_run(schedule 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): harness = ServiceDataHarness(tmp_path) harness.register(QBITTORRENT_CONCERN) diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 8d125b1..1a3ee20 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -1224,6 +1224,21 @@ async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path): 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 async def test_qbittorrent_adapter_missing_service(): from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 1326070..e6e5f60 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -46,7 +46,7 @@ fully removed (web-ui-rework; see decision log 2026-06-17). ### 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 range controls, filtering, and display formatting remain consistent across Prometheus and qBittorrent charts. The selector offers 5 minutes, 15 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours, 24 hours, 2 days, 7 days, 14 days, 30 days, and **All values**; sources with bounded local retention expose the finite windows they can retain plus all retained values. - Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable` wrapper (`components/ui/data-table.tsx`). @@ -321,7 +321,7 @@ 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. - 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 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 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. - 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. diff --git a/frontend/src/api/scheduler.ts b/frontend/src/api/scheduler.ts index 5d70cee..8e75514 100644 --- a/frontend/src/api/scheduler.ts +++ b/frontend/src/api/scheduler.ts @@ -24,11 +24,13 @@ export function fetchSchedulerRuns( export function fetchSchedulerSamples( serviceId: string, - windowSeconds: number, + window: number | "all", ): Promise { return get( `/api/scheduler/services/${serviceId}/samples`, - { window_seconds: String(windowSeconds) }, + window === "all" + ? { all_values: "true" } + : { window_seconds: String(window) }, ); } diff --git a/frontend/src/components/LineSeriesChart.tsx b/frontend/src/components/LineSeriesChart.tsx index 7d33810..6cc02ce 100644 --- a/frontend/src/components/LineSeriesChart.tsx +++ b/frontend/src/components/LineSeriesChart.tsx @@ -15,7 +15,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges"; +import { + DEFAULT_CHART_RANGES, + type ChartRangeOption, + type ChartRangeValue, +} from "./chartRanges"; import { formatScaled, metricScaleInfo, @@ -72,11 +76,11 @@ interface LineSeriesChartProps { scale?: MetricScale; /** Available displayed time ranges. Defaults to the shared range choices. */ rangeOptions?: readonly ChartRangeOption[]; - /** Initial uncontrolled range. Defaults to the largest available option. */ - defaultRangeSeconds?: number; + /** Initial uncontrolled range. Defaults to the largest numeric option. */ + defaultRangeSeconds?: ChartRangeValue; /** Controlled range for consumers that refetch when the selection changes. */ - rangeSeconds?: number; - onRangeChange?: (rangeSeconds: number) => void; + rangeSeconds?: ChartRangeValue; + onRangeChange?: (range: ChartRangeValue) => void; } /** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */ @@ -91,8 +95,11 @@ export function LineSeriesChart({ onRangeChange, }: LineSeriesChartProps) { const initialRange = - defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value; - const [localRangeSeconds, setLocalRangeSeconds] = useState(initialRange); + defaultRangeSeconds ?? + [...rangeOptions].reverse().find((range) => typeof range.value === "number") + ?.value; + const [localRangeSeconds, setLocalRangeSeconds] = + useState(initialRange); const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds; const latestTimestamp = series.reduce( (max, seriesItem) => @@ -102,9 +109,10 @@ export function LineSeriesChart({ ), 0, ); - const cutoff = selectedRangeSeconds - ? latestTimestamp - selectedRangeSeconds * 1000 - : null; + const cutoff = + typeof selectedRangeSeconds === "number" + ? latestTimestamp - selectedRangeSeconds * 1000 + : null; const visibleSeries = cutoff !== null && latestTimestamp > 0 ? series.map((seriesItem) => ({ @@ -125,7 +133,7 @@ export function LineSeriesChart({ formatScaled(value, scaleInfo, unit); function handleRangeChange(value: string) { - const nextRange = Number(value); + const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value); setLocalRangeSeconds(nextRange); onRangeChange?.(nextRange); } @@ -136,7 +144,9 @@ export function LineSeriesChart({
onChange({ ...config, task_id: v })} - > - - - - - {tasks - .filter((t) => t.enabled) - .map((t) => ( - - {t.name} - - ))} - - - - ); - } + // SSH task output gets a dedicated task picker; everything else gets a + // generic text field per top-level schema property. + if (isTaskOutput) { + return ( + + + + ); + } - const properties = binding - ? Object.entries( - ( - binding.configSchema as - { properties?: Record } | undefined - )?.properties ?? {}, - ) - : []; + const properties = binding + ? Object.entries( + ( + binding.configSchema as + | { properties?: Record } + | undefined + )?.properties ?? {}, + ) + : []; - if (properties.length === 0) return null; + if (properties.length === 0) return null; - return ( -
- {properties.map(([key, schema]) => { - const isNumber = - (schema as { type?: string }).type === "integer" || - (schema as { type?: string }).type === "number"; - // Use a multi-line resizable textarea for fields that tend to hold - // complex multi-line values (PromQL expressions, Grafana query strings, - // markdown/text blocks, etc.). The widget kind's config schema can opt - // in via `format: "textarea"`; the well-known field names below are - // treated as textarea by default. - const schemaFormat = (schema as { format?: string }).format; - const TEXTAREA_KEYS = new Set([ - "promql", - "query", - "text", - "command", - "notes", - ]); - const isTextarea = - schemaFormat === "textarea" || TEXTAREA_KEYS.has(key); - // Enum schema fields (e.g. unit/scale) render as a dropdown so users pick - // from the allowed values consistently across every widget kind. - const enumOptions = (schema as { enum?: string[] }).enum; - return ( - - {enumOptions ? ( - - ) : isTextarea ? ( -