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.
This commit is contained in:
henrygd
2026-09-30 20:24:04 -04:00
parent 65e3e2e768
commit 6ff9b4cfea
3 changed files with 90 additions and 14 deletions

View File

@@ -10,6 +10,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
@@ -17,7 +18,7 @@ import { alertInfo } from "@/lib/alerts"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
import { $alerts, $systems } from "@/lib/stores" import { $alerts, $systems } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils" 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")) const Slider = lazy(() => import("@/components/ui/slider"))
@@ -27,6 +28,24 @@ const alertDebounce = 400
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[] 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) => { const failedUpdateToast = (error: unknown) => {
console.error(error) console.error(error)
toast({ toast({
@@ -245,7 +264,17 @@ export function AlertContent({
const [checked, setChecked] = useState(global ? false : !!alert) const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10)) 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 const Icon = alertData.icon
@@ -267,12 +296,12 @@ export function AlertContent({
return systemIds return systemIds
} }
function sendUpsert(min: number, value: number) { function sendUpsert(min: number, value: number, valueFactor = factor) {
const systems = getSystemIds() const systems = getSystemIds()
systems.length && systems.length &&
upsertAlerts({ upsertAlerts({
name: alertKey, name: alertKey,
value, value: Number((value * valueFactor).toFixed(6)),
min, min,
systems, systems,
}) })
@@ -326,7 +355,7 @@ export function AlertContent({
Average drops below{" "} Average drops below{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{alertData.unit} {unitLabel}
</strong> </strong>
</Trans> </Trans>
) : ( ) : (
@@ -334,7 +363,7 @@ export function AlertContent({
Average exceeds{" "} Average exceeds{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{alertData.unit} {unitLabel}
</strong> </strong>
</Trans> </Trans>
)} )}
@@ -345,9 +374,9 @@ export function AlertContent({
value={[value]} value={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])} onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])} onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1} step={step}
min={alertData.min ?? 1} min={valueMin ?? 1}
max={alertData.max ?? 99} max={valueMax ?? 99}
/> />
<Input <Input
type="number" type="number"
@@ -355,17 +384,45 @@ export function AlertContent({
onChange={(e) => { onChange={(e) => {
let val = parseFloat(e.target.value) let val = parseFloat(e.target.value)
if (!Number.isNaN(val)) { if (!Number.isNaN(val)) {
if (alertData.max != null) val = Math.min(val, alertData.max) // keep values on the step grid so the unit can be inferred on reload
if (alertData.min != null) val = Math.max(val, alertData.min) if (unit) val = roundToStep(val, inputStep)
if (valueMax != null) val = Math.min(val, valueMax)
if (inputMin != null) val = Math.max(val, inputMin)
setValue(val) setValue(val)
sendUpsert(min, val) sendUpsert(min, val)
} }
}} }}
step={alertData.step ?? 1} step={inputStep}
min={alertData.min ?? 1} min={inputMin ?? 1}
max={alertData.max ?? 99} max={valueMax ?? 99}
className="w-16 h-8 text-center px-1" className="w-16 h-8 text-center px-1"
/> />
{units && (
<Select
value={String(unitIndex)}
onValueChange={(index) => {
const next = units[Number(index)]
const nextInput = getInputRange(next)
// convert the current threshold to the new unit, clamped to its range
const val = roundToStep((value * factor) / next.factor, nextInput.step)
const clamped = Math.min(Math.max(val, nextInput.min), next.max)
setUnitIndex(Number(index))
setValue(clamped)
sendUpsert(min, clamped, next.factor)
}}
>
<SelectTrigger className="w-auto shrink-0 h-8 gap-1.5 px-2.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map(({ unit }, i) => (
<SelectItem key={unit} value={String(i)}>
{unit.trim()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -53,6 +53,11 @@ export const alertInfo: Record<string, AlertInfo> = {
icon: EthernetIcon, icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`, desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250, 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: { NetworkMonitorLoss: {
name: () => t`Network Monitor Loss`, name: () => t`Network Monitor Loss`,

View File

@@ -459,6 +459,20 @@ export interface AlertInfo {
/** Additional information that remains visible while the alert is enabled */ /** Additional information that remains visible while the alert is enabled */
note?: () => string note?: () => string
invert?: boolean 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<string, Map<string, AlertRecord>> export type AlertMap = Record<string, Map<string, AlertRecord>>