mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-23 05:01:49 +02:00
Compare commits
3 Commits
3586f73f30
...
svenvg93-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7fb8ab80 | ||
|
|
3730a78e5a | ||
|
|
7cdd0907e8 |
@@ -56,7 +56,7 @@ dev-hub: export ENV=dev
|
|||||||
dev-hub:
|
dev-hub:
|
||||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@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 \
|
else \
|
||||||
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg1
|
||||||
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
|
||||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type SystemAlertStats struct {
|
|||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
LoadAvg1 float64 `json:"l1"`
|
||||||
LoadAvg5 float64 `json:"l5"`
|
LoadAvg5 float64 `json:"l5"`
|
||||||
LoadAvg15 float64 `json:"l15"`
|
LoadAvg15 float64 `json:"l15"`
|
||||||
}
|
}
|
||||||
|
|||||||
96
beszel/internal/alerts/alerts_history.go
Normal file
96
beszel/internal/alerts/alerts_history.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
val = data.Info.DashboardTemp
|
val = data.Info.DashboardTemp
|
||||||
unit = "°C"
|
unit = "°C"
|
||||||
|
case "LoadAvg1":
|
||||||
|
val = data.Info.LoadAvg1
|
||||||
|
unit = ""
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
val = data.Info.LoadAvg5
|
val = data.Info.LoadAvg5
|
||||||
unit = ""
|
unit = ""
|
||||||
@@ -196,6 +199,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.mapSums[key] += temp
|
alert.mapSums[key] += temp
|
||||||
}
|
}
|
||||||
|
case "LoadAvg1":
|
||||||
|
alert.val += stats.LoadAvg1
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
alert.val += stats.LoadAvg5
|
alert.val += stats.LoadAvg5
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
@@ -288,6 +293,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create Alert History
|
||||||
|
am.RecordAlertHistory(alert)
|
||||||
|
|
||||||
// expand the user relation and send the alert
|
// expand the user relation and send the alert
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ type Info struct {
|
|||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
Os Os `json:"os" cbor:"14,keyasint"`
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"15,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"16,keyasint,omitempty,omitzero"`
|
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
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
|
sum.LoadAvg1 += stats.LoadAvg1
|
||||||
|
sum.LoadAvg5 += stats.LoadAvg5
|
||||||
|
sum.LoadAvg15 += stats.LoadAvg15
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
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.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / 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
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type UserSettings struct {
|
|||||||
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
|
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
|
||||||
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
|
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
|
||||||
// UnitDisk uint8 `json:"unitDisk"` // 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 {
|
func NewUserManager(app core.App) *UserManager {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15"
|
||||||
]
|
]
|
||||||
74
beszel/migrations/1_create_alerts_history.go
Normal file
74
beszel/migrations/1_create_alerts_history.go
Normal 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)
|
||||||
|
}
|
||||||
344
beszel/site/package-lock.json
generated
344
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
@@ -70,4 +71,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
beszel/site/src/components/alerts-history-columns.tsx
Normal file
146
beszel/site/src/components/alerts-history-columns.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
91
beszel/site/src/components/charts/load-average-chart.tsx
Normal file
91
beszel/site/src/components/charts/load-average-chart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,11 +17,16 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
// Add state for alert history retention
|
||||||
|
const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
|
// Add alertHistoryRetention to data
|
||||||
|
data.alertHistoryRetention = alertHistoryRetention
|
||||||
await saveSettings(data)
|
await saveSettings(data)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -182,6 +187,27 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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}>
|
<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" />}
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
<Trans>Save Settings</Trans>
|
<Trans>Save Settings</Trans>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $router } from "@/components/router.tsx"
|
import { $router } from "@/components/router.tsx"
|
||||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
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 { $userSettings, pb } from "@/lib/stores.ts"
|
||||||
import { toast } from "@/components/ui/use-toast.ts"
|
import { toast } from "@/components/ui/use-toast.ts"
|
||||||
import { UserSettings } from "@/types.js"
|
import { UserSettings } from "@/types.js"
|
||||||
@@ -16,6 +16,7 @@ import Notifications from "./notifications.tsx"
|
|||||||
import ConfigYaml from "./config-yaml.tsx"
|
import ConfigYaml from "./config-yaml.tsx"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import Fingerprints from "./tokens-fingerprints.tsx"
|
import Fingerprints from "./tokens-fingerprints.tsx"
|
||||||
|
import AlertsHistoryDataTable from "./alerts-history-data-table"
|
||||||
|
|
||||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
try {
|
try {
|
||||||
@@ -71,6 +72,11 @@ export default function SettingsLayout() {
|
|||||||
icon: FileSlidersIcon,
|
icon: FileSlidersIcon,
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t`Alerts History`,
|
||||||
|
href: getPagePath($router, "settings", { name: "alerts-history" }),
|
||||||
|
icon: LogsIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
@@ -121,5 +127,7 @@ function SettingsContent({ name }: { name: string }) {
|
|||||||
return <ConfigYaml />
|
return <ConfigYaml />
|
||||||
case "tokens":
|
case "tokens":
|
||||||
return <Fingerprints />
|
return <Fingerprints />
|
||||||
|
case "alerts-history":
|
||||||
|
return <AlertsHistoryDataTable />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const DiskChart = lazy(() => import("../charts/disk-chart"))
|
|||||||
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
||||||
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
||||||
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
||||||
|
const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
const cache = new Map<string, any>()
|
||||||
|
|
||||||
@@ -595,6 +596,18 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</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 */}
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
|
|||||||
@@ -182,14 +182,14 @@ export default function SystemsTable() {
|
|||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||||
>
|
>
|
||||||
{info.getValue() as string}
|
{info.getValue() as string}
|
||||||
<CopyIcon className="h-2.5 w-2.5" />
|
<CopyIcon className="size-2.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (originalRow) => originalRow.info.cpu,
|
accessorFn: ({ info }) => decimalString(info.cpu, info.cpu >= 10 ? 1 : 2),
|
||||||
id: "cpu",
|
id: "cpu",
|
||||||
name: () => t`CPU`,
|
name: () => t`CPU`,
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
@@ -221,6 +221,44 @@ export default function SystemsTable() {
|
|||||||
Icon: GpuIcon,
|
Icon: GpuIcon,
|
||||||
header: sortableHeader,
|
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,
|
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||||
id: "net",
|
id: "net",
|
||||||
@@ -229,8 +267,12 @@ export default function SystemsTable() {
|
|||||||
Icon: EthernetIcon,
|
Icon: EthernetIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
cell(info) {
|
cell(info) {
|
||||||
|
if (info.row.original.status !== "up") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const val = info.getValue() as number
|
||||||
const userSettings = useStore($userSettings)
|
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 (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{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,
|
accessorFn: (originalRow) => originalRow.info.dt,
|
||||||
id: "temp",
|
id: "temp",
|
||||||
@@ -546,7 +548,7 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
|
|||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead className="px-1" key={header.id}>
|
<TableHead className="px-1.5" key={header.id}>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
|
|||||||
49
beszel/site/src/components/ui/collapsible.tsx
Normal file
49
beszel/site/src/components/ui/collapsible.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ export const $systems = atom([] as SystemRecord[])
|
|||||||
/** List of alert records */
|
/** List of alert records */
|
||||||
export const $alerts = atom([] as AlertRecord[])
|
export const $alerts = atom([] as AlertRecord[])
|
||||||
|
|
||||||
|
/** List of alerts history records */
|
||||||
|
export const $alertsHistory = atom([] as AlertsHistoryRecord[])
|
||||||
|
|
||||||
/** SSH public key */
|
/** SSH public key */
|
||||||
export const $publicKey = atom("")
|
export const $publicKey = atom("")
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,16 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
icon: ThermometerIcon,
|
icon: ThermometerIcon,
|
||||||
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
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: {
|
LoadAvg5: {
|
||||||
name: () => t`Load Average 5m`,
|
name: () => t`Load Average 5m`,
|
||||||
unit: "",
|
unit: "",
|
||||||
|
|||||||
18
beszel/site/src/types.d.ts
vendored
18
beszel/site/src/types.d.ts
vendored
@@ -44,6 +44,8 @@ export interface SystemInfo {
|
|||||||
c: number
|
c: number
|
||||||
/** cpu model */
|
/** cpu model */
|
||||||
m: string
|
m: string
|
||||||
|
/** load average 1 minute */
|
||||||
|
l1?: number
|
||||||
/** load average 5 minutes */
|
/** load average 5 minutes */
|
||||||
l5?: number
|
l5?: number
|
||||||
/** load average 15 minutes */
|
/** load average 15 minutes */
|
||||||
@@ -187,6 +189,17 @@ export interface AlertRecord extends RecordModel {
|
|||||||
// user: string
|
// 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 type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
|
||||||
|
|
||||||
export interface ChartTimeData {
|
export interface ChartTimeData {
|
||||||
@@ -200,7 +213,7 @@ export interface ChartTimeData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserSettings = {
|
export interface UserSettings {
|
||||||
// lang?: string
|
// lang?: string
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
emails?: string[]
|
emails?: string[]
|
||||||
@@ -208,6 +221,9 @@ export type UserSettings = {
|
|||||||
unitTemp?: Unit
|
unitTemp?: Unit
|
||||||
unitNet?: Unit
|
unitNet?: Unit
|
||||||
unitDisk?: Unit
|
unitDisk?: Unit
|
||||||
|
|
||||||
|
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
|
||||||
|
alertHistoryRetention?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartDataContainer = {
|
type ChartDataContainer = {
|
||||||
|
|||||||
Reference in New Issue
Block a user