Compare commits

..

9 Commits

Author SHA1 Message Date
Sven van Ginkel
9d7fb8ab80 [Feature] Add Alerts History page (#973)
* Add alert history

* refactor

* fix one colunm

* update migration

* add retention
2025-07-20 19:20:51 -04:00
henrygd
3730a78e5a update load avg display and include it in longer records 2025-07-16 21:24:42 -04:00
Sven van Ginkel
7cdd0907e8 [Feature][0.12.0-Beta] Enhance Load Average Display, Charting & Alert Grouping (#960)
* Add 1m load

* update alart dialog

* fix null data

* Remove omit zero

* change table and alert view
2025-07-16 16:03:26 -04:00
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
34 changed files with 1114 additions and 400 deletions

View File

@@ -56,7 +56,7 @@ dev-hub: export ENV=dev
dev-hub:
mkdir -p ./site/dist && touch ./site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/hub ./internal/{alerts,hub,records,users} -name "*.go" | entr -r -s "cd ./cmd/hub && go run . serve"; \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
else \
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
fi

View File

@@ -6,8 +6,10 @@ import (
"fmt"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"github.com/shirou/gopsutil/v4/common"
"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))
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
if sensor.Temperature != 0 && sensor.Temperature < 1 {
sensor.Temperature = scaleTemperature(sensor.Temperature)

View File

@@ -251,6 +251,7 @@ func (a *Agent) getSystemStats() system.Stats {
// update base system info
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg1 = systemStats.LoadAvg1
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
a.systemInfo.MemPct = systemStats.MemPct

View File

@@ -47,6 +47,7 @@ type SystemAlertStats struct {
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
LoadAvg1 float64 `json:"l1"`
LoadAvg5 float64 `json:"l5"`
LoadAvg15 float64 `json:"l15"`
}

View File

@@ -0,0 +1,96 @@
package alerts
import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func (am *AlertManager) RecordAlertHistory(alert SystemAlertData) {
// Get alert, user, system, name, value
alertId := alert.alertRecord.Id
userId := ""
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) == 0 {
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
userId = user.Id
}
}
systemId := alert.systemRecord.Id
name := alert.name
value := alert.val
now := time.Now().UTC()
if alert.triggered {
// Create new alerts_history record
collection, err := am.hub.FindCollectionByNameOrId("alerts_history")
if err == nil {
history := core.NewRecord(collection)
history.Set("alert", alertId)
history.Set("user", userId)
history.Set("system", systemId)
history.Set("name", name)
history.Set("value", value)
history.Set("state", "active")
history.Set("created_date", now)
history.Set("solved_date", nil)
_ = am.hub.Save(history)
}
} else {
// Find latest active alerts_history record for this alert and set to solved
record, err := am.hub.FindFirstRecordByFilter(
"alerts_history",
"alert={:alert} && state='active'",
dbx.Params{"alert": alertId},
)
if err == nil && record != nil {
record.Set("state", "solved")
record.Set("solved_date", now)
_ = am.hub.Save(record)
}
}
}
// DeleteOldAlertHistory deletes alerts_history records older than the given retention duration
func (am *AlertManager) DeleteOldAlertHistory(retention time.Duration) {
now := time.Now().UTC()
cutoff := now.Add(-retention)
_, err := am.hub.DB().NewQuery(
"DELETE FROM alerts_history WHERE solved_date IS NOT NULL AND solved_date < {:cutoff}",
).Bind(dbx.Params{"cutoff": cutoff}).Execute()
if err != nil {
am.hub.Logger().Error("failed to delete old alerts_history records", "error", err)
}
}
// Helper to get retention duration from user settings
func getAlertHistoryRetention(settings map[string]interface{}) time.Duration {
retStr, _ := settings["alertHistoryRetention"].(string)
switch retStr {
case "1m":
return 30 * 24 * time.Hour
case "3m":
return 90 * 24 * time.Hour
case "6m":
return 180 * 24 * time.Hour
case "1y":
return 365 * 24 * time.Hour
default:
return 90 * 24 * time.Hour // default 3 months
}
}
// CleanUpAllAlertHistory deletes old alerts_history records for each user based on their retention setting
func (am *AlertManager) CleanUpAllAlertHistory() {
records, err := am.hub.FindAllRecords("user_settings")
if err != nil {
return
}
for _, record := range records {
var settings map[string]interface{}
if err := record.UnmarshalJSONField("settings", &settings); err != nil {
continue
}
am.DeleteOldAlertHistory(getAlertHistoryRetention(settings))
}
}

View File

@@ -54,6 +54,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
val = data.Info.DashboardTemp
unit = "°C"
case "LoadAvg1":
val = data.Info.LoadAvg1
unit = ""
case "LoadAvg5":
val = data.Info.LoadAvg5
unit = ""
@@ -196,6 +199,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
alert.mapSums[key] += temp
}
case "LoadAvg1":
alert.val += stats.LoadAvg1
case "LoadAvg5":
alert.val += stats.LoadAvg5
case "LoadAvg15":
@@ -288,6 +293,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// app.Logger().Error("failed to save alert record", "err", err)
return
}
// Create Alert History
am.RecordAlertHistory(alert)
// expand the user relation and send the alert
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)

