feat(widgets): scale chart axes/tooltips with unit + scale options

Consistent graph scaling across every line-chart widget. A new shared
frontend/src/lib/metricFormat.ts picks a decimal prefix (kB/MB/GB, kbps/Mbps,
Gbps, …) from the series magnitude and formats values; LineSeriesChart accepts
unit + scale and formats both the Y-axis ticks and the tooltip with the SAME
prefix (one consistent unit per axis). MetricChartWidget (Prometheus) and
QbittorrentSpeedWidget pass the widget config through; qBit speed defaults to
bytes/sec → MB/s.

WidgetConfigDialog now renders `enum` schema fields as a <Select> dropdown, so
the backend's unit/scale Literal enums become consistent pickers in every graph
widget's config (and any future enum option).

Decimal (x1000) prefixes by default (matches Mbps/MB/s/Grafana).

Tests: 13 new metricFormat tests (auto/fixed scaling, percent, seconds,
nulls, trailing-zero trimming). 179/179 frontend tests pass; tsc + ESLint clean.
This commit is contained in:
Developer
2026-07-12 11:46:07 +00:00
parent 05a9faca3e
commit b7019b33ac
12 changed files with 333 additions and 26 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: frontend/src/components
## role
Shared UI component library providing reusable React display, table, dialog, card, chart, and widget components for the frontend application.
UI component library providing reusable React components for dashboard widgets, backup management tables, charts, dialogs, and media session displays.
## parent
index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md
+4 -4
View File
@@ -4,7 +4,7 @@ dir: frontend/src/components
index: frontend/src/components/.pi-map.index.md
## role
Shared UI component library providing reusable React display, table, dialog, card, chart, and widget components for the frontend application.
UI component library providing reusable React components for dashboard widgets, backup management tables, charts, dialogs, and media session displays.
## files
- BackupAlertsTable.tsx | Renders a responsive table of backup alerts with severity badges and acknowledge actions, switching between desktop table and mobile card layouts. | exp: func:BackupAlertsTable({ alerts, onAcknowledge }: Props), call:useIsMobile, call:onAcknowledge, call:alerts.map, call:severityVariant, call:formatTimestamp | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, @/components/ui/mobile-card, ../hooks/useIsMobile, ../types/backups, useIsMobile hook, BackupAlert type
- BackupDashboardWidget.tsx | Displays a dashboard widget summarizing backup job statistics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupDashboardWidget(), call:useBackupDashboard, call:new Date(data.last_failed_at * 1000).toLocaleString | dep: @/components/ui/badge, @/components/ui/card, ../hooks/useBackups
@@ -14,7 +14,7 @@ Shared UI component library providing reusable React display, table, dialog, car
- DialogFooter.tsx | Renders a dialog footer component with cancel, optional secondary action, and confirm buttons, mapping legacy MUI color/variant props to shadcn Button variants. | exp: func:DialogFooter({ onCancel, cancelLabel = "Cancel", onConfirm, confirmLabel, confirmBusyLabel, confirmDisabled, confirmColor = "primary", confirmVariant = "contained", confirmStartIcon, secondaryAction, }: DialogFooterProps), call:resolveConfirmVariant | dep: react, @/components/ui/button
- HoverEditButton.tsx | Renders a hover-reveal edit button for desktop and always-visible edit button for mobile, preserving legacy CSS class hooks. | exp: func:HoverEditButton({ onClick, label = "Edit", mobile = "always", }: HoverEditButtonProps), call:e.stopPropagation, call:onClick | dep: lucide-react, @/components/ui/button
- LibraryOverview.tsx | Renders a two-column responsive grid displaying movie and TV library counts using shadcn/ui Card components | exp: func:LibraryOverview({ libraries }: Props), call:libraries.filter, call:movieLibs.map, call:lib.total.toLocaleString, call:lib.movies.toLocaleString, call:tvLibs.map, call:lib.series.toLocaleString | dep: @/components/ui/card, ../types
- LineSeriesChart.tsx | Renders multiple time-series as a shared recharts line-chart with merged timestamps. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, }: LineSeriesChartProps), call:mergeSeries, call:formatTime, call:Number, call:series.map | dep: recharts
- LineSeriesChart.tsx | Renders a responsive multi-series line chart using recharts with automatic metric scaling and time-based X-axis formatting. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", }: LineSeriesChartProps), call:series.reduce, call:Math.abs, call:metricScaleInfo, call:formatScaled, call:mergeSeries, call:formatTime, call:Number, call:fmt, call:series.map | dep: recharts, ../lib/metricFormat, metricFormat
- MetricCard.tsx | Renders a compact metric display card with label, value, and optional subtext using Tailwind CSS styling. | exp: func:MetricCard({ label, value, subtext }: Props) | dep: @/components/ui/card
- NowPlaying.tsx | Renders a now-playing panel by wrapping SessionActivityPanel with a specific empty message for user activity sessions. | exp: func:NowPlaying({ sessions, onSelectSession }: Props) | dep: ../types, ./SessionActivityPanel
- PinnedServiceLink.tsx | Renders a navigable card-shaped button for pinned service shortcuts on dashboards and provides a helper to construct service target paths. | exp: PinnedServiceLinkProps, func:PinnedServiceLink({ label, target, icon: Icon = Boxes, className, }: PinnedServiceLinkProps), call:useNavigate, call:navigate, call:cn, func:serviceLinkTarget(serviceType: string, serviceId: string, tab: string) → string | dep: react-router-dom, lucide-react, @/lib/utils
@@ -23,10 +23,10 @@ Shared UI component library providing reusable React display, table, dialog, car
- ServiceTestPanel.tsx | Presentational component rendering a "Test credentials" panel with test button, result display, and a "Save anyway" checkbox. | exp: func:ServiceTestPanel({ result, isPending, saveAnyway, onTest, onSaveAnywayChange, disabled, }: Props), call:onSaveAnywayChange | dep: @/components/ui/alert, @/components/ui/button, ../types
- SessionActivityPanel.tsx | Renders a scrollable table displaying live media session activity details with status badges and optional session selection callbacks. | exp: func:SessionActivityPanel({ sessions, emptyMessage = "No live sessions matched to this user.", selectedUserLabel, onSelectSession, }: Props), call:buildStatusSummary, call:sessions.map, call:formatStateLabel, call:onSelectSession, call:sessionStateVariant, call:event.stopPropagation | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, ../types
- TabbedCard.tsx | Renders a card with a line-style tab bar header and content area, acting as a controlled wrapper around shadcn/ui Tabs for backward-compatible API migration from MUI. | exp: func:TabbedCard({ value, onChange, tabs, children, }: TabbedCardProps), call:onChange, call:String | dep: react, @/components/ui/card, @/components/ui/tabs
- WidgetConfigDialog.tsx | Provides a dialog and sheet interface for creating, configuring, reordering, and managing the lifecycle of UI widgets and their dashboard references. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui (dialog, button, input, textarea, label, switch, select, badge, alert, sheet-form)
- WidgetConfigDialog.tsx | This file provides a React dialog component for creating, editing, deleting, and managing dashboard widgets and their specific configurations. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/* (dialog, button, input, textarea, label, switch, select, badge, alert, sheet-form)
- WidgetInstance.tsx | Renders a widget instance card that dynamically resolves and displays a widget component, with optional edit and copy actions. | exp: func:WidgetInstanceCard({ widget, onEdit, onCopy }: Props), call:useServiceInstances, call:resolveWidget, call:onCopy, call:onEdit | dep: @/components/ui/alert, @/components/ui/button, lucide-react, ../hooks/useServices, ../integrations/registry, ../types, ./SectionCard
## arch
Presentational React/TypeScript components built on shadcn/ui primitives with responsive (desktop/mobile) layouts, wrapping recharts for visualization and maintaining backward-compatible APIs during migration from MUI.
Presentational React components built on shadcn/ui primitives with Tailwind CSS, using responsive design patterns (desktop table/mobile card) and wrapping legacy APIs for MUI-to-shadcn migration compatibility.
## tags
call:use, components, ui, card, widget, table, backup, call:on
## symbols
+32 -1
View File
@@ -7,6 +7,12 @@ import {
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
type MetricUnit,
type MetricScale,
metricScaleInfo,
formatScaled,
} from "../lib/metricFormat";
export interface SeriesPoint {
t: number;
@@ -51,13 +57,32 @@ const CHART_COLORS = [
interface LineSeriesChartProps {
series: ChartSeries[];
height?: number;
/** Display unit for the Y axis + tooltip (drives decimal-prefix scaling). */
unit?: MetricUnit;
/** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */
scale?: MetricScale;
}
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
export function LineSeriesChart({
series,
height = 300,
unit = "none",
scale = "auto",
}: LineSeriesChartProps) {
// Choose ONE (divisor, suffix) from the series magnitude so the axis and
// tooltip stay consistent (e.g. all values shown in MB/s).
const maxAbs = series.reduce((m, s) => {
for (const p of s.points) {
const v = p.v == null ? 0 : Math.abs(p.v);
if (v > m) m = v;
}
return m;
}, 0);
const scaleInfo = metricScaleInfo(maxAbs, unit, scale);
const fmt = (v: number | null | undefined) =>
formatScaled(v, scaleInfo, unit);
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={mergeSeries(series)}>
@@ -68,9 +93,15 @@ export function LineSeriesChart({
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<YAxis
tickFormatter={fmt}
tick={{ fontSize: 11 }}
width={56}
className="fill-muted-foreground"
/>
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
formatter={(value) => fmt(Number(value))}
contentStyle={{
backgroundColor: "var(--color-popover)",
border: "1px solid var(--color-border)",
+30 -4
View File
@@ -161,8 +161,18 @@ function WidgetConfigEditor({
// in via `format: "textarea"`; the well-known field names below are
// treated as textarea by default.
const schemaFormat = (schema as { format?: string }).format;
const TEXTAREA_KEYS = new Set(["promql", "query", "text", "command", "notes"]);
const isTextarea = schemaFormat === "textarea" || TEXTAREA_KEYS.has(key);
const TEXTAREA_KEYS = new Set([
"promql",
"query",
"text",
"command",
"notes",
]);
const isTextarea =
schemaFormat === "textarea" || TEXTAREA_KEYS.has(key);
// Enum schema fields (e.g. unit/scale) render as a dropdown so users pick
// from the allowed values consistently across every widget kind.
const enumOptions = (schema as { enum?: string[] }).enum;
return (
<Field
key={key}
@@ -170,7 +180,23 @@ function WidgetConfigEditor({
htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description}
>
{isTextarea ? (
{enumOptions ? (
<Select
value={String(config[key] ?? "")}
onValueChange={(v) => onChange({ ...config, [key]: v })}
>
<SelectTrigger id={`widget-cfg-${key}`}>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{enumOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt.replace(/_/g, " ")}
</SelectItem>
))}
</SelectContent>
</Select>
) : isTextarea ? (
<Textarea
id={`widget-cfg-${key}`}
rows={6}
@@ -397,7 +423,7 @@ export function WidgetConfigDialog({
!search ||
w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search),
);
);
}, [allWidgets, instances, references, existingSearch]);
async function handleAddReference(widgetId: string) {
+8 -3
View File
@@ -2,19 +2,24 @@
dir: frontend/src/lib
## role
Provides shared utility functions for the frontend application, specifically Tailwind CSS class merging and conflict resolution.
Shared utility library providing metric formatting, chart axis scaling, and CSS class manipulation for the frontend application.
## parent
index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md
## children
-
- frontend/src/lib/__tests__
index: frontend/src/lib/__tests__/.pi-map.index.md
map: frontend/src/lib/__tests__/.pi-map.md
## files
- metricFormat.ts
- utils.ts
## links
index: frontend/src/lib/.pi-map.index.md
map: frontend/src/lib/.pi-map.md
## workflows
- change lib behavior
read: utils.ts
read: metricFormat.ts, utils.ts
- explore lib subdirectories
index: frontend/src/lib/__tests__/.pi-map.index.md
## dirty
-
+14 -6
View File
@@ -4,19 +4,27 @@ dir: frontend/src/lib
index: frontend/src/lib/.pi-map.index.md
## role
Provides shared utility functions for the frontend application, specifically Tailwind CSS class merging and conflict resolution.
Shared utility library providing metric formatting, chart axis scaling, and CSS class manipulation for the frontend application.
## files
- metricFormat.ts | Formats metric values and computes axis scaling using decimal prefixes for chart widgets. | exp: MetricUnit, MetricScale, METRIC_UNIT_LABELS, METRIC_SCALE_LABELS, ScaleInfo, func:metricScaleInfo(maxAbs: number, unit: MetricUnit, scale: MetricScale) → ScaleInfo, call:Math.floor, call:Math.log, call:Math.max, call:Math.min, func:formatScaled(value: number | null | undefined, { divisor, suffix }: ScaleInfo, unit: MetricUnit) → string, call:Number.isNaN, call:trim, call:formatDuration, call:`${trim(value / divisor)} ${suffix}`.trim, func:formatMetricValue(value: number | null | undefined, unit: MetricUnit, scale: MetricScale) → string, call:Number.isNaN, call:metricScaleInfo, call:Math.abs, call:formatScaled
- utils.ts | Utility function that merges Tailwind CSS classes with proper deduplication and conflict resolution | exp: func:cn(...inputs: ClassValue[]), call:twMerge, call:clsx | dep: clsx, tailwind-merge
## arch
Functional utility module pattern using the `clsx` + `tailwind-merge` (cn) convention for deterministic class composition.
Functional utility module pattern exposing pure helper functions, leveraging external libraries (decimal prefixes, tailwind-merge) for stateless transformations.
## tags
merge, tailwind, cn, call:tw, call:clsx, utils, utility, merges
metric, scale, info, format, unit, labels, scaled, call:number.is
## symbols
- metricScaleInfo
- formatScaled
- formatMetricValue
- cn
- call:twMerge
- call:clsx
- MetricUnit
- MetricScale
- METRIC_UNIT_LABELS
- METRIC_SCALE_LABELS
## workflows
- change lib behavior
read: utils.ts
read: metricFormat.ts, utils.ts
- explore lib subdirectories
index: frontend/src/lib/__tests__/.pi-map.index.md
## dirty
-
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import {
metricScaleInfo,
formatScaled,
formatMetricValue,
} from "../metricFormat";
describe("metricScaleInfo", () => {
it("auto-scales bytes/sec by magnitude (MB/s)", () => {
const info = metricScaleInfo(1_500_000, "bytes_per_sec", "auto"); // 1.5 MB/s
expect(info.suffix).toBe("MB/s");
expect(info.divisor).toBe(1_000_000);
});
it("auto-scales bytes/sec to kB/s for smaller magnitudes", () => {
const info = metricScaleInfo(2_500, "bytes_per_sec", "auto");
expect(info.suffix).toBe("kB/s");
expect(info.divisor).toBe(1_000);
});
it("auto-scales bits/sec to Mbps", () => {
const info = metricScaleInfo(5_000_000, "bits_per_sec", "auto");
expect(info.suffix).toBe("Mbps");
});
it("forces a fixed prefix when scale is set", () => {
const info = metricScaleInfo(2_000_000_000, "bytes", "m"); // force MB even though it'd auto-pick GB
expect(info.suffix).toBe("MB");
expect(info.divisor).toBe(1_000_000);
});
it("clamps beyond the largest prefix", () => {
const info = metricScaleInfo(1e30, "bytes", "auto");
expect(info.suffix).toBe("TB");
});
it("raw unit never scales (divisor 1, no suffix)", () => {
const info = metricScaleInfo(9999, "none", "auto");
expect(info.suffix).toBe("");
expect(info.divisor).toBe(1);
});
});
describe("formatScaled", () => {
it("formats bytes/sec with the chosen scale", () => {
const info = metricScaleInfo(1_500_000, "bytes_per_sec", "auto");
expect(formatScaled(1_500_000, info, "bytes_per_sec")).toBe("1.5 MB/s");
});
it("appends % for percent unit regardless of scale", () => {
expect(
formatScaled(42, metricScaleInfo(42, "percent", "auto"), "percent"),
).toBe("42%");
});
it("humanizes seconds", () => {
expect(
formatScaled(90, metricScaleInfo(90, "seconds", "auto"), "seconds"),
).toBe("1.5 min");
});
it("returns em dash for null/NaN", () => {
const info = metricScaleInfo(1, "none", "auto");
expect(formatScaled(null, info, "none")).toBe("—");
expect(formatScaled(NaN, info, "none")).toBe("—");
});
});
describe("formatMetricValue (one-shot)", () => {
it("auto-scales a scalar value", () => {
expect(formatMetricValue(12_500_000, "bytes_per_sec")).toBe("12.5 MB/s");
});
it("formats bits/sec as Gbps for large values", () => {
expect(formatMetricValue(1_000_000_000, "bits_per_sec")).toBe("1 Gbps");
});
it("raw unit keeps the number as-is", () => {
expect(formatMetricValue(1500, "none")).toBe("1500");
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Metric value formatting + axis scaling for chart widgets.
*
* The data sources (Prometheus range queries, qBittorrent speed counters)
* return raw numbers. Charts display them with a consistent decimal-prefix
* scale (kB/MB/GB, kbps/Mbps/Gbps, …) chosen from the series' magnitude.
*
* Convention: decimal (base 1000) prefixes k/M/G/T — matches networking
* (Mbps, MB/s) and the kB/MB/GB phrasing users expect for storage/speed.
*/
export type MetricUnit =
| "none"
| "bytes"
| "bytes_per_sec"
| "bits_per_sec"
| "bits"
| "percent"
| "seconds";
export type MetricScale = "auto" | "k" | "m" | "g" | "t";
const BASE = 1000;
const PREFIXES = ["", "k", "M", "G", "T"] as const;
const FIXED_INDEX: Record<Exclude<MetricScale, "auto">, number> = {
k: 1,
m: 2,
g: 3,
t: 4,
};
/** Base unit suffix appended after the prefix (e.g. "B", "B/s", "bps"). */
const UNIT_SUFFIX: Record<
Exclude<MetricUnit, "percent" | "seconds">,
string
> = {
none: "",
bytes: "B",
bytes_per_sec: "B/s",
bits_per_sec: "bps",
bits: "bit",
};
/** A readable label for each unit value (used in option lists / legends). */
export const METRIC_UNIT_LABELS: Record<MetricUnit, string> = {
none: "none (raw)",
bytes: "bytes",
bytes_per_sec: "bytes/sec",
bits_per_sec: "bits/sec",
bits: "bits",
percent: "percent",
seconds: "seconds",
};
export const METRIC_SCALE_LABELS: Record<MetricScale, string> = {
auto: "auto",
k: `k (×${BASE})`,
m: `M (×${BASE ** 2})`,
g: `G (×${BASE ** 3})`,
t: `T (×${BASE ** 4})`,
};
export interface ScaleInfo {
divisor: number;
suffix: string;
}
/**
* Pick a (divisor, suffix) for the given magnitude, unit, and scale mode.
* `maxAbs` is the reference value used for auto-scaling (typically the chart's
* max absolute value) so the whole axis uses one consistent prefix.
*/
export function metricScaleInfo(
maxAbs: number,
unit: MetricUnit,
scale: MetricScale,
): ScaleInfo {
if (unit === "none") return { divisor: 1, suffix: "" };
if (unit === "percent") return { divisor: 1, suffix: "%" };
if (unit === "seconds") return { divisor: 1, suffix: "s" };
const unitSuffix = UNIT_SUFFIX[unit] ?? "";
let idx: number;
if (scale === "auto") {
idx = maxAbs > 0 ? Math.floor(Math.log(maxAbs) / Math.log(BASE)) : 0;
} else {
idx = FIXED_INDEX[scale];
}
idx = Math.max(0, Math.min(idx, PREFIXES.length - 1));
const divisor = BASE ** idx;
const suffix = `${PREFIXES[idx]}${unitSuffix}`;
return { divisor, suffix };
}
/** Format a number with up to ~3 significant digits, trimming trailing zeros. */
function trim(n: number): string {
if (!Number.isFinite(n)) return "—";
const abs = Math.abs(n);
if (abs === 0) return "0";
let s: string;
if (abs >= 100) s = n.toFixed(0);
else if (abs >= 10) s = n.toFixed(1);
else if (abs >= 1) s = n.toFixed(2);
else s = n.toFixed(3);
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
}
/** Humanize seconds into a short duration string. */
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds)) return "—";
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ms`;
if (seconds < 60) return `${seconds.toFixed(1)} s`;
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} min`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)} h`;
return `${(seconds / 86400).toFixed(1)} d`;
}
/**
* Format a value using a precomputed ScaleInfo (so the tooltip matches the axis).
* percent/seconds ignore the divisor and format specially.
*/
export function formatScaled(
value: number | null | undefined,
{ divisor, suffix }: ScaleInfo,
unit: MetricUnit,
): string {
if (value == null || Number.isNaN(value)) return "—";
if (unit === "percent") return `${trim(value)}%`;
if (unit === "seconds") return formatDuration(value);
return `${trim(value / divisor)} ${suffix}`.trim();
}
/** One-shot format (picks its own scale from the value itself). Handy for scalars. */
export function formatMetricValue(
value: number | null | undefined,
unit: MetricUnit,
scale: MetricScale = "auto",
): string {
if (value == null || Number.isNaN(value)) return "—";
const info = metricScaleInfo(Math.abs(value), unit, scale);
return formatScaled(value, info, unit);
}
+1 -1
View File
@@ -2,7 +2,7 @@
dir: frontend/src/widgets
## role
Provides self-contained dashboard widget components for rendering various data sources (Alertmanager, backups, Jellyfin, Prometheus metrics, qBittorrent, SSH tasks, and static content) in a unified card layout.
Collection of self-contained dashboard widget components that fetch and render monitoring, media, backup, and torrent data in various visual formats.
## parent
index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md
+4 -4
View File
@@ -4,24 +4,24 @@ dir: frontend/src/widgets
index: frontend/src/widgets/.pi-map.index.md
## role
Provides self-contained dashboard widget components for rendering various data sources (Alertmanager, backups, Jellyfin, Prometheus metrics, qBittorrent, SSH tasks, and static content) in a unified card layout.
Collection of self-contained dashboard widget components that fetch and render monitoring, media, backup, and torrent data in various visual formats.
## files
- AlertmanagerAlertsWidget.tsx | Renders an Alertmanager alerts dashboard widget displaying alert summaries, severity badges, and individual alert details. | exp: func:AlertmanagerAlertsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Object.entries(summary.by_severity).map, call:severityVariant, call:alerts.slice(0, 5).map | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types
- BackupsWidget.tsx | Displays a dashboard widget showing backup job metrics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:new Date(summary.last_failed_at * 1000).toLocaleString | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types/backups, ../types
- JellyfinNowPlayingWidget.tsx | Displays a Jellyfin now-playing widget that fetches and renders active media sessions using a custom data hook and session activity panel. | exp: func:JellyfinNowPlayingWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Array.isArray | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SessionActivityPanel, ../components/SectionCard, ../hooks/useWidgets, ../types, SessionActivityPanel, SectionCard, useWidgetData, types
- JellyfinWidget.tsx | Displays Jellyfin media server activity sessions in a widget with loading, error, and empty states. | exp: func:JellyfinWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Array.isArray | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SessionActivityPanel, ../components/SectionCard, ../hooks/useWidgets, ../types
- MetricChartWidget.tsx | Renders a metric chart widget that fetches time-series data and displays it in a line chart with loading, error, and empty states. | exp: func:MetricChartWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData, WidgetInstance type
- MetricChartWidget.tsx | Renders a metric chart widget that displays time-series data in a line chart with loading, error, and empty states. | exp: func:MetricChartWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData, metricFormat
- MetricGaugeWidget.tsx | Renders a metric gauge widget using a radial bar chart with threshold-based color bands for visualizing a single numeric value. | exp: func:MetricGaugeWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Math.max, call:Math.round, call:Math.min, call:toPercent, call:formatValue | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, recharts, SectionCard, useWidgetData, WidgetInstance types
- MetricMeanWidget.tsx | Displays a metric mean value from Prometheus/PromQL query results with conditional loading, error, and empty states. | exp: func:MetricMeanWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:formatMean | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance
- PrometheusMetricWidget.tsx | Displays a Prometheus metric widget that fetches and formats time-series data with loading and error states. | exp: func:PrometheusMetricWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:formatMetricResult | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance
- QbittorrentActiveTorrentsWidget.tsx | Displays a list of active torrents from a qBittorrent instance with download/upload speeds and status badges in a dashboard widget. | exp: func:QbittorrentActiveTorrentsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:torrents.map, call:formatSpeed | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData hook, WidgetInstance type
- QbittorrentSpeedWidget.tsx | Renders a qBittorrent download/upload speed widget with a line series chart, handling loading, error, and empty states. | exp: func:QbittorrentSpeedWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData hook
- QbittorrentSpeedWidget.tsx | Displays qBittorrent download/upload speed data as a line series chart with loading, error, and empty states. | exp: func:QbittorrentSpeedWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgets, metricFormat types, WidgetInstance types
- QbittorrentTotalsWidget.tsx | Displays qBittorrent torrent totals and per-state breakdown using a widget with loading, error, and data states. | exp: func:QbittorrentTotalsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Object.keys, call:Object.entries(payload.by_state).map | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types
- SshTaskWidget.tsx | Displays SSH task execution results with exit status, stdout, and stderr in a polling widget card | exp: func:SshTaskWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types
- StaticWidget.tsx | Renders a static text widget that displays fetched text content or a fallback message within a section card. | exp: func:StaticWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance
- index.ts | Barrel file that re-exports all widget components from a dashboard/widget module. | dep: AlertmanagerAlertsWidget, BackupsWidget, MetricChartWidget, MetricGaugeWidget, MetricMeanWidget, JellyfinWidget, JellyfinNowPlayingWidget, PrometheusMetricWidget, QbittorrentActiveTorrentsWidget, QbittorrentSpeedWidget, QbittorrentTotalsWidget, SshTaskWidget, StaticWidget
## arch
Composable React functional components using custom data-fetching hooks, each implementing independent loading/error/empty state handling with chart visualizations, exported via a barrel file for modular dashboard composition.
Composition of individually encapsulated widget components using a consistent pattern of data-fetching hooks with standardized loading, error, empty, and data states, exported via a barrel file.
## tags
widget, components, data, ui, call:use, metric, sectioncard, types
## symbols
+6 -1
View File
@@ -2,6 +2,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
@@ -29,7 +30,11 @@ export function MetricChartWidget({
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<LineSeriesChart series={series} />
<LineSeriesChart
series={series}
unit={widget.config.unit as MetricUnit}
scale={widget.config.scale as MetricScale}
/>
) : (
<Alert>
<AlertDescription>
@@ -2,6 +2,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
@@ -19,6 +20,9 @@ export function QbittorrentSpeedWidget({
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined;
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
const scale = (widget.config.scale as MetricScale) || "auto";
return (
<SectionCard title={widget.title} description={description}>
@@ -29,7 +33,12 @@ export function QbittorrentSpeedWidget({
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<LineSeriesChart series={series} height={220} />
<LineSeriesChart
series={series}
unit={unit}
scale={scale}
height={220}
/>
) : (
<Alert>
<AlertDescription>No speed data yet</AlertDescription>