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:
@@ -32,6 +32,25 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
|
||||
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(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
@@ -57,5 +76,21 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
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)
|
||||
if widget_kind == "chart":
|
||||
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).
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
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}"}
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
|
||||
return raw
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
@@ -281,6 +266,34 @@ class PrometheusWidgetSource:
|
||||
result = payload.get("data", {}).get("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]:
|
||||
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
|
||||
promql = config.get("promql")
|
||||
@@ -292,6 +305,63 @@ class PrometheusWidgetSource:
|
||||
return raw
|
||||
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:
|
||||
"""Fetch firing alerts from an Alertmanager service and summarize them."""
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_authentik_service_definition():
|
||||
|
||||
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("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("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
|
||||
@@ -991,3 +991,200 @@ def test_widget_reference_update_sort_order(widget_ref_client):
|
||||
# Widget instances themselves are unchanged.
|
||||
assert store.get_widget(widget_a["id"])["sort_order"] == 0
|
||||
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([
|
||||
"metric",
|
||||
"chart",
|
||||
"gauge",
|
||||
"mean",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||
"active_alerts",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
|
||||
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
|
||||
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||
@@ -130,6 +132,53 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -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 0–100 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,6 +2,8 @@ export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||
export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||
export { JellyfinWidget } from "./JellyfinWidget";
|
||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||
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.
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~40
|
||||
- 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()`.
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~30
|
||||
- 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()`.
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~25
|
||||
- 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).
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~60
|
||||
- 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": ...}`.
|
||||
|
||||
- [ ] **2.5 Create `PrometheusGaugeWidget` component**
|
||||
- [x] **2.5 Create `PrometheusGaugeWidget` component**
|
||||
- Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx` (new)
|
||||
- Lines: ~90
|
||||
- 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`.
|
||||
|
||||
- [ ] **2.6 Create `PrometheusMeanWidget` component**
|
||||
- [x] **2.6 Create `PrometheusMeanWidget` component**
|
||||
- Files: `frontend/src/widgets/PrometheusMeanWidget.tsx` (new)
|
||||
- Lines: ~50
|
||||
- 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).
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~60
|
||||
- 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`.
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~35
|
||||
- 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`.
|
||||
|
||||
- [ ] **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)
|
||||
- Lines: ~10
|
||||
- Dependencies: 2.5, 2.6, 2.8
|
||||
- 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`
|
||||
- Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user