View File

@@ -92,8 +92,9 @@ type Info struct {
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"15,keyasint,omitempty,omitzero"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"16,keyasint,omitempty,omitzero"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
}
// Final data structure to return to the hub

View File

@@ -219,6 +219,9 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
// delete old alert history records for each user based on their retention setting
h.Cron().MustAdd("delete old alerts_history", "5 */1 * * *", h.AlertManager.CleanUpAllAlertHistory)
return nil
}

View File

@@ -203,6 +203,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv
sum.LoadAvg1 += stats.LoadAvg1
sum.LoadAvg5 += stats.LoadAvg5
sum.LoadAvg15 += stats.LoadAvg15
// Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
@@ -278,7 +281,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {

View File

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

View File

@@ -76,6 +76,7 @@ func init() {
"Disk",
"Temperature",
"Bandwidth",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
]

View File

@@ -0,0 +1,74 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `[
{
"name": "alerts_history",
"type": "base",
"system": false,
"listRule": "",
"deleteRule": "",
"viewRule": ""
"fields": [
{
"name": "alert",
"type": "relation",
"required": true,
"collectionId": "elngm8x1l60zi2v",
"cascadeDelete": true,
"maxSelect": 1
},
{
"name": "user",
"type": "relation",
"required": true,
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"maxSelect": 1
},
{
"name": "system",
"type": "relation",
"required": true,
"collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": true,
"maxSelect": 1
},
{
"name": "name",
"type": "text",
"required": true
},
{
"name": "value",
"type": "number",
"required": true
},
{
"name": "state",
"type": "select",
"required": true,
"values": ["active", "solved"]
},
{
"name": "created_date",
"type": "date",
"required": true
},
{
"name": "solved_date",
"type": "date",
"required": false
}
]
}
]`
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
}, nil)
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1"
@@ -70,4 +71,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -0,0 +1,146 @@
import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button"
import { ArrowUpDown } from "lucide-react"
import { Badge } from "@/components/ui/badge"
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
{
accessorKey: "system",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
System <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{row.original.expand?.system?.name || row.original.system}</span>,
enableSorting: true,
filterFn: (row, _, filterValue) => {
const display = row.original.expand?.system?.name || row.original.system || ""
return display.toLowerCase().includes(filterValue.toLowerCase())
},
},
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{row.getValue("name")}</span>,
enableSorting: true,
filterFn: (row, _, filterValue) => {
const value = row.getValue("name") || ""
return String(value).toLowerCase().includes(filterValue.toLowerCase())
},
},
{
accessorKey: "value",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="text-right w-full justify-end"
>
Value <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{Math.round(Number(row.getValue("value")))}</span>,
enableSorting: true,
},
{
accessorKey: "state",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="text-center w-full justify-start"
>
State <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const state = row.getValue("state") as string
let color = ""
if (state === "solved") color = "bg-green-100 text-green-800 border-green-200"
else if (state === "active") color = "bg-yellow-100 text-yellow-800 border-yellow-200"
return (
<span className="text-center block">
<Badge className={`capitalize ${color}`}>{state}</Badge>
</span>
)
},
enableSorting: true,
},
{
accessorKey: "create_date",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-center block">
{row.original.created_date ? new Date(row.original.created_date).toLocaleString() : ""}
</span>
),
enableSorting: true,
},
{
accessorKey: "solved_date",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Solved <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-center block">
{row.original.solved_date ? new Date(row.original.solved_date).toLocaleString() : ""}
</span>
),
enableSorting: true,
},
{
accessorKey: "duration",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Duration <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const created = row.original.created_date ? new Date(row.original.created_date) : null
const solved = row.original.solved_date ? new Date(row.original.solved_date) : null
if (!created || !solved) return <span className="text-center block"></span>
const diffMs = solved.getTime() - created.getTime()
if (diffMs < 0) return <span className="text-center block"></span>
const totalSeconds = Math.floor(diffMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return (
<span className="text-center block">
{[
hours ? `${hours}h` : null,
minutes ? `${minutes}m` : null,
`${seconds}s`
].filter(Boolean).join(" ")}
</span>
)
},
enableSorting: true,
},
]

