mirror of
https://github.com/fankes/beszel.git
synced 2025-10-18 17:29:28 +08:00
Adds display unit preference (#938)
* Adds temperature unit preference * add unit preferences for networking * adds options for MB/s and bps. * supports disk throughput unit preferences
This commit is contained in:
@@ -17,6 +17,9 @@ type UserSettings struct {
|
||||
ChartTime string `json:"chartTime"`
|
||||
NotificationEmails []string `json:"emails"`
|
||||
NotificationWebhooks []string `json:"webhooks"`
|
||||
TemperatureUnit string `json:"temperatureUnit"` // "celsius" or "fahrenheit"
|
||||
NetworkUnit string `json:"networkUnit"` // "mbps" (MB/s) or "bps"
|
||||
DiskUnit string `json:"diskUnit"` // "mbps" (MB/s) or "bps"
|
||||
// Language string `json:"lang"`
|
||||
}
|
||||
|
||||
@@ -43,6 +46,9 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||
ChartTime: "1h",
|
||||
NotificationEmails: []string{},
|
||||
NotificationWebhooks: []string{},
|
||||
TemperatureUnit: "celsius",
|
||||
NetworkUnit: "mbps",
|
||||
DiskUnit: "mbps",
|
||||
}
|
||||
record.UnmarshalJSONField("settings", &settings)
|
||||
if len(settings.NotificationEmails) == 0 {
|
||||
|
@@ -9,11 +9,15 @@ import {
|
||||
toFixedWithoutTrailingZeros,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
convertNetworkSpeed,
|
||||
convertDiskSpeed,
|
||||
} from "@/lib/utils"
|
||||
// import Spinner from '../spinner'
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
|
||||
/** [label, key, color, opacity] */
|
||||
type DataKeys = [string, string, number, number]
|
||||
@@ -47,11 +51,26 @@ export default memo(function AreaChartDefault({
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { i18n } = useLingui()
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
const { chartTime } = chartData
|
||||
|
||||
const showMax = chartTime !== "1h" && maxToggled
|
||||
|
||||
// Determine if this is a network chart or disk chart and adjust unit accordingly
|
||||
const isNetworkChart = chartName === "bw"
|
||||
const isDiskChart = chartName === "dio" || chartName.startsWith("efs")
|
||||
const displayUnit = useMemo(() => {
|
||||
if (isNetworkChart) {
|
||||
const { symbol } = convertNetworkSpeed(1, userSettings.networkUnit)
|
||||
return symbol
|
||||
} else if (isDiskChart) {
|
||||
const { symbol } = convertDiskSpeed(1, userSettings.diskUnit)
|
||||
return symbol
|
||||
}
|
||||
return unit
|
||||
}, [isNetworkChart, isDiskChart, userSettings.networkUnit, userSettings.diskUnit, unit])
|
||||
|
||||
const dataKeys: DataKeys[] = useMemo(() => {
|
||||
// [label, key, color, opacity]
|
||||
if (chartName === "CPU Usage") {
|
||||
@@ -102,8 +121,14 @@ export default memo(function AreaChartDefault({
|
||||
let val: string
|
||||
if (tickFormatter) {
|
||||
val = tickFormatter(value)
|
||||
} else if (isNetworkChart) {
|
||||
const { value: convertedValue, symbol } = convertNetworkSpeed(value, userSettings.networkUnit)
|
||||
val = toFixedWithoutTrailingZeros(convertedValue, 2) + symbol
|
||||
} else if (isDiskChart) {
|
||||
const { value: convertedValue, symbol } = convertDiskSpeed(value, userSettings.diskUnit)
|
||||
val = toFixedWithoutTrailingZeros(convertedValue, 2) + symbol
|
||||
} else {
|
||||
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
val = toFixedWithoutTrailingZeros(value, 2) + displayUnit
|
||||
}
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
@@ -120,8 +145,14 @@ export default memo(function AreaChartDefault({
|
||||
contentFormatter={({ value }) => {
|
||||
if (contentFormatter) {
|
||||
return contentFormatter(value)
|
||||
} else if (isNetworkChart) {
|
||||
const { display } = convertNetworkSpeed(value, userSettings.networkUnit)
|
||||
return display
|
||||
} else if (isDiskChart) {
|
||||
const { display } = convertDiskSpeed(value, userSettings.diskUnit)
|
||||
return display
|
||||
}
|
||||
return decimalString(value) + unit
|
||||
return decimalString(value) + displayUnit
|
||||
}}
|
||||
// indicator="line"
|
||||
/>
|
||||
|
@@ -10,10 +10,11 @@ import {
|
||||
toFixedFloat,
|
||||
getSizeAndUnit,
|
||||
toFixedWithoutTrailingZeros,
|
||||
convertNetworkSpeed,
|
||||
} from "@/lib/utils"
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $containerFilter } from "@/lib/stores"
|
||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||
import { ChartData } from "@/types"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { ChartType } from "@/lib/enums"
|
||||
@@ -30,6 +31,7 @@ export default memo(function ContainerChart({
|
||||
unit?: string
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const { containerData } = chartData
|
||||
@@ -87,10 +89,15 @@ export default memo(function ContainerChart({
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
return updateYAxisWidth(val)
|
||||
}
|
||||
} else if (isNetChart) {
|
||||
obj.tickFormatter = (value) => {
|
||||
const { value: convertedValue, symbol } = convertNetworkSpeed(value, userSettings.networkUnit)
|
||||
return updateYAxisWidth(`${toFixedFloat(convertedValue, 2)}${symbol}`)
|
||||
}
|
||||
} else {
|
||||
obj.tickFormatter = (value) => {
|
||||
const { v, u } = getSizeAndUnit(value, false)
|
||||
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
|
||||
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}`)
|
||||
}
|
||||
}
|
||||
// tooltip formatter
|
||||
@@ -99,12 +106,14 @@ export default memo(function ContainerChart({
|
||||
try {
|
||||
const sent = item?.payload?.[key]?.ns ?? 0
|
||||
const received = item?.payload?.[key]?.nr ?? 0
|
||||
const { display: receivedDisplay } = convertNetworkSpeed(received, userSettings.networkUnit)
|
||||
const { display: sentDisplay } = convertNetworkSpeed(sent, userSettings.networkUnit)
|
||||
return (
|
||||
<span className="flex">
|
||||
{decimalString(received)} MB/s
|
||||
{receivedDisplay}
|
||||
<span className="opacity-70 ms-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{decimalString(sent)} MB/s
|
||||
{sentDisplay}
|
||||
<span className="opacity-70 ms-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
|
@@ -15,14 +15,16 @@ import {
|
||||
toFixedWithoutTrailingZeros,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
convertTemperature,
|
||||
} from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { $temperatureFilter } from "@/lib/stores"
|
||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||
const filter = useStore($temperatureFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
if (chartData.systemStats.length === 0) {
|
||||
@@ -36,13 +38,17 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
colors: Record<string, string>
|
||||
}
|
||||
const tempSums = {} as Record<string, number>
|
||||
const unit = userSettings.temperatureUnit || "celsius"
|
||||
|
||||
for (let data of chartData.systemStats) {
|
||||
let newData = { created: data.created } as Record<string, number | string>
|
||||
let keys = Object.keys(data.stats?.t ?? {})
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let key = keys[i]
|
||||
newData[key] = data.stats.t![key]
|
||||
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||
const celsiusTemp = data.stats.t![key]
|
||||
const { value } = convertTemperature(celsiusTemp, unit)
|
||||
newData[key] = value
|
||||
tempSums[key] = (tempSums[key] ?? 0) + value
|
||||
}
|
||||
newChartData.data.push(newData)
|
||||
}
|
||||
@@ -51,7 +57,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||
}
|
||||
return newChartData
|
||||
}, [chartData])
|
||||
}, [chartData, userSettings.temperatureUnit])
|
||||
|
||||
const colors = Object.keys(newChartData.colors)
|
||||
|
||||
@@ -74,7 +80,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||
return updateYAxisWidth(val + " °C")
|
||||
const { symbol } = convertTemperature(0, userSettings.temperatureUnit || "celsius")
|
||||
return updateYAxisWidth(val + " " + symbol)
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -88,7 +95,10 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
||||
contentFormatter={(item) => {
|
||||
const { symbol } = convertTemperature(0, userSettings.temperatureUnit || "celsius")
|
||||
return decimalString(item.value) + " " + symbol
|
||||
}}
|
||||
filter={filter}
|
||||
/>
|
||||
}
|
||||
|
@@ -101,6 +101,63 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Unit preferences</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Adjust Display units for metrics.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="temperatureUnit">
|
||||
<Trans>Temperature unit</Trans>
|
||||
</Label>
|
||||
<Select name="temperatureUnit" key={userSettings.temperatureUnit} defaultValue={userSettings.temperatureUnit || "celsius"}>
|
||||
<SelectTrigger id="temperatureUnit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="celsius">Celsius (°C)</SelectItem>
|
||||
<SelectItem value="fahrenheit">Fahrenheit (°F)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="networkUnit">
|
||||
<Trans>Network unit</Trans>
|
||||
</Label>
|
||||
<Select name="networkUnit" key={userSettings.networkUnit} defaultValue={userSettings.networkUnit || "mbps"}>
|
||||
<SelectTrigger id="networkUnit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mbps">MB/s (Megabytes per second)</SelectItem>
|
||||
<SelectItem value="bps">bps (Bits per second)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="diskUnit">
|
||||
<Trans>Disk unit</Trans>
|
||||
</Label>
|
||||
<Select name="diskUnit" key={userSettings.diskUnit} defaultValue={userSettings.diskUnit || "mbps"}>
|
||||
<SelectTrigger id="diskUnit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mbps">MB/s (Megabytes per second)</SelectItem>
|
||||
<SelectItem value="bps">bps (Bits per second)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||
<Trans>Save Settings</Trans>
|
||||
|
@@ -63,9 +63,17 @@ import {
|
||||
PenBoxIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { $systems, pb } from "@/lib/stores"
|
||||
import { $systems, $userSettings, pb } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||
import {
|
||||
cn,
|
||||
copyToClipboard,
|
||||
decimalString,
|
||||
isReadOnlyUser,
|
||||
useLocalStorage,
|
||||
convertTemperature,
|
||||
convertNetworkSpeed,
|
||||
} from "@/lib/utils"
|
||||
import AlertsButton from "../alerts/alert-button"
|
||||
import { $router, Link, navigate } from "../router"
|
||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||
@@ -222,7 +230,9 @@ export default function SystemsTable() {
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const val = info.getValue() as number
|
||||
return <span className="tabular-nums whitespace-nowrap">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
||||
const userSettings = useStore($userSettings)
|
||||
const { display } = convertNetworkSpeed(val, userSettings.networkUnit)
|
||||
return <span className="tabular-nums whitespace-nowrap">{display}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -278,9 +288,11 @@ export default function SystemsTable() {
|
||||
if (!val) {
|
||||
return null
|
||||
}
|
||||
const userSettings = useStore($userSettings)
|
||||
const { value, symbol } = convertTemperature(val, userSettings.temperatureUnit)
|
||||
return (
|
||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||
{decimalString(val)} °C
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {symbol}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -753,4 +765,4 @@ function IndicatorDot({ system, className }: { system: SystemRecord; className?:
|
||||
// style={{ marginBottom: "-1px" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
@@ -28,6 +28,9 @@ export const $maxValues = atom(false)
|
||||
export const $userSettings = map<UserSettings>({
|
||||
chartTime: "1h",
|
||||
emails: [pb.authStore.record?.email || ""],
|
||||
temperatureUnit: "celsius",
|
||||
networkUnit: "mbps",
|
||||
diskUnit: "mbps",
|
||||
})
|
||||
// update local storage on change
|
||||
$userSettings.subscribe((value) => {
|
||||
|
@@ -3,7 +3,7 @@ import { toast } from "@/components/ui/use-toast"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
|
||||
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
|
||||
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord, TemperatureUnit, TemperatureConversion, SpeedUnit, SpeedConversion } from "@/types"
|
||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
||||
import { WritableAtom } from "nanostores"
|
||||
import { timeDay, timeHour } from "d3-time"
|
||||
@@ -266,6 +266,109 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
/** Convert temperature from Celsius to the specified unit */
|
||||
export function convertTemperature(
|
||||
celsius: number,
|
||||
unit: TemperatureUnit = "celsius"
|
||||
): TemperatureConversion {
|
||||
switch (unit) {
|
||||
case "fahrenheit":
|
||||
return { value: (celsius * 9) / 5 + 32, symbol: "°F" }
|
||||
default:
|
||||
return { value: celsius, symbol: "°C" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert network speed from MB/s to the specified unit */
|
||||
export function convertNetworkSpeed(
|
||||
mbps: number,
|
||||
unit: SpeedUnit = "mbps"
|
||||
): SpeedConversion {
|
||||
switch (unit) {
|
||||
case "bps": {
|
||||
const bps = mbps * 8 * 1_000_000 // Convert MB/s to bits per second
|
||||
|
||||
// Format large numbers appropriately
|
||||
if (bps >= 1_000_000_000) {
|
||||
return {
|
||||
value: bps / 1_000_000_000,
|
||||
symbol: " Gbps",
|
||||
display: `${decimalString(bps / 1_000_000_000, bps >= 10_000_000_000 ? 0 : 1)} Gbps`,
|
||||
}
|
||||
} else if (bps >= 1_000_000) {
|
||||
return {
|
||||
value: bps / 1_000_000,
|
||||
symbol: " Mbps",
|
||||
display: `${decimalString(bps / 1_000_000, bps >= 10_000_000 ? 0 : 1)} Mbps`,
|
||||
}
|
||||
} else if (bps >= 1_000) {
|
||||
return {
|
||||
value: bps / 1_000,
|
||||
symbol: " Kbps",
|
||||
display: `${decimalString(bps / 1_000, bps >= 10_000 ? 0 : 1)} Kbps`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
value: bps,
|
||||
symbol: " bps",
|
||||
display: `${Math.round(bps)} bps`,
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
value: mbps,
|
||||
symbol: " MB/s",
|
||||
display: `${decimalString(mbps, mbps >= 100 ? 1 : 2)} MB/s`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert disk speed from MB/s to the specified unit */
|
||||
export function convertDiskSpeed(
|
||||
mbps: number,
|
||||
unit: SpeedUnit = "mbps"
|
||||
): SpeedConversion {
|
||||
switch (unit) {
|
||||
case "bps": {
|
||||
const bps = mbps * 8 * 1_000_000 // Convert MB/s to bits per second
|
||||
|
||||
// Format large numbers appropriately
|
||||
if (bps >= 1_000_000_000) {
|
||||
return {
|
||||
value: bps / 1_000_000_000,
|
||||
symbol: " Gbps",
|
||||
display: `${decimalString(bps / 1_000_000_000, bps >= 10_000_000_000 ? 0 : 1)} Gbps`,
|
||||
}
|
||||
} else if (bps >= 1_000_000) {
|
||||
return {
|
||||
value: bps / 1_000_000,
|
||||
symbol: " Mbps",
|
||||
display: `${decimalString(bps / 1_000_000, bps >= 10_000_000 ? 0 : 1)} Mbps`,
|
||||
}
|
||||
} else if (bps >= 1_000) {
|
||||
return {
|
||||
value: bps / 1_000,
|
||||
symbol: " Kbps",
|
||||
display: `${decimalString(bps / 1_000, bps >= 10_000 ? 0 : 1)} Kbps`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
value: bps,
|
||||
symbol: " bps",
|
||||
display: `${Math.round(bps)} bps`,
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
value: mbps,
|
||||
symbol: " MB/s",
|
||||
display: `${decimalString(mbps, mbps >= 100 ? 1 : 2)} MB/s`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserSettings() {
|
||||
try {
|
||||
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
|
||||
|
19
beszel/site/src/types.d.ts
vendored
19
beszel/site/src/types.d.ts
vendored
@@ -22,6 +22,22 @@ export interface FingerprintRecord extends RecordModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Unit preference types
|
||||
export type TemperatureUnit = "celsius" | "fahrenheit"
|
||||
export type SpeedUnit = "mbps" | "bps"
|
||||
|
||||
// Unit conversion result types
|
||||
export interface TemperatureConversion {
|
||||
value: number
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export interface SpeedConversion {
|
||||
value: number
|
||||
symbol: string
|
||||
display: string
|
||||
}
|
||||
|
||||
export interface SystemRecord extends RecordModel {
|
||||
name: string
|
||||
host: string
|
||||
@@ -205,6 +221,9 @@ export type UserSettings = {
|
||||
chartTime: ChartTimes
|
||||
emails?: string[]
|
||||
webhooks?: string[]
|
||||
temperatureUnit?: TemperatureUnit
|
||||
networkUnit?: SpeedUnit
|
||||
diskUnit?: SpeedUnit
|
||||
}
|
||||
|
||||
type ChartDataContainer = {
|
||||
|
Reference in New Issue
Block a user