Compare commits

..

6 Commits

Author SHA1 Message Date
henrygd
3586f73f30 check for malformed sensor names on darwin (#796) 2025-07-16 14:56:13 -04:00
henrygd
752ccc6beb hide tokens page for readonly users 2025-07-16 14:41:23 -04:00
henrygd
f577476c81 clear systems from memory on logout (#970) 2025-07-16 14:39:15 -04:00
henrygd
49ae424698 refactor: consolidate fixedFloat funcs + remove trailing zeroes from y axis 2025-07-15 21:46:41 -04:00
henrygd
d4fd19522b fix races when loading user settings 2025-07-15 21:29:51 -04:00
henrygd
5c047e4afd Refactor unit preferences and update chart components
* Refactor user settings to use enum for unit preferences (temperature,
network, disk).
* Update chart components to utilize new unit formatting functions
* Remove deprecated conversion functions and streamline unit handling
across charts.
* Enhance settings page to allow user selection of unit preferences with
updated labels.
2025-07-15 18:57:37 -04:00
18 changed files with 123 additions and 139 deletions

View File

@@ -6,8 +6,10 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"path" "path"
"runtime"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/shirou/gopsutil/v4/common" "github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors" "github.com/shirou/gopsutil/v4/sensors"
@@ -103,6 +105,11 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
systemStats.Temperatures = make(map[string]float64, len(temps)) systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps { for i, sensor := range temps {
// check for malformed strings on darwin (gopsutil/issues/1832)
if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) {
continue
}
// scale temperature // scale temperature
if sensor.Temperature != 0 && sensor.Temperature < 1 { if sensor.Temperature != 0 && sensor.Temperature < 1 {
sensor.Temperature = scaleTemperature(sensor.Temperature) sensor.Temperature = scaleTemperature(sensor.Temperature)

View File

@@ -17,9 +17,9 @@ type UserSettings struct {
ChartTime string `json:"chartTime"` ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"` NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"` NotificationWebhooks []string `json:"webhooks"`
// TemperatureUnit int `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit // UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
// NetworkUnit int `json:"unitNet"` // 0 for bytes, 1 for bits // UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
// DiskUnit int `json:"unitDisk"` // 0 for bytes, 1 for bits // UnitDisk uint8 `json:"unitDisk"` // 0 for bytes, 1 for bits
} }
func NewUserManager(app core.App) *UserManager { func NewUserManager(app core.App) *UserManager {
@@ -41,7 +41,6 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record record := e.Record
// intialize settings with defaults // intialize settings with defaults
settings := UserSettings{ settings := UserSettings{
// Language: "en",
ChartTime: "1h", ChartTime: "1h",
NotificationEmails: []string{}, NotificationEmails: []string{},
NotificationWebhooks: []string{}, NotificationWebhooks: []string{},

View File

@@ -1,22 +1,13 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
import { import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
useYAxisWidth,
cn,
formatShortDate,
chartMargin,
toFixedWithoutTrailingZeros,
formatBytes,
decimalString,
toFixedFloat,
} from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $containerFilter, $userSettings } from "@/lib/stores" import { $containerFilter, $userSettings } from "@/lib/stores"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
import { ChartType, DataUnit } from "@/lib/enums" import { ChartType, Unit } from "@/lib/enums"
export default memo(function ContainerChart({ export default memo(function ContainerChart({
dataKey, dataKey,
@@ -85,11 +76,11 @@ export default memo(function ContainerChart({
// tick formatter // tick formatter
if (chartType === ChartType.CPU) { if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => { obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit const val = toFixedFloat(value, 2) + unit
return updateYAxisWidth(val) return updateYAxisWidth(val)
} }
} else { } else {
const chartUnit = isNetChart ? userSettings.unitNet : DataUnit.Bytes const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => { obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true) const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit) return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
@@ -118,7 +109,7 @@ export default memo(function ContainerChart({
} }
} else if (chartType === ChartType.Memory) { } else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => { obj.toolTipFormatter = (item: any) => {
const { value, unit } = formatBytes(item.value, false, DataUnit.Bytes, true) const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return decimalString(value) + " " + unit return decimalString(value) + " " + unit
} }
} else { } else {

View File

@@ -1,10 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, formatShortDate, decimalString, toFixedFloat, chartMargin, formatBytes } from "@/lib/utils" import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo } from "react" import { memo } from "react"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { DataUnit } from "@/lib/enums" import { Unit } from "@/lib/enums"
export default memo(function DiskChart({ export default memo(function DiskChart({
dataKey, dataKey,
@@ -47,7 +47,7 @@ export default memo(function DiskChart({
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, DataUnit.Bytes, true) const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit) return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}} }}
/> />
@@ -59,7 +59,7 @@ export default memo(function DiskChart({
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, DataUnit.Bytes, true) const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit return decimalString(convertedValue) + " " + unit
}} }}
/> />

View File

@@ -8,14 +8,7 @@ import {
ChartTooltipContent, ChartTooltipContent,
xAxis, xAxis,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
@@ -72,7 +65,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
domain={[0, "auto"]} domain={[0, "auto"]}
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(value) => { tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) const val = toFixedFloat(value, 2)
return updateYAxisWidth(val + "W") return updateYAxisWidth(val + "W")
}} }}
tickLine={false} tickLine={false}

View File

@@ -1,10 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin, formatBytes } from "@/lib/utils" import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { memo } from "react" import { memo } from "react"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { DataUnit } from "@/lib/enums" import { Unit } from "@/lib/enums"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) { export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
@@ -40,7 +40,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => { tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, DataUnit.Bytes, true) const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit) return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}} }}
/> />
@@ -57,7 +57,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
// mem values are supplied as GB // mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, DataUnit.Bytes, true) const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}} }}
/> />

View File

@@ -1,20 +1,16 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo } from "react" import { memo } from "react"
import { $userSettings } from "@/lib/stores"
import { useStore } from "@nanostores/react"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const userSettings = useStore($userSettings)
if (chartData.systemStats.length === 0) { if (chartData.systemStats.length === 0) {
return null return null
@@ -33,11 +29,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
direction="ltr" direction="ltr"
orientation={chartData.orientation} orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]} domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + " GB")} tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/> />
{xAxis(chartData)} {xAxis(chartData)}
<ChartTooltip <ChartTooltip
@@ -46,7 +45,11 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"} contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
// indicator="line" // indicator="line"
/> />
} }

