Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 940b966192 | |||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 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,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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,7 +113,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,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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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