Compare commits

..

2 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
19 changed files with 861 additions and 381 deletions

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

@@ -293,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

@@ -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

@@ -20,6 +20,9 @@ type UserSettings struct {
// 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 {

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

@@ -11,14 +11,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { BellIcon, GlobeIcon, ServerIcon, HourglassIcon } from "lucide-react"
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types"
import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox"
import { Collapsible } from "../ui/collapsible"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"

View File

@@ -8,16 +8,9 @@ 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"
import { memo } from "react"
import { t } from "@lingui/core/macro"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
@@ -27,46 +20,20 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
return null
}
/** Format load average data for chart */
const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
// Define colors for the three load average lines
const colors = {
"1m": "hsl(25, 95%, 53%)", // Orange for 1-minute
"5m": "hsl(217, 91%, 60%)", // Blue for 5-minute
"15m": "hsl(271, 81%, 56%)", // Purple for 15-minute
}
for (let data of chartData.systemStats) {
let newData = { created: data.created } as Record<string, number | string>
// Add load average values if they exist and stats is not null
if (data.stats && data.stats.l1 !== undefined) {
newData["1m"] = data.stats.l1
}
if (data.stats && data.stats.l5 !== undefined) {
newData["5m"] = data.stats.l5
}
if (data.stats && data.stats.l15 !== undefined) {
newData["15m"] = data.stats.l15
}
newChartData.data.push(newData)
}
newChartData.colors = colors
return newChartData
}, [chartData])
const loadKeys = ["1m", "5m", "15m"].filter(key =>
newChartData.data.some(data => data[key] !== undefined)
)
// console.log('rendered at', new Date())
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>
@@ -75,7 +42,7 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
@@ -84,8 +51,7 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val)
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
@@ -95,7 +61,7 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
// itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
@@ -103,21 +69,23 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
/>
}
/>
{loadKeys.map((key) => (
<Line
key={key}
dataKey={key}
name={key === "1m" ? t`1 min` : key === "5m" ? t`5 min` : t`15 min`}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
isAnimationActive={false}
/>
))}
{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

@@ -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

@@ -17,11 +17,16 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
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)
}
@@ -182,6 +187,27 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</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 {
@@ -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

@@ -484,18 +484,6 @@ export default function SystemDetail({ name }: { name: string }) {
/>
</ChartCard>
{/* Load Average chart */}
{(systemStats.at(-1)?.stats.l1 !== undefined || systemStats.at(-1)?.stats.l5 !== undefined || systemStats.at(-1)?.stats.l15 !== undefined) && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
>
<LoadAverageChart chartData={chartData} />
</ChartCard>
)}
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
@@ -608,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

View File

@@ -84,7 +84,6 @@ import { ClassValue } from "clsx"
import { getPagePath } from "@nanostores/router"
import { SystemDialog } from "../add-system"
import { Dialog } from "../ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
type ViewMode = "table" | "grid"
@@ -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,8 +267,12 @@ export default function SystemsTable() {
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
if (info.row.original.status !== "up") {
return null
}
const val = info.getValue() as number
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true)
const { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -239,64 +280,6 @@ export default function SystemsTable() {
)
},
},
{
id: "loadAverage",
name: () => t`Load Average`,
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const system = info.row.original;
const l1 = system.info?.l1;
const l5 = system.info?.l5;
const l15 = system.info?.l15;
const cores = system.info?.c || 1;
// If no load average data, return null
if (!l1 && !l5 && !l15) return null;
const loadAverages = [
{ name: "1m", value: l1 },
{ name: "5m", value: l5 },
{ name: "15m", value: l15 }
].filter(la => la.value !== undefined);
if (!loadAverages.length) return null;
function getDotColor(value: number) {
const normalized = value / cores;
if (normalized < 0.7) return "bg-green-500";
if (normalized < 1.0) return "bg-orange-500";
return "bg-red-600";
}
return (
<div className="flex items-center gap-2 w-full">
{loadAverages.map((la, idx) => (
<TooltipProvider key={la.name}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center cursor-pointer">
<span className={cn("inline-block w-2 h-2 rounded-full mr-1", getDotColor(la.value || 0))} />
<span className="tabular-nums">
{decimalString(la.value || 0, 2)}
</span>
{idx < loadAverages.length - 1 && <span className="mx-1 text-muted-foreground">/</span>}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="text-center">
<div className="font-medium">{t`${la.name}`}</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
);
},
},
{
accessorFn: (originalRow) => originalRow.info.dt,
id: "temp",
@@ -565,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

@@ -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("")

View File

@@ -455,27 +455,3 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/**
* Calculate load average percentage relative to CPU cores
* @param loadAverage - The load average value (1m, 5m, or 15m)
* @param cores - Number of CPU cores
* @returns Percentage (0-100) representing CPU utilization
*/
export const calculateLoadAveragePercent = (loadAverage: number, cores: number): number => {
if (!loadAverage || !cores) return 0
return Math.min((loadAverage / cores) * 100, 100)
}
/**
* Get load average opacity based on utilization relative to cores
* @param loadAverage - The load average value
* @param cores - Number of CPU cores
* @returns Opacity value (0.6, 0.8, or 1.0)
*/
export const getLoadAverageOpacity = (loadAverage: number, cores: number): number => {
if (!loadAverage || !cores) return 0.6
if (loadAverage < cores * 0.5) return 0.6
if (loadAverage < cores) return 0.8
return 1.0
}

View File

@@ -189,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 {
@@ -202,7 +213,7 @@ export interface ChartTimeData {
}
}
export type UserSettings = {
export interface UserSettings {
// lang?: string
chartTime: ChartTimes
emails?: string[]
@@ -210,6 +221,9 @@ export type UserSettings = {
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
alertHistoryRetention?: string
}
type ChartDataContainer = {