feat(prometheus-direct-charting): slice 2 — gauge + mean widgets

Add gauge widget (recharts RadialBarChart with configurable threshold
bands, scalar-only per SC-111) and mean widget (client-side average over
range-query window, scalar-only per SC-114). Extract shared _instant_query
helper from the metric path; _fetch_gauge and _fetch_mean dispatch in
PrometheusWidgetSource.fetch(). Both new widget kinds declared in
integrations/prometheus.py and frontend registry.

Backend: 305 pytest pass, ruff clean. Frontend: 136 vitest pass, build+lint green.
This commit is contained in:
Developer
2026-07-08 22:09:10 +00:00
parent 5dad98231f
commit 65bae95e3c
12 changed files with 707 additions and 32 deletions
@@ -32,6 +32,25 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
promql: str
warn_at: float | None = None
crit_at: float | None = None
min: float | None = None
max: float | None = None
unit: str | None = None
class PrometheusMeanWidgetConfig(WidgetConfigBase):
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
unit: str | None = None
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="prometheus", service_type="prometheus",
name="Prometheus", name="Prometheus",
@@ -57,5 +76,21 @@ DEFINITION = ServiceDefinition(
default_config={"promql": "", "window": "1h"}, default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000, refresh_interval_ms=60_000,
), ),
widget_kind(
kind="gauge",
name="Gauge",
description="Instant query rendered as a gauge with optional threshold bands.",
model_cls=PrometheusGaugeWidgetConfig,
default_config={"promql": ""},
refresh_interval_ms=30_000,
),
widget_kind(
kind="mean",
name="Mean",
description="Average value of a PromQL query over a time window.",
model_cls=PrometheusMeanWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
], ],
) )
@@ -225,28 +225,13 @@ class PrometheusWidgetSource:
timeout = int(service.config.get("timeout_seconds") or 10) timeout = int(service.config.get("timeout_seconds") or 10)
if widget_kind == "chart": if widget_kind == "chart":
return await self._fetch_chart(base_url, timeout, config) return await self._fetch_chart(base_url, timeout, config)
if widget_kind == "gauge":
return await self._fetch_gauge(base_url, timeout, config)
if widget_kind == "mean":
return await self._fetch_mean(base_url, timeout, config)
# Default: instant-query metric path (unchanged). # Default: instant-query metric path (unchanged).
promql = config.get("promql") raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
if not promql: return raw
return {"error": "promql is required"}
url = f"{base_url}/api/v1/query"
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
url,
params={"query": promql},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
return {"result": payload.get("data", {})}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except requests.RequestException as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
except Exception as exc: except Exception as exc:
logger.exception("prometheus adapter failed") logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"} return {"error": f"Prometheus query failed: {exc}"}
@@ -281,6 +266,34 @@ class PrometheusWidgetSource:
result = payload.get("data", {}).get("result", []) result = payload.get("data", {}).get("result", [])
return {"matrix": result} return {"matrix": result}
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
"""Run a Prometheus ``/api/v1/query`` instant query.
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
``{"result": data}`` on success or ``{"error": str}`` (never raises,
per SC-103).
"""
if not promql:
return {"error": "promql is required"}
try:
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
f"{base_url}/api/v1/query",
params={"query": promql},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
except asyncio.TimeoutError:
return {"error": "Prometheus query timed out"}
except requests.RequestException as exc:
logger.exception("prometheus instant query failed")
return {"error": f"Prometheus query failed: {exc}"}
return {"result": payload.get("data", {})}
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]: async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104).""" """Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
promql = config.get("promql") promql = config.get("promql")
@@ -292,6 +305,63 @@ class PrometheusWidgetSource:
return raw return raw
return {"series": normalize_prometheus_matrix(raw["matrix"])} return {"series": normalize_prometheus_matrix(raw["matrix"])}
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
Scalar-only: a multi-series query returns an error (SC-111). Threshold
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
through for the frontend renderer.
"""
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
if "error" in raw:
return raw
result = raw["result"].get("result", [])
if len(result) != 1:
return {"error": "Gauge requires a single-series query; refine your PromQL"}
try:
value = float(result[0]["value"][1])
except (KeyError, IndexError, ValueError, TypeError):
return {"error": "Gauge query returned no scalar value"}
return {
"value": value,
"warn_at": config.get("warn_at"),
"crit_at": config.get("crit_at"),
"min": config.get("min"),
"max": config.get("max"),
"unit": config.get("unit"),
}
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
Runs ``query_range`` over the configured window preset, averages all
non-null numeric samples of the single series, and returns a scalar.
Scalar-only: a multi-series query returns an error (SC-114).
"""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._range_query(base_url, timeout, promql, window)
if "error" in raw:
return raw
result = raw["matrix"]
if len(result) != 1:
return {"error": "Mean requires a single-series query; refine your PromQL"}
points = result[0].get("values") or []
nums: list[float] = []
for _, v in points:
if v in (None, "NaN", "+Inf", "-Inf"):
continue
try:
nums.append(float(v))
except (TypeError, ValueError):
continue
if not nums:
return {"error": "Mean query returned no numeric samples in the window"}
mean = sum(nums) / len(nums)
return {"value": mean, "unit": config.get("unit")}
class AlertmanagerWidgetSource: class AlertmanagerWidgetSource:
"""Fetch firing alerts from an Alertmanager service and summarize them.""" """Fetch firing alerts from an Alertmanager service and summarize them."""
+1 -1
View File
@@ -100,7 +100,7 @@ def test_authentik_service_definition():
def test_definitions_declare_widget_kinds(): def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"} assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"}
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart"} assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("nextcloud").widget_kinds == []
+197
View File
@@ -991,3 +991,200 @@ def test_widget_reference_update_sort_order(widget_ref_client):
# Widget instances themselves are unchanged. # Widget instances themselves are unchanged.
assert store.get_widget(widget_a["id"])["sort_order"] == 0 assert store.get_widget(widget_a["id"])["sort_order"] == 0
assert store.get_widget(widget_b["id"])["sort_order"] == 1 assert store.get_widget(widget_b["id"])["sort_order"] == 1
# ---------------------------------------------------------------------------
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_returns_scalar():
"""SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(
service,
"gauge",
{
"promql": "cpu_usage",
"warn_at": 0.8,
"crit_at": 0.95,
"unit": "%",
},
)
call = mock_get.call_args
assert call.args[0].endswith("/api/v1/query")
assert call.kwargs["params"]["query"] == "cpu_usage"
assert result["value"] == 0.75
assert result["warn_at"] == 0.8
assert result["crit_at"] == 0.95
assert result["unit"] == "%"
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_rejects_multi_series():
"""SC-111: gauge must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "value": [100, "1"]},
{"metric": {"instance": "b"}, "value": [100, "2"]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "gauge", {"promql": "up"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
result = await adapter.fetch(service, "gauge", {"promql": ""})
assert result == {"error": "promql is required"}
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_computes_average():
"""SC-112: mean kind averages non-null values over the window."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "cpu"},
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
assert result["value"] == 2.0
assert result["unit"] is None
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_rejects_multi_series():
"""SC-114: mean must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_skips_nan_values():
"""SC-112: NaN / Inf values are excluded from the mean computation."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {},
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
# (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
assert result["value"] == 3.0
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
@@ -27,6 +27,8 @@ describe("service registry", () => {
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"metric", "metric",
"chart", "chart",
"gauge",
"mean",
]); ]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts", "active_alerts",
+49
View File
@@ -3,6 +3,8 @@ import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget"; import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget"; import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -130,6 +132,53 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
component: PrometheusChartWidget, component: PrometheusChartWidget,
}, },
{
kind: "gauge",
name: "Gauge",
description:
"Instant query rendered as a gauge with optional threshold bands.",
refreshIntervalMs: 30_000,
defaultConfig: { promql: "" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL instant query (must return a single scalar)",
},
warn_at: { type: "number", description: "Warning threshold" },
crit_at: { type: "number", description: "Critical threshold" },
min: { type: "number" },
max: { type: "number" },
unit: { type: "string" },
},
required: ["promql"],
},
component: PrometheusGaugeWidget,
},
{
kind: "mean",
name: "Mean",
description: "Average value of a PromQL query over a time window.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query (must return a single series)",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
unit: { type: "string" },
},
required: ["promql"],
},
component: PrometheusMeanWidget,
},
], ],
}, },
jellyfin: { jellyfin: {
@@ -0,0 +1,137 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
RadialBarChart,
RadialBar,
ResponsiveContainer,
PolarAngleAxis,
} from "recharts";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface GaugeData {
value: number;
warn_at?: number | null;
crit_at?: number | null;
min?: number | null;
max?: number | null;
unit?: string | null;
}
function formatValue(value: number, unit?: string | null): string {
let formatted: string;
if (Math.abs(value) >= 100) {
formatted = value.toFixed(0);
} else if (Math.abs(value) >= 1) {
formatted = value.toFixed(2).replace(/\.?0+$/, "");
} else {
formatted = value.toFixed(4).replace(/\.?0+$/, "");
}
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusGaugeWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const gauge = data?.data as GaugeData | undefined;
// Compute gauge domain and threshold bands.
const value = gauge?.value ?? 0;
const min = gauge?.min ?? 0;
const max =
gauge?.max ?? Math.max(value, gauge?.warn_at ?? 0, gauge?.crit_at ?? 0, 1);
const warnAt = gauge?.warn_at;
const critAt = gauge?.crit_at;
const hasBands = warnAt != null && critAt != null;
// recharts RadialBarChart uses a 0100 domain for the angle axis.
// Map our [min, max] domain to [0, 100].
const range = max - min || 1;
const toPercent = (v: number) => Math.round(((v - min) / range) * 100);
const valuePct = Math.max(0, Math.min(100, toPercent(value)));
// Build track cells: green / amber / red when bands are set, else neutral.
const trackCells = hasBands
? [
{ pct: toPercent(warnAt!), fill: "hsl(var(--chart-1))" }, // green
{ pct: toPercent(critAt!), fill: "hsl(var(--chart-4))" }, // amber
{ pct: 100, fill: "hsl(var(--destructive))" }, // red
]
: [{ pct: 100, fill: "hsl(var(--muted))" }];
const valueColor = hasBands
? value >= critAt!
? "hsl(var(--destructive))"
: value >= warnAt!
? "hsl(var(--chart-4))"
: "hsl(var(--chart-1))"
: "hsl(var(--primary))";
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[220px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : gauge ? (
<div className="flex flex-col items-center">
<ResponsiveContainer width="100%" height={200}>
<RadialBarChart
innerRadius="65%"
outerRadius="100%"
data={[
...trackCells.map((c) => ({
name: "track",
pct: c.pct,
fill: c.fill,
})),
{ name: "value", pct: valuePct, fill: valueColor },
]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background={{ fill: "hsl(var(--muted))" }}
dataKey="pct"
cornerRadius={6}
/>
</RadialBarChart>
</ResponsiveContainer>
<div className="-mt-12 flex flex-col items-center">
<span className="text-2xl font-bold" style={{ color: valueColor }}>
{formatValue(value, gauge.unit)}
</span>
{hasBands ? (
<span className="text-xs text-muted-foreground">
warn {formatValue(warnAt!, gauge.unit)} · crit{" "}
{formatValue(critAt!, gauge.unit)}
</span>
) : null}
</div>
</div>
) : (
<Alert>
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,59 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface MeanData {
value: number;
unit?: string | null;
}
function formatMean(value: number, unit?: string | null): string {
let formatted: string;
if (Math.abs(value) >= 1000) {
formatted = value.toFixed(0);
} else if (Math.abs(value) >= 1) {
formatted = value.toFixed(2).replace(/\.?0+$/, "");
} else {
formatted = value.toFixed(4).replace(/\.?0+$/, "");
}
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusMeanWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const mean = data?.data as MeanData | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-32" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : mean ? (
<div className="flex flex-col items-center justify-center py-4">
<span className="text-3xl font-bold">
{formatMean(mean.value, mean.unit)}
</span>
</div>
) : (
<Alert>
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusGaugeWidget } from "../PrometheusGaugeWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "wg1",
service_id: "s1",
widget_kind: "gauge",
title: "CPU Gauge",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "wg1", error, fetched_at: 0 }
: { widget_id: "wg1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusGaugeWidget", () => {
it("renders a gauge with value and threshold bands", () => {
mockData({
value: 0.75,
warn_at: 0.8,
crit_at: 0.95,
unit: "%",
});
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText("CPU Gauge")).toBeInTheDocument();
expect(screen.getByText(/0.75 %/)).toBeInTheDocument();
// Threshold labels present when bands are set.
expect(screen.getByText(/warn/i)).toBeInTheDocument();
expect(screen.getByText(/crit/i)).toBeInTheDocument();
});
it("renders a gauge without threshold bands (single color)", () => {
mockData({ value: 42, unit: "req/s" });
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/42 req\/s/)).toBeInTheDocument();
// No threshold labels when bands are absent.
expect(screen.queryByText(/warn/i)).not.toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Gauge requires a single-series query");
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
});
it("shows empty state when no data", () => {
mockData(null);
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusMeanWidget } from "../PrometheusMeanWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "wm1",
service_id: "s1",
widget_kind: "mean",
title: "Avg CPU",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "wm1", error, fetched_at: 0 }
: { widget_id: "wm1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusMeanWidget", () => {
it("renders the mean value with unit", () => {
mockData({ value: 23.5, unit: "%" });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText("Avg CPU")).toBeInTheDocument();
expect(screen.getByText(/23.5 %/)).toBeInTheDocument();
});
it("renders the mean value without unit", () => {
mockData({ value: 1500, unit: null });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/1500/)).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Mean requires a single-series query");
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
});
it("shows empty state when no data", () => {
mockData(null);
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
+2
View File
@@ -2,6 +2,8 @@ export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget"; export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget"; export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget"; export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget"; export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget"; export { SshTaskWidget } from "./SshTaskWidget";
@@ -113,61 +113,61 @@ This ordering ensures the chart capability is proven against Prometheus before t
**Satisfies:** SC-109, SC-110, SC-111, SC-112, SC-113, SC-114. **Satisfies:** SC-109, SC-110, SC-111, SC-112, SC-113, SC-114.
- [ ] **2.1 Extract shared `_instant_query` helper + add `_fetch_gauge` to `PrometheusWidgetSource`** - [x] **2.1 Extract shared `_instant_query` helper + add `_fetch_gauge` to `PrometheusWidgetSource`**
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
- Lines: ~40 - Lines: ~40
- Dependencies: Slice 1 (1.3) - Dependencies: Slice 1 (1.3)
- Details: Extract the instant-query HTTP call from the existing `metric` path into a private `_instant_query(base_url, timeout, promql) -> dict` returning `{"result": [...]}` or `{"error": ...}`. Refactor the `metric` path to use it (behavior unchanged). Add `_fetch_gauge(self, base_url, timeout, config)` using `_instant_query`: assert `len(result) == 1` (scalar-only, SC-111); parse `float(result[0]["value"][1])`; return `{"value": float, "warn_at": config.get("warn_at"), "crit_at": config.get("crit_at"), "min": config.get("min"), "max": config.get("max"), "unit": config.get("unit")}`. Dispatch `widget_kind == "gauge"` in `.fetch()`. - Details: Extract the instant-query HTTP call from the existing `metric` path into a private `_instant_query(base_url, timeout, promql) -> dict` returning `{"result": [...]}` or `{"error": ...}`. Refactor the `metric` path to use it (behavior unchanged). Add `_fetch_gauge(self, base_url, timeout, config)` using `_instant_query`: assert `len(result) == 1` (scalar-only, SC-111); parse `float(result[0]["value"][1])`; return `{"value": float, "warn_at": config.get("warn_at"), "crit_at": config.get("crit_at"), "min": config.get("min"), "max": config.get("max"), "unit": config.get("unit")}`. Dispatch `widget_kind == "gauge"` in `.fetch()`.
- [ ] **2.2 Add `_fetch_mean` to `PrometheusWidgetSource`** - [x] **2.2 Add `_fetch_mean` to `PrometheusWidgetSource`**
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
- Lines: ~30 - Lines: ~30
- Dependencies: 2.1, Slice 1 (1.3 for `_range_query`) - Dependencies: 2.1, Slice 1 (1.3 for `_range_query`)
- Details: Add `_fetch_mean(self, base_url, timeout, config)` using the shared `_range_query` from S1. Assert `len(result) == 1` (scalar-only, SC-114). Collect non-null numeric values from the single series; compute arithmetic mean; return `{"value": mean, "unit": config.get("unit")}`. If no numeric samples → `{"error": "..."}`. Dispatch `widget_kind == "mean"` in `.fetch()`. - Details: Add `_fetch_mean(self, base_url, timeout, config)` using the shared `_range_query` from S1. Assert `len(result) == 1` (scalar-only, SC-114). Collect non-null numeric values from the single series; compute arithmetic mean; return `{"value": mean, "unit": config.get("unit")}`. If no numeric samples → `{"error": "..."}`. Dispatch `widget_kind == "mean"` in `.fetch()`.
- [ ] **2.3 Declare `gauge` + `mean` widget kinds in Prometheus integration** - [x] **2.3 Declare `gauge` + `mean` widget kinds in Prometheus integration**
- Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify) - Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify)
- Lines: ~25 - Lines: ~25
- Dependencies: 2.1, 2.2 - Dependencies: 2.1, 2.2
- Details: Add `PrometheusGaugeWidgetConfig` (`promql: str`, `warn_at: float|None`, `crit_at: float|None`, `min: float|None`, `max: float|None`, `unit: str|None`) and `PrometheusMeanWidgetConfig` (`promql: str`, `window: str = "1h"`, `unit: str|None`). Add `widget_kind(...)` entries: `gauge` (refresh 30s), `mean` (refresh 60s). - Details: Add `PrometheusGaugeWidgetConfig` (`promql: str`, `warn_at: float|None`, `crit_at: float|None`, `min: float|None`, `max: float|None`, `unit: str|None`) and `PrometheusMeanWidgetConfig` (`promql: str`, `window: str = "1h"`, `unit: str|None`). Add `widget_kind(...)` entries: `gauge` (refresh 30s), `mean` (refresh 60s).
- [ ] **2.4 Add backend tests for gauge + mean adapters** - [x] **2.4 Add backend tests for gauge + mean adapters**
- Files: `backend/tests/test_widgets.py` (modify) or `backend/tests/test_prometheus_range.py` (modify) - Files: `backend/tests/test_widgets.py` (modify) or `backend/tests/test_prometheus_range.py` (modify)
- Lines: ~60 - Lines: ~60
- Dependencies: 2.1, 2.2 - Dependencies: 2.1, 2.2
- Details: Mock `requests.get` for gauge: instant query returning 1 series → assert `{value, ...}` shape; returning 2 series → assert `{"error": ...}` (SC-111). Mock for mean: range query returning 1 series with known values `[1.0, 2.0, 3.0]` → assert mean `2.0`; returning 2 series → assert `{"error": ...}` (SC-114). Test timeout/RequestException → `{"error": ...}`. - Details: Mock `requests.get` for gauge: instant query returning 1 series → assert `{value, ...}` shape; returning 2 series → assert `{"error": ...}` (SC-111). Mock for mean: range query returning 1 series with known values `[1.0, 2.0, 3.0]` → assert mean `2.0`; returning 2 series → assert `{"error": ...}` (SC-114). Test timeout/RequestException → `{"error": ...}`.
- [ ] **2.5 Create `PrometheusGaugeWidget` component** - [x] **2.5 Create `PrometheusGaugeWidget` component**
- Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx` (new) - Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx` (new)
- Lines: ~90 - Lines: ~90
- Dependencies: Slice 1 (1.5 for widget pattern) - Dependencies: Slice 1 (1.5 for widget pattern)
- Details: Render via recharts `RadialBarChart` (no new dep; SC-110). Threshold bands: three stacked `RadialBar` track cells (green `0→warn`, amber `warn→crit`, red `crit→max`) + a value cell. When `warn_at`/`crit_at` absent → single neutral-color track. `min`/`max` default to `0`/`max(value, 1)`. Reuse `SectionCard` + `Alert`/`Skeleton` for loading/error states. Consume `data?.data?.value`, `warn_at`, etc. off `useWidgetData`. - Details: Render via recharts `RadialBarChart` (no new dep; SC-110). Threshold bands: three stacked `RadialBar` track cells (green `0→warn`, amber `warn→crit`, red `crit→max`) + a value cell. When `warn_at`/`crit_at` absent → single neutral-color track. `min`/`max` default to `0`/`max(value, 1)`. Reuse `SectionCard` + `Alert`/`Skeleton` for loading/error states. Consume `data?.data?.value`, `warn_at`, etc. off `useWidgetData`.
- [ ] **2.6 Create `PrometheusMeanWidget` component** - [x] **2.6 Create `PrometheusMeanWidget` component**
- Files: `frontend/src/widgets/PrometheusMeanWidget.tsx` (new) - Files: `frontend/src/widgets/PrometheusMeanWidget.tsx` (new)
- Lines: ~50 - Lines: ~50
- Dependencies: Slice 1 - Dependencies: Slice 1
- Details: Single-value display reusing the `MetricCard` pattern (big number + optional `unit` suffix + subtext "mean over last {window}"). Loading/error/empty via `Skeleton`/`Alert`. No charting library — it's a number (SC-112). - Details: Single-value display reusing the `MetricCard` pattern (big number + optional `unit` suffix + subtext "mean over last {window}"). Loading/error/empty via `Skeleton`/`Alert`. No charting library — it's a number (SC-112).
- [ ] **2.7 Create gauge + mean frontend tests** - [x] **2.7 Create gauge + mean frontend tests**
- Files: `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` (new), `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` (new) - Files: `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` (new), `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` (new)
- Lines: ~60 - Lines: ~60
- Dependencies: 2.5, 2.6 - Dependencies: 2.5, 2.6
- Details: Each covers loading, error, and rendered-data case (SC-125). Gauge test: render with bands (`warn_at`/`crit_at` set) and without (single color). Mean test: render with `value` + `unit`. - Details: Each covers loading, error, and rendered-data case (SC-125). Gauge test: render with bands (`warn_at`/`crit_at` set) and without (single color). Mean test: render with `value` + `unit`.
- [ ] **2.8 Add gauge + mean bindings to frontend registry** - [x] **2.8 Add gauge + mean bindings to frontend registry**
- Files: `frontend/src/integrations/registry.ts` (modify) - Files: `frontend/src/integrations/registry.ts` (modify)
- Lines: ~35 - Lines: ~35
- Dependencies: 2.5, 2.6 - Dependencies: 2.5, 2.6
- Details: Import `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Add `gauge` (refresh 30s, configSchema with `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`) and `mean` (refresh 60s, configSchema with `promql`, `window`, `unit`) entries to the `prometheus` binding's `widgets` array alongside `metric` and `chart`. - Details: Import `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Add `gauge` (refresh 30s, configSchema with `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`) and `mean` (refresh 60s, configSchema with `promql`, `window`, `unit`) entries to the `prometheus` binding's `widgets` array alongside `metric` and `chart`.
- [ ] **2.9 Update widgets barrel + registry tests** - [x] **2.9 Update widgets barrel + registry tests**
- Files: `frontend/src/widgets/index.ts` (modify), `frontend/src/integrations/registry.test.ts` (modify) - Files: `frontend/src/widgets/index.ts` (modify), `frontend/src/integrations/registry.test.ts` (modify)
- Lines: ~10 - Lines: ~10
- Dependencies: 2.5, 2.6, 2.8 - Dependencies: 2.5, 2.6, 2.8
- Details: Export `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean` (four kinds). - Details: Export `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean` (four kinds).
- [ ] **2.10 Verify Slice 2 (build + lint + test)** - [x] **2.10 Verify Slice 2 (build + lint + test)**
- Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint` - Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint`
- Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve. - Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve.