Compare commits

...

2 Commits

Author SHA1 Message Date
Developer ef4a6379c9 chore(workflow): add solo branch landing helper 2026-07-15 18:48:17 +00:00
Developer d76ea49777 feat(charting): unify configurable time windows 2026-07-15 15:21:57 +00:00
25 changed files with 1607 additions and 1146 deletions
+1
View File
@@ -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
@@ -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)
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 since_ts = _safe_int(time.time()) - window_seconds
samples = QbittorrentSampleStore().window(service_id, since_ts=since_ts) 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 100300 band; with therefore return 20 and 60 points respectively; all longer presets stay
``target_points=200`` every preset yields 200 points. in the target 100300 point band.
""" """
return max(15, round(window_seconds / target_points)) return max(15, round(window_seconds / target_points))
@@ -480,8 +480,12 @@ 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":
configured_window = config.get("window_seconds")
if configured_window == "all":
samples = QbittorrentSampleStore().window(service.id)
else:
window_seconds = _safe_int( window_seconds = _safe_int(
config.get("window_seconds") or service.config.get("sample_retention_seconds") or 1_800 configured_window or service.config.get("sample_retention_seconds") or 1_800
) )
window_seconds = max(60, min(window_seconds, 86_400)) window_seconds = max(60, min(window_seconds, 86_400))
since_ts = _safe_int(time.time()) - window_seconds since_ts = _safe_int(time.time()) - window_seconds
+21 -4
View File
@@ -14,16 +14,33 @@ from media_library_viewer_api.widgets.prometheus_range import (
class TestStepForWindow: class TestStepForWindow:
"""SC-104: every preset must yield 100300 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 100300 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.
+26
View File
@@ -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)
+15
View File
@@ -1224,6 +1224,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
+2 -2
View File
@@ -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 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` - 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,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. - 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 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.
+4 -2
View File
@@ -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) },
); );
} }
+20 -10
View File
@@ -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,11 @@ 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. */ /** Initial uncontrolled range. Defaults to the largest numeric option. */
defaultRangeSeconds?: number; 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. */
@@ -91,8 +95,11 @@ export function LineSeriesChart({
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,7 +109,8 @@ export function LineSeriesChart({
), ),
0, 0,
); );
const cutoff = selectedRangeSeconds const cutoff =
typeof selectedRangeSeconds === "number"
? latestTimestamp - selectedRangeSeconds * 1000 ? latestTimestamp - selectedRangeSeconds * 1000
: null; : null;
const visibleSeries = const visibleSeries =
@@ -125,7 +133,7 @@ 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);
} }
@@ -136,7 +144,9 @@ export function LineSeriesChart({
<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}
> >
@@ -141,7 +141,8 @@ function WidgetConfigEditor({
? Object.entries( ? Object.entries(
( (
binding.configSchema as binding.configSchema as
{ properties?: Record<string, unknown> } | undefined | { properties?: Record<string, unknown> }
| undefined
)?.properties ?? {}, )?.properties ?? {},
) )
: []; : [];
@@ -190,7 +191,7 @@ function WidgetConfigEditor({
<SelectContent> <SelectContent>
{enumOptions.map((opt) => ( {enumOptions.map((opt) => (
<SelectItem key={opt} value={opt}> <SelectItem key={opt} value={opt}>
{opt.replace(/_/g, " ")} {opt === "all" ? "All values" : opt.replace(/_/g, " ")}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -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,22 @@ describe("LineSeriesChart", () => {
).toHaveTextContent("2 hours"); ).toHaveTextContent("2 hours");
}); });
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",
});
});
});
+58 -25
View File
@@ -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] : [],
);
} }
+7 -3
View File
@@ -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",
+15 -3
View File
@@ -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>
+2 -1
View File
@@ -456,7 +456,8 @@ export interface SchedulerRunsResponse {
export interface SchedulerSamplesResponse { export interface SchedulerSamplesResponse {
service_id: string; service_id: string;
window_seconds: number; window_seconds: number | null;
all_values: boolean;
samples: Array<{ samples: Array<{
ts: number; ts: number;
dl_speed: number; dl_speed: number;
@@ -1,7 +1,10 @@
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 {
chartRangesThrough,
type ChartRangeValue,
} 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 +27,11 @@ 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; const configuredRange = widget.config.window_seconds;
const maxRangeSeconds =
configuredRange === "all" ? 86_400 : Number(configuredRange) || 1800;
const defaultRangeSeconds: ChartRangeValue =
configuredRange === "all" ? "all" : maxRangeSeconds;
return ( return (
<SectionCard title={widget.title} description={description}> <SectionCard title={widget.title} description={description}>
@@ -41,7 +48,7 @@ export function QbittorrentSpeedWidget({
scale={scale} scale={scale}
height={220} height={220}
rangeOptions={chartRangesThrough(maxRangeSeconds)} rangeOptions={chartRangesThrough(maxRangeSeconds)}
defaultRangeSeconds={maxRangeSeconds} defaultRangeSeconds={defaultRangeSeconds}
/> />
) : ( ) : (
<Alert> <Alert>
+2 -2
View File
@@ -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 ~100300 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 ~100300 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
+208
View File
@@ -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."