From 6ff9b4cfeabb54d8bc48ae5a61e9b1a67cc92341 Mon Sep 17 00:00:00 2001 From: henrygd Date: Wed, 30 Sep 2026 20:24:04 -0400 Subject: [PATCH] feat(ui): add MB/s and GB/s unit select to bandwidth alert Raises the bandwidth threshold max to 5 GB/s without making the MB/s slider less precise. Values are still stored in MB/s, and the unit is inferred from the stored value when the alert sheet opens. --- .../src/components/alerts/alerts-sheet.tsx | 85 ++++++++++++++++--- internal/site/src/lib/alerts.ts | 5 ++ internal/site/src/types.d.ts | 14 +++ 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/internal/site/src/components/alerts/alerts-sheet.tsx b/internal/site/src/components/alerts/alerts-sheet.tsx index 94e4e3b30..845aa0746 100644 --- a/internal/site/src/components/alerts/alerts-sheet.tsx +++ b/internal/site/src/components/alerts/alerts-sheet.tsx @@ -10,6 +10,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "@/components/ui/use-toast" @@ -17,7 +18,7 @@ import { alertInfo } from "@/lib/alerts" import { pb } from "@/lib/api" import { $alerts, $systems } from "@/lib/stores" import { cn, debounce } from "@/lib/utils" -import type { AlertInfo, AlertRecord, SystemRecord } from "@/types" +import type { AlertInfo, AlertRecord, AlertUnit, SystemRecord } from "@/types" const Slider = lazy(() => import("@/components/ui/slider")) @@ -27,6 +28,24 @@ const alertDebounce = 400 const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[] +/** Round to a multiple of step, trimming float noise like 0.30000000000000004 */ +const roundToStep = (val: number, step: number) => Number((Math.round(val / step) * step).toFixed(6)) + +/** Number input step and minimum for a unit, which may be finer than the slider */ +const getInputRange = ({ min, step, inputStep = step }: AlertUnit) => ({ step: inputStep, min: Math.min(min, inputStep) }) + +/** Index of the unit to display a stored value in: the largest unit whose input range and step fit it */ +function getUnitIndex(units: AlertUnit[], value: number) { + for (let i = units.length - 1; i > 0; i--) { + const { min, step } = getInputRange(units[i]) + const val = value / units[i].factor + if (val >= min && val <= units[i].max && Math.abs(roundToStep(val, step) - val) < 1e-6) { + return i + } + } + return 0 +} + const failedUpdateToast = (error: unknown) => { console.error(error) toast({ @@ -245,7 +264,17 @@ export function AlertContent({ const [checked, setChecked] = useState(global ? false : !!alert) const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10)) - const [value, setValue] = useState(alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80))) + const { units } = alertData + const storedValue = alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80)) + const [unitIndex, setUnitIndex] = useState(() => (units ? getUnitIndex(units, storedValue) : 0)) + /** Selected threshold unit, if the alert has selectable units */ + const unit = units?.[unitIndex] + const factor = unit?.factor ?? 1 + const unitLabel = unit?.unit ?? alertData.unit + const { min: valueMin, max: valueMax, step = 1 } = unit ?? alertData + const { min: inputMin, step: inputStep } = unit ? getInputRange(unit) : { min: valueMin, step } + /** Threshold value in the selected unit */ + const [value, setValue] = useState(() => Number((storedValue / factor).toFixed(6))) const Icon = alertData.icon @@ -267,12 +296,12 @@ export function AlertContent({ return systemIds } - function sendUpsert(min: number, value: number) { + function sendUpsert(min: number, value: number, valueFactor = factor) { const systems = getSystemIds() systems.length && upsertAlerts({ name: alertKey, - value, + value: Number((value * valueFactor).toFixed(6)), min, systems, }) @@ -326,7 +355,7 @@ export function AlertContent({ Average drops below{" "} {value} - {alertData.unit} + {unitLabel} ) : ( @@ -334,7 +363,7 @@ export function AlertContent({ Average exceeds{" "} {value} - {alertData.unit} + {unitLabel} )} @@ -345,9 +374,9 @@ export function AlertContent({ value={[value]} onValueCommit={(val) => sendUpsert(min, val[0])} onValueChange={(val) => setValue(val[0])} - step={alertData.step ?? 1} - min={alertData.min ?? 1} - max={alertData.max ?? 99} + step={step} + min={valueMin ?? 1} + max={valueMax ?? 99} /> { let val = parseFloat(e.target.value) if (!Number.isNaN(val)) { - if (alertData.max != null) val = Math.min(val, alertData.max) - if (alertData.min != null) val = Math.max(val, alertData.min) + // keep values on the step grid so the unit can be inferred on reload + if (unit) val = roundToStep(val, inputStep) + if (valueMax != null) val = Math.min(val, valueMax) + if (inputMin != null) val = Math.max(val, inputMin) setValue(val) sendUpsert(min, val) } }} - step={alertData.step ?? 1} - min={alertData.min ?? 1} - max={alertData.max ?? 99} + step={inputStep} + min={inputMin ?? 1} + max={valueMax ?? 99} className="w-16 h-8 text-center px-1" /> + {units && ( + + )} )} diff --git a/internal/site/src/lib/alerts.ts b/internal/site/src/lib/alerts.ts index b5f52a7dc..39c6a0f32 100644 --- a/internal/site/src/lib/alerts.ts +++ b/internal/site/src/lib/alerts.ts @@ -53,6 +53,11 @@ export const alertInfo: Record = { icon: EthernetIcon, desc: () => t`Triggers when combined up/down exceeds a threshold`, max: 250, + // 1024 to match the hub, which converts bandwidth with 1024 * 1024 + units: [ + { unit: " MB/s", factor: 1, min: 1, max: 250, step: 1, inputStep: 0.1 }, + { unit: " GB/s", factor: 1024, min: 0.1, max: 5, step: 0.1 }, + ], }, NetworkMonitorLoss: { name: () => t`Network Monitor Loss`, diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 942b9bc2b..45e3d0fab 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -459,6 +459,20 @@ export interface AlertInfo { /** Additional information that remains visible while the alert is enabled */ note?: () => string invert?: boolean + /** Selectable threshold units. Values are stored in the first unit (factor 1) */ + units?: AlertUnit[] +} + +export interface AlertUnit { + /** Unit suffix shown after the value */ + unit: string + /** Multiplier converting a value in this unit to the stored value */ + factor: number + min: number + max: number + step: number + /** Finer step for the number input, which also accepts values down to this step */ + inputStep?: number } export type AlertMap = Record>