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) {