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:
@@ -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,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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
-
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user