View File

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

View File

@@ -1,10 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
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 { memo } from "react"
import { useLingui } from "@lingui/react/macro"
import { DataUnit } from "@/lib/enums"
import { Unit } from "@/lib/enums"
export default memo(function DiskChart({
dataKey,
@@ -47,7 +47,7 @@ export default memo(function DiskChart({
tickLine={false}
axisLine={false}
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)
}}
/>
@@ -59,7 +59,7 @@ export default memo(function DiskChart({
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
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
}}
/>

View File

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

View File

@@ -0,0 +1,91 @@
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { t } from "@lingui/core/macro"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
const keys = {
l1: {
color: "hsl(271, 81%, 60%)", // Purple
label: t`1 min`,
},
l5: {
color: "hsl(217, 91%, 60%)", // Blue
label: t`5 min`,
},
l15: {
color: "hsl(25, 95%, 53%)", // Orange
label: t`15 min`,
},
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
// itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value)}
/>
}
/>
{Object.entries(keys).map(([key, value]: [string, { color: string; label: string }]) => {
return (
<Line
key={key}
dataKey={`stats.${key}`}
name={value.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={value.color}
isAnimationActive={false}
/>
)
})}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,10 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
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 { ChartData } from "@/types"
import { useLingui } from "@lingui/react/macro"
import { DataUnit } from "@/lib/enums"
import { Unit } from "@/lib/enums"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
@@ -40,7 +40,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
tickLine={false}
axisLine={false}
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)
}}
/>
@@ -57,7 +57,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
// 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
}}
/>

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 { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { $userSettings } from "@/lib/stores"
import { useStore } from "@nanostores/react"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const userSettings = useStore($userSettings)
if (chartData.systemStats.length === 0) {
return null
@@ -33,11 +29,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
direction="ltr"
orientation={chartData.orientation}
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}
tickLine={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)}
<ChartTooltip
@@ -46,7 +45,11 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
content={
<ChartTooltipContent
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"
/>
}

View File

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

View File