View File

@@ -12,9 +12,9 @@ import {
useYAxisWidth, useYAxisWidth,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedFloat,
chartMargin, chartMargin,
convertTemperature, formatTemperature,
decimalString, decimalString,
} from "@/lib/utils" } from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
@@ -75,8 +75,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
domain={[0, "auto"]} domain={[0, "auto"]}
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = convertTemperature(val, userSettings.unitTemp) const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return updateYAxisWidth(toFixedWithoutTrailingZeros(value, 2) + " " + unit) return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
}} }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
@@ -91,7 +91,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => { contentFormatter={(item) => {
const { value, unit } = convertTemperature(item.value, userSettings.unitTemp) const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return decimalString(value) + " " + unit return decimalString(value) + " " + unit
}} }}
filter={filter} filter={filter}

View File

@@ -11,7 +11,7 @@ import { useState } from "react"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { DataUnit, TemperatureUnit } from "@/lib/enums" import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -118,33 +118,41 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Select <Select
name="unitTemp" name="unitTemp"
key={userSettings.unitTemp} key={userSettings.unitTemp}
defaultValue={userSettings.unitTemp?.toString() || String(TemperatureUnit.Celsius)} defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
> >
<SelectTrigger id="unitTemp"> <SelectTrigger id="unitTemp">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={String(TemperatureUnit.Celsius)}>Celsius (°C)</SelectItem> <SelectItem value={String(Unit.Celsius)}>
<SelectItem value={String(TemperatureUnit.Fahrenheit)}>Fahrenheit (°F)</SelectItem> <Trans>Celsius (°C)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Fahrenheit)}>
<Trans>Fahrenheit (°F)</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="block" htmlFor="unitTemp"> <Label className="block" htmlFor="unitNet">
<Trans>Network unit</Trans> <Trans>Network unit</Trans>
</Label> </Label>
<Select <Select
name="unitNet" name="unitNet"
key={userSettings.unitNet} key={userSettings.unitNet}
defaultValue={userSettings.unitNet?.toString() ?? String(DataUnit.Bytes)} defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
> >
<SelectTrigger id="unitTemp"> <SelectTrigger id="unitNet">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={String(DataUnit.Bytes)}>Bytes (KB/s, MB/s, GB/s)</SelectItem> <SelectItem value={String(Unit.Bytes)}>
<SelectItem value={String(DataUnit.Bits)}>Bits (kbps, Mbps, Gbps)</SelectItem> <Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -156,14 +164,18 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Select <Select
name="unitDisk" name="unitDisk"
key={userSettings.unitDisk} key={userSettings.unitDisk}
defaultValue={userSettings.unitDisk?.toString() ?? String(DataUnit.Bytes)} defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
> >
<SelectTrigger id="unitDisk"> <SelectTrigger id="unitDisk">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={String(DataUnit.Bytes)}>Bytes (KB/s, MB/s, GB/s)</SelectItem> <SelectItem value={String(Unit.Bytes)}>
<SelectItem value={String(DataUnit.Bits)}>Bits (kbps, Mbps, Gbps)</SelectItem> <Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -63,7 +63,7 @@ export default function SettingsLayout() {
title: t`Tokens & Fingerprints`, title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }), href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon, icon: FingerprintIcon,
// admin: true, noReadOnly: true,
}, },
{ {
title: t`YAML Config`, title: t`YAML Config`,

View File

@@ -1,5 +1,5 @@
import React from "react" import React from "react"
import { cn, isAdmin } from "@/lib/utils" import { cn, isAdmin, isReadOnlyUser } from "@/lib/utils"
import { buttonVariants } from "../../ui/button" import { buttonVariants } from "../../ui/button"
import { $router, Link, navigate } from "../../router" import { $router, Link, navigate } from "../../router"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -12,6 +12,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
title: string title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>> icon?: React.FC<React.SVGProps<SVGSVGElement>>
admin?: boolean admin?: boolean
noReadOnly?: boolean
}[] }[]
} }
@@ -46,7 +47,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
{/* Desktop View */} {/* Desktop View */}
<nav className={cn("hidden md:grid gap-1", className)} {...props}> <nav className={cn("hidden md:grid gap-1", className)} {...props}>
{items.map((item) => { {items.map((item) => {
if (item.admin && !isAdmin()) { if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
return null return null
} }
return ( return (

View File

@@ -34,6 +34,8 @@ import {
InstallDropdown, InstallDropdown,
} from "@/components/install-dropdowns" } from "@/components/install-dropdowns"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons" import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { redirectPage } from "@nanostores/router"
import { $router } from "@/components/router"
const pbFingerprintOptions = { const pbFingerprintOptions = {
expand: "system", expand: "system",
@@ -41,6 +43,9 @@ const pbFingerprintOptions = {
} }
const SettingsFingerprintsPage = memo(() => { const SettingsFingerprintsPage = memo(() => {
if (isReadOnlyUser()) {
redirectPage($router, "settings", { name: "general" })
}
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([]) const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
// Get fingerprint records on mount // Get fingerprint records on mount

View File

@@ -11,7 +11,7 @@ import {
$temperatureFilter, $temperatureFilter,
} from "@/lib/stores" } from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import { ChartType, DataUnit, Os } from "@/lib/enums" import { ChartType, Unit, Os } from "@/lib/enums"
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card" import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -27,7 +27,6 @@ import {
getPbTimestamp, getPbTimestamp,
listen, listen,
toFixedFloat, toFixedFloat,
toFixedWithoutTrailingZeros,
useLocalStorage, useLocalStorage,
} from "@/lib/utils" } from "@/lib/utils"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
@@ -479,7 +478,7 @@ export default function SystemDetail({ name }: { name: string }) {
chartData={chartData} chartData={chartData}
chartName="CPU Usage" chartName="CPU Usage"
maxToggled={maxValues} maxToggled={maxValues}
tickFormatter={(val) => toFixedWithoutTrailingZeros(val, 2) + "%"} tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"} contentFormatter={({ value }) => decimalString(value) + "%"}
/> />
</ChartCard> </ChartCard>
@@ -556,7 +555,6 @@ export default function SystemDetail({ name }: { name: string }) {
maxToggled={maxValues} maxToggled={maxValues}
tickFormatter={(val) => { tickFormatter={(val) => {
let { value, unit } = formatBytes(val, true, userSettings.unitNet, true) let { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
// value = value >= 10 ? Math.ceil(value) : value
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}} }}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
@@ -628,10 +626,6 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="grid xl:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => { {Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
// const sizeFormatter = (value: number, decimals?: number) => {
// const { v, u } = getSizeAndUnit(value, false)
// return toFixedFloat(v, decimals || 1) + u
// }
return ( return (
<div key={id} className="contents"> <div key={id} className="contents">
<ChartCard <ChartCard
@@ -643,7 +637,7 @@ export default function SystemDetail({ name }: { name: string }) {
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
chartName={`g.${id}.u`} chartName={`g.${id}.u`}
tickFormatter={(val) => toFixedWithoutTrailingZeros(val, 2) + "%"} tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"} contentFormatter={({ value }) => decimalString(value) + "%"}
/> />
</ChartCard> </ChartCard>
@@ -658,11 +652,11 @@ export default function SystemDetail({ name }: { name: string }) {
chartName={`g.${id}.mu`} chartName={`g.${id}.mu`}
max={gpu.mt} max={gpu.mt}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, DataUnit.Bytes, true) const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}} }}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, DataUnit.Bytes, true) const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit return decimalString(convertedValue) + " " + unit
}} }}
/> />

View File

@@ -70,7 +70,7 @@ import {
copyToClipboard, copyToClipboard,
isReadOnlyUser, isReadOnlyUser,
useLocalStorage, useLocalStorage,
convertTemperature, formatTemperature,
decimalString, decimalString,
formatBytes, formatBytes,
} from "@/lib/utils" } from "@/lib/utils"
@@ -135,7 +135,6 @@ export default function SystemsTable() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {}) const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid") const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
const userSettings = useStore($userSettings)
const locale = i18n.locale const locale = i18n.locale
@@ -230,6 +229,7 @@ export default function SystemsTable() {
Icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true) const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true)
return ( return (
<span className="tabular-nums whitespace-nowrap"> <span className="tabular-nums whitespace-nowrap">
@@ -291,7 +291,8 @@ export default function SystemsTable() {
if (!val) { if (!val) {
return null return null
} }
const { value, unit } = convertTemperature(val, userSettings.unitTemp) const userSettings = useStore($userSettings)
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return ( return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}> <span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
{decimalString(value, value >= 100 ? 1 : 2)} {unit} {decimalString(value, value >= 100 ? 1 : 2)} {unit}

View File

@@ -1,3 +1,4 @@
/** Operating system */
export enum Os { export enum Os {
Linux = 0, Linux = 0,
Darwin, Darwin,
@@ -5,6 +6,7 @@ export enum Os {
FreeBSD, FreeBSD,
} }
/** Type of chart */
export enum ChartType { export enum ChartType {
Memory, Memory,
Disk, Disk,
@@ -12,12 +14,10 @@ export enum ChartType {
CPU, CPU,
} }
export enum DataUnit { /** Unit of measurement */
export enum Unit {
Bytes, Bytes,
Bits, Bits,
}
export enum TemperatureUnit {
Celsius, Celsius,
Fahrenheit, Fahrenheit,
} }

View File

@@ -28,9 +28,9 @@ export const $maxValues = atom(false)
export const $userSettings = map<UserSettings>({ export const $userSettings = map<UserSettings>({
chartTime: "1h", chartTime: "1h",
emails: [pb.authStore.record?.email || ""], emails: [pb.authStore.record?.email || ""],
temperatureUnit: "celsius", // unitTemp: "celsius",
networkUnit: "mbps", // unitNet: "mbps",
diskUnit: "mbps", // unitDisk: "mbps",
}) })
// update local storage on change // update local storage on change
$userSettings.subscribe((value) => { $userSettings.subscribe((value) => {

View File

@@ -10,8 +10,7 @@ import {
ChartTimes, ChartTimes,
FingerprintRecord, FingerprintRecord,
SystemRecord, SystemRecord,
TemperatureConversion, UserSettings,
DataUnitConversion,
} from "@/types" } from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase" import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores" import { WritableAtom } from "nanostores"
@@ -20,7 +19,7 @@ import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons" import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
import { prependBasePath } from "@/components/router" import { prependBasePath } from "@/components/router"
import { DataUnit, TemperatureUnit } from "./enums" import { Unit } from "./enums"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -83,7 +82,10 @@ export const updateSystemList = (() => {
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */ /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() { export async function logOut() {
sessionStorage.setItem("lo", "t") $systems.set([])
$alerts.set([])
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear() pb.authStore.clear()
pb.realtime.unsubscribe() pb.realtime.unsubscribe()
} }
@@ -235,17 +237,17 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth } return { yAxisWidth, updateYAxisWidth }
} }
export function toFixedWithoutTrailingZeros(num: number, digits: number) { /** Format number to x decimal places, without trailing zeros */
return parseFloat(num.toFixed(digits)).toString()
}
export function toFixedFloat(num: number, digits: number) { export function toFixedFloat(num: number, digits: number) {
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits)) return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
} }
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map() let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places */ /** Format number to x decimal places, maintaining trailing zeros */
export function decimalString(num: number, digits = 2) { export function decimalString(num: number, digits = 2) {
if (digits === 0) {
return Math.ceil(num).toString()
}
let formatter = decimalFormatters.get(digits) let formatter = decimalFormatters.get(digits)
if (!formatter) { if (!formatter) {
formatter = new Intl.NumberFormat(undefined, { formatter = new Intl.NumberFormat(undefined, {
@@ -276,11 +278,13 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
return [value, setValue] return [value, setValue]
} }
export function convertTemperature(celsius: number, unit = TemperatureUnit.Celsius): TemperatureConversion { /** Format temperature to user's preferred unit */
const userSettings = $userSettings.get() export function formatTemperature(celsius: number, unit?: Unit): { value: number; unit: string } {
unit ||= userSettings.unitTemp || TemperatureUnit.Celsius if (!unit) {
unit = $userSettings.get().unitTemp || Unit.Celsius
}
// need loose equality check due to form data being strings // need loose equality check due to form data being strings
if (unit == TemperatureUnit.Fahrenheit) { if (unit == Unit.Fahrenheit) {
return { return {
value: celsius * 1.8 + 32, value: celsius * 1.8 + 32,
unit: "°F", unit: "°F",
@@ -292,17 +296,18 @@ export function convertTemperature(celsius: number, unit = TemperatureUnit.Celsi
} }
} }
/** Format bytes to user's preferred unit */
export function formatBytes( export function formatBytes(
size: number, size: number,
perSecond = false, perSecond = false,
unit = DataUnit.Bytes, unit = Unit.Bytes,
isMegabytes = false isMegabytes = false
): DataUnitConversion { ): { value: number; unit: string } {
// Convert MB to bytes if isMegabytes is true // Convert MB to bytes if isMegabytes is true
if (isMegabytes) size *= 1024 * 1024 if (isMegabytes) size *= 1024 * 1024
// need loose equality check due to form data being strings // need loose equality check due to form data being strings
if (unit == DataUnit.Bits) { if (unit == Unit.Bits) {
const bits = size * 8 const bits = size * 8
const suffix = perSecond ? "ps" : "" const suffix = perSecond ? "ps" : ""
if (bits < 1000) return { value: bits, unit: `b${suffix}` } if (bits < 1000) return { value: bits, unit: `b${suffix}` }
@@ -322,7 +327,7 @@ export function formatBytes(
unit: `Tb${suffix}`, unit: `Tb${suffix}`,
} }
} }
// bytes
const suffix = perSecond ? "/s" : "" const suffix = perSecond ? "/s" : ""
if (size < 100) return { value: size, unit: `B${suffix}` } if (size < 100) return { value: size, unit: `B${suffix}` }
if (size < 1000 * 1024) return { value: size / 1024, unit: `KB${suffix}` } if (size < 1000 * 1024) return { value: size / 1024, unit: `KB${suffix}` }
@@ -342,40 +347,24 @@ export function formatBytes(
} }
} }
/** Fetch or create user settings in database */
export async function updateUserSettings() { export async function updateUserSettings() {
try { try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" }) const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings) $userSettings.set(req.settings)
return return
} catch (e) { } catch (e) {
console.log("get settings", e) console.error("get settings", e)
} }
// create user settings if error fetching existing // create user settings if error fetching existing
try { try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id }) const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings) $userSettings.set(createdSettings.settings)
} catch (e) { } catch (e) {
console.log("create settings", e) console.error("create settings", e)
} }
} }
/**
* Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes or megabytes
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) {
return { v: sizeInGB / 1_000, u: " TB" }
} else if (sizeInGB >= 1) {
return { v: sizeInGB, u: " GB" }
}
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
}
export const chartMargin = { top: 12 } export const chartMargin = { top: 12 }
export const alertInfo: Record<string, AlertInfo> = { export const alertInfo: Record<string, AlertInfo> = {

View File

@@ -1,5 +1,5 @@
import { RecordModel } from "pocketbase" import { RecordModel } from "pocketbase"
import { DataUnit, Os, TemperatureUnit } from "./lib/enums" import { Unit, Os } from "./lib/enums"
// global window properties // global window properties
declare global { declare global {
@@ -22,17 +22,6 @@ export interface FingerprintRecord extends RecordModel {
} }
} }
// Unit conversion result types
export interface TemperatureConversion {
value: number
unit: string
}
export interface DataUnitConversion {
value: number
unit: string
}
export interface SystemRecord extends RecordModel { export interface SystemRecord extends RecordModel {
name: string name: string
host: string host: string
@@ -216,9 +205,9 @@ export type UserSettings = {
chartTime: ChartTimes chartTime: ChartTimes
emails?: string[] emails?: string[]
webhooks?: string[] webhooks?: string[]
unitTemp?: TemperatureUnit unitTemp?: Unit
unitNet?: DataUnit unitNet?: Unit
unitDisk?: DataUnit unitDisk?: Unit
} }
type ChartDataContainer = { type ChartDataContainer = {