@@ -0,0 +1,247 @@
"use client"
import * as React from "react"
import { useStore } from "@nanostores/react"
import { $alertsHistory, pb } from "@/lib/stores"
import { AlertsHistoryRecord } from "@/types"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
flexRender,
ColumnFiltersState,
SortingState,
VisibilityState,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { alertsHistoryColumns } from "../../alerts-history-columns"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner"
export default function AlertsHistoryDataTable() {
const alertsHistory = useStore($alertsHistory)
React.useEffect(() => {
pb.collection<AlertsHistoryRecord>("alerts_history")
.getFullList({
sort: "-created_date",
expand: "system,user,alert"
})
.then(records => {
$alertsHistory.set(records)
})
}, [])
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const [combinedFilter, setCombinedFilter] = React.useState("")
const [globalFilter, setGlobalFilter] = React.useState("")
const table = useReactTable({
data: alertsHistory,
columns: [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...alertsHistoryColumns,
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
globalFilterFn: (row, _columnId, filterValue) => {
const system = row.original.expand?.system?.name || row.original.system || ""
const name = row.getValue("name") || ""
const search = String(filterValue).toLowerCase()
return (
system.toLowerCase().includes(search) ||
String(name).toLowerCase().includes(search)
)
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
})
// Bulk delete handler
const handleBulkDelete = async () => {
if (!window.confirm("Are you sure you want to delete the selected records?")) return
const selectedIds = table.getSelectedRowModel().rows.map(row => row.original.id)
try {
await Promise.all(selectedIds.map(id => pb.collection("alerts_history").delete(id)))
$alertsHistory.set(alertsHistory.filter(r => !selectedIds.includes(r.id)))
toast.success("Deleted selected records.")
} catch (e) {
toast.error("Failed to delete some records.")
}
}
// Export to CSV handler
const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return
const headers = ["system", "name", "value", "state", "created_date", "solved_date", "duration"]
const csvRows = [headers.join(",")]
for (const row of selectedRows) {
const r = row.original
csvRows.push([
r.expand?.system?.name || r.system,
r.name,
r.value,
r.state,
r.created_date,
r.solved_date,
(() => {
const created = r.created_date ? new Date(r.created_date) : null
const solved = r.solved_date ? new Date(r.solved_date) : null
if (!created || !solved) return ""
const diffMs = solved.getTime() - created.getTime()
if (diffMs < 0) return ""
const totalSeconds = Math.floor(diffMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return [
hours ? `${hours}h` : null,
minutes ? `${minutes}m` : null,
`${seconds}s`
].filter(Boolean).join(" ")
})()
].map(v => `"${v ?? ""}"`).join(","))
}
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "alerts_history.csv"
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="w-full">
<div className="flex items-center py-4 gap-4">
<Input
placeholder="Filter system or name..."
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
className="max-w-sm"
/>
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<>
<Button
variant="destructive"
onClick={handleBulkDelete}
size="sm"
>
Delete Selected
</Button>
<Button
variant="outline"
onClick={handleExportCSV}
size="sm"
>
Export Selected
</Button>
</>
)}
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@@ -11,17 +11,22 @@ import { useState } from "react"
import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro"
import { DataUnit, TemperatureUnit } from "@/lib/enums"
import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
// Add state for alert history retention
const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
// Add alertHistoryRetention to data
data.alertHistoryRetention = alertHistoryRetention
await saveSettings(data)
setIsLoading(false)
}
@@ -118,33 +123,41 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Select
name="unitTemp"
key={userSettings.unitTemp}
defaultValue={userSettings.unitTemp?.toString() || String(TemperatureUnit.Celsius)}
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
>
<SelectTrigger id="unitTemp">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(TemperatureUnit.Celsius)}>Celsius (°C)</SelectItem>
<SelectItem value={String(TemperatureUnit.Fahrenheit)}>Fahrenheit (°F)</SelectItem>
<SelectItem value={String(Unit.Celsius)}>
<Trans>Celsius (°C)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Fahrenheit)}>
<Trans>Fahrenheit (°F)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="block" htmlFor="unitTemp">
<Label className="block" htmlFor="unitNet">
<Trans>Network unit</Trans>
</Label>
<Select
name="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 />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(DataUnit.Bytes)}>Bytes (KB/s, MB/s, GB/s)</SelectItem>
<SelectItem value={String(DataUnit.Bits)}>Bits (kbps, Mbps, Gbps)</SelectItem>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -156,20 +169,45 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Select
name="unitDisk"
key={userSettings.unitDisk}
defaultValue={userSettings.unitDisk?.toString() ?? String(DataUnit.Bytes)}
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitDisk">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(DataUnit.Bytes)}>Bytes (KB/s, MB/s, GB/s)</SelectItem>
<SelectItem value={String(DataUnit.Bits)}>Bits (kbps, Mbps, Gbps)</SelectItem>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<div>
<Label htmlFor="alertHistoryRetention">
<Trans>Alert History Retention</Trans>
</Label>
<Select
name="alertHistoryRetention"
value={alertHistoryRetention}
onValueChange={setAlertHistoryRetention}
>
<SelectTrigger className="w-64 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">1 month</SelectItem>
<SelectItem value="3m">3 months</SelectItem>
<SelectItem value="6m">6 months</SelectItem>
<SelectItem value="1y">1 year</SelectItem>
</SelectContent>
</Select>
</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>

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useStore } from "@nanostores/react"
import { $router } from "@/components/router.tsx"
import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, LogsIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js"
@@ -16,6 +16,7 @@ import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx"
import { useLingui } from "@lingui/react/macro"
import Fingerprints from "./tokens-fingerprints.tsx"
import AlertsHistoryDataTable from "./alerts-history-data-table"
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
@@ -63,7 +64,7 @@ export default function SettingsLayout() {
title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon,
// admin: true,
noReadOnly: true,
},
{
title: t`YAML Config`,
@@ -71,6 +72,11 @@ export default function SettingsLayout() {
icon: FileSlidersIcon,
admin: true,
},
{
title: t`Alerts History`,
href: getPagePath($router, "settings", { name: "alerts-history" }),
icon: LogsIcon,
},
]
const page = useStore($router)
@@ -121,5 +127,7 @@ function SettingsContent({ name }: { name: string }) {
return <ConfigYaml />
case "tokens":
return <Fingerprints />
case "alerts-history":
return <AlertsHistoryDataTable />
}
}

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import {
$temperatureFilter,
} from "@/lib/stores"
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 { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react"
@@ -27,7 +27,6 @@ import {
getPbTimestamp,
listen,
toFixedFloat,
toFixedWithoutTrailingZeros,
useLocalStorage,
} from "@/lib/utils"
import { Separator } from "../ui/separator"
@@ -49,6 +48,7 @@ const DiskChart = lazy(() => import("../charts/disk-chart"))
const SwapChart = lazy(() => import("../charts/swap-chart"))
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
const cache = new Map<string, any>()
@@ -479,7 +479,7 @@ export default function SystemDetail({ name }: { name: string }) {
chartData={chartData}
chartName="CPU Usage"
maxToggled={maxValues}
tickFormatter={(val) => toFixedWithoutTrailingZeros(val, 2) + "%"}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
/>
</ChartCard>
@@ -556,7 +556,6 @@ export default function SystemDetail({ name }: { name: string }) {
maxToggled={maxValues}
tickFormatter={(val) => {
let { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
// value = value >= 10 ? Math.ceil(value) : value
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}}
contentFormatter={({ value }) => {
@@ -597,6 +596,18 @@ export default function SystemDetail({ name }: { name: string }) {
</ChartCard>
)}
{/* Load Average chart */}
{system.info.l1 !== undefined && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
>
<LoadAverageChart chartData={chartData} />
</ChartCard>
)}
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && (
<ChartCard
@@ -628,10 +639,6 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
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 (
<div key={id} className="contents">
<ChartCard
@@ -643,7 +650,7 @@ export default function SystemDetail({ name }: { name: string }) {
<AreaChartDefault
chartData={chartData}
chartName={`g.${id}.u`}
tickFormatter={(val) => toFixedWithoutTrailingZeros(val, 2) + "%"}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
/>
</ChartCard>
@@ -658,11 +665,11 @@ export default function SystemDetail({ name }: { name: string }) {
chartName={`g.${id}.mu`}
max={gpu.mt}
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
}}
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
}}
/>

View File

@@ -70,7 +70,7 @@ import {
copyToClipboard,
isReadOnlyUser,
useLocalStorage,
convertTemperature,
formatTemperature,
decimalString,
formatBytes,
} from "@/lib/utils"
@@ -135,7 +135,6 @@ export default function SystemsTable() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
const userSettings = useStore($userSettings)
const locale = i18n.locale
@@ -183,14 +182,14 @@ export default function SystemsTable() {
onClick={() => copyToClipboard(info.getValue() as string)}
>
{info.getValue() as string}
<CopyIcon className="h-2.5 w-2.5" />
<CopyIcon className="size-2.5" />
</Button>
</span>
),
header: sortableHeader,
},
{
accessorFn: (originalRow) => originalRow.info.cpu,
accessorFn: ({ info }) => decimalString(info.cpu, info.cpu >= 10 ? 1 : 2),
id: "cpu",
name: () => t`CPU`,
cell: CellFormatter,
@@ -222,6 +221,44 @@ export default function SystemsTable() {
Icon: GpuIcon,
header: sortableHeader,
},
{
id: "loadAverage",
accessorFn: ({ info }) => {
const { l1 = 0, l5 = 0, l15 = 0 } = info
return l1 + l5 + l15
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
if (sysInfo.l1 == undefined) {
return null
}
const { l1 = 0, l5 = 0, l15 = 0, t: cpuThreads = 1 } = sysInfo
const loadAverages = [l1, l5, l15]
function getDotColor() {
const max = Math.max(...loadAverages)
const normalized = max / cpuThreads
if (status !== "up") return "bg-primary/30"
if (normalized < 0.7) return "bg-green-500"
if (normalized < 1.0) return "bg-yellow-500"
return "bg-red-600"
}
return (
<div className="flex items-center gap-2 w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full", getDotColor())} />
{loadAverages.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))}
</div>
)
},
},
{
accessorFn: (originalRow) => originalRow.info.b || 0,
id: "net",
@@ -230,7 +267,12 @@ export default function SystemsTable() {
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true)
if (info.row.original.status !== "up") {
return null
}
const val = info.getValue() as number
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -238,46 +280,6 @@ export default function SystemsTable() {
)
},
},
{
accessorFn: (originalRow) => originalRow.info.l5,
id: "l5",
name: () => t({ message: "L5", comment: "Load average 5 minutes" }),
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
if (!val) {
return null
}
return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
{decimalString(val)}
</span>
)
},
},
{
accessorFn: (originalRow) => originalRow.info.l15,
id: "l15",
name: () => t({ message: "L15", comment: "Load average 15 minutes" }),
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
if (!val) {
return null
}
return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
{decimalString(val)}
</span>
)
},
},
{
accessorFn: (originalRow) => originalRow.info.dt,
id: "temp",
@@ -291,7 +293,8 @@ export default function SystemsTable() {
if (!val) {
return null
}
const { value, unit } = convertTemperature(val, userSettings.unitTemp)
const userSettings = useStore($userSettings)
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -545,7 +548,7 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-1" key={header.id}>
<TableHead className="px-1.5" key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { ChevronDownIcon, HourglassIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "./button"
interface CollapsibleProps {
title: string
children: React.ReactNode
description?: React.ReactNode
defaultOpen?: boolean
className?: string
icon?: React.ReactNode
}
export function Collapsible({ title, children, description, defaultOpen = false, className, icon }: CollapsibleProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen)
return (
<div className={cn("border rounded-lg", className)}>
<Button
variant="ghost"
className="w-full justify-between p-4 font-semibold"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-2">
{icon}
{title}
</div>
<ChevronDownIcon
className={cn("h-4 w-4 transition-transform duration-200", {
"rotate-180": isOpen,
})}
/>
</Button>
{description && (
<div className="px-4 pb-2 text-sm text-muted-foreground">
{description}
</div>
)}
{isOpen && (
<div className="px-4 pb-4">
<div className="grid gap-3">
{children}
</div>
</div>
)}
</div>
)
}

View File

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

View File

@@ -15,6 +15,9 @@ export const $systems = atom([] as SystemRecord[])
/** List of alert records */
export const $alerts = atom([] as AlertRecord[])
/** List of alerts history records */
export const $alertsHistory = atom([] as AlertsHistoryRecord[])
/** SSH public key */
export const $publicKey = atom("")
@@ -28,9 +31,9 @@ export const $maxValues = atom(false)
export const $userSettings = map<UserSettings>({
chartTime: "1h",
emails: [pb.authStore.record?.email || ""],
temperatureUnit: "celsius",
networkUnit: "mbps",
diskUnit: "mbps",
// unitTemp: "celsius",
// unitNet: "mbps",
// unitDisk: "mbps",
})
// update local storage on change
$userSettings.subscribe((value) => {

View File

@@ -10,8 +10,7 @@ import {
ChartTimes,
FingerprintRecord,
SystemRecord,
TemperatureConversion,
DataUnitConversion,
UserSettings,
} from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores"
@@ -20,7 +19,7 @@ import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
import { prependBasePath } from "@/components/router"
import { DataUnit, TemperatureUnit } from "./enums"
import { Unit } from "./enums"
export function cn(...inputs: ClassValue[]) {
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. */
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.realtime.unsubscribe()
}
@@ -235,17 +237,17 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth }
}
export function toFixedWithoutTrailingZeros(num: number, digits: number) {
return parseFloat(num.toFixed(digits)).toString()
}
/** Format number to x decimal places, without trailing zeros */
export function toFixedFloat(num: number, digits: number) {
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
}
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) {
if (digits === 0) {
return Math.ceil(num).toString()
}
let formatter = decimalFormatters.get(digits)
if (!formatter) {
formatter = new Intl.NumberFormat(undefined, {
@@ -276,11 +278,13 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
return [value, setValue]
}
export function convertTemperature(celsius: number, unit = TemperatureUnit.Celsius): TemperatureConversion {
const userSettings = $userSettings.get()
unit ||= userSettings.unitTemp || TemperatureUnit.Celsius
/** Format temperature to user's preferred unit */
export function formatTemperature(celsius: number, unit?: Unit): { value: number; unit: string } {
if (!unit) {
unit = $userSettings.get().unitTemp || Unit.Celsius
}
// need loose equality check due to form data being strings
if (unit == TemperatureUnit.Fahrenheit) {
if (unit == Unit.Fahrenheit) {
return {
value: celsius * 1.8 + 32,
unit: "°F",
@@ -292,17 +296,18 @@ export function convertTemperature(celsius: number, unit = TemperatureUnit.Celsi
}
}
/** Format bytes to user's preferred unit */
export function formatBytes(
size: number,
perSecond = false,
unit = DataUnit.Bytes,
unit = Unit.Bytes,
isMegabytes = false
): DataUnitConversion {
): { value: number; unit: string } {
// Convert MB to bytes if isMegabytes is true
if (isMegabytes) size *= 1024 * 1024
// need loose equality check due to form data being strings
if (unit == DataUnit.Bits) {
if (unit == Unit.Bits) {
const bits = size * 8
const suffix = perSecond ? "ps" : ""
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
@@ -322,7 +327,7 @@ export function formatBytes(
unit: `Tb${suffix}`,
}
}
// bytes
const suffix = perSecond ? "/s" : ""
if (size < 100) return { value: size, unit: `B${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() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
return
} catch (e) {
console.log("get settings", e)
console.error("get settings", e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings)
} 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 alertInfo: Record<string, AlertInfo> = {
@@ -418,6 +407,16 @@ export const alertInfo: Record<string, AlertInfo> = {
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",

View File

@@ -1,5 +1,5 @@
import { RecordModel } from "pocketbase"
import { DataUnit, Os, TemperatureUnit } from "./lib/enums"
import { Unit, Os } from "./lib/enums"
// global window properties
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 {
name: string
host: string
@@ -55,6 +44,8 @@ export interface SystemInfo {
c: number
/** cpu model */
m: string
/** load average 1 minute */
l1?: number
/** load average 5 minutes */
l5?: number
/** load average 15 minutes */
@@ -198,6 +189,17 @@ export interface AlertRecord extends RecordModel {
// user: string
}
export interface AlertsHistoryRecord extends RecordModel {
alert: string;
user: string;
system: string;
name: string;
value: number;
state: "active" | "solved";
created_date: string;
solved_date?: string | null;
}
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
export interface ChartTimeData {
@@ -211,14 +213,17 @@ export interface ChartTimeData {
}
}
export type UserSettings = {
export interface UserSettings {
// lang?: string
chartTime: ChartTimes
emails?: string[]
webhooks?: string[]
unitTemp?: TemperatureUnit
unitNet?: DataUnit
unitDisk?: DataUnit
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
alertHistoryRetention?: string
}
type ChartDataContainer = {