mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-21 21:26:16 +01:00
Compare commits
13 Commits
v0.12.0-be
...
svenvg93-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7fb8ab80 | ||
|
|
3730a78e5a | ||
|
|
7cdd0907e8 | ||
|
|
3586f73f30 | ||
|
|
752ccc6beb | ||
|
|
f577476c81 | ||
|
|
49ae424698 | ||
|
|
d4fd19522b | ||
|
|
5c047e4afd | ||
|
|
6576141f54 | ||
|
|
926e807020 | ||
|
|
d91847c6c5 | ||
|
|
0abd88270c |
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,8 +1,19 @@
|
||||
name: 🐛 Bug report
|
||||
description: Report a new bug or issue.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
labels: ['bug', "needs confirmation"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of Beszel is this about?
|
||||
options:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
@@ -43,6 +54,39 @@ body:
|
||||
3. Pour it into a cup.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: Which category does this relate to most?
|
||||
options:
|
||||
- Metrics
|
||||
- Charts & Visualization
|
||||
- Settings & Configuration
|
||||
- Notifications & Alerts
|
||||
- Authentication
|
||||
- Installation
|
||||
- Performance
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: metrics
|
||||
attributes:
|
||||
label: Affected Metrics
|
||||
description: If applicable, which specific metric does this relate to most?
|
||||
options:
|
||||
- CPU
|
||||
- Memory
|
||||
- Storage
|
||||
- Network
|
||||
- Containers
|
||||
- GPU
|
||||
- Sensors
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
@@ -61,7 +105,6 @@ body:
|
||||
id: install-method
|
||||
attributes:
|
||||
label: Installation method
|
||||
default: 0
|
||||
options:
|
||||
- Docker
|
||||
- Binary
|
||||
|
||||
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,8 +1,19 @@
|
||||
name: 🚀 Feature request
|
||||
description: Request a new feature or change.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
labels: ["enhancement", "needs review"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of Beszel is this about?
|
||||
options:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||
@@ -11,8 +22,55 @@ body:
|
||||
label: Describe the feature you would like to see
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / Use Case
|
||||
description: Why do you want this feature? What problem does it solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe how you would like to see this feature implemented
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: Which category does this relate to most?
|
||||
options:
|
||||
- Metrics
|
||||
- Charts & Visualization
|
||||
- Settings & Configuration
|
||||
- Notifications & Alerts
|
||||
- Authentication
|
||||
- Installation
|
||||
- Performance
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: metrics
|
||||
attributes:
|
||||
label: Affected Metrics
|
||||
description: If applicable, which specific metric does this relate to most?
|
||||
options:
|
||||
- CPU
|
||||
- Memory
|
||||
- Storage
|
||||
- Network
|
||||
- Containers
|
||||
- GPU
|
||||
- Sensors
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
## 📃 Description
|
||||
|
||||
A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes.
|
||||
|
||||
## 🪵 Changelog
|
||||
|
||||
### ➕ Added
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
### ✏️ Changed
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
### 🔧 Fixed
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
### 🗑️ Removed
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
## 📷 Screenshots
|
||||
|
||||
If this PR has any UI/UX changes it's strongly suggested you add screenshots here.
|
||||
2
.github/workflows/docker-images.yml
vendored
2
.github/workflows/docker-images.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
tags: |
|
||||
type=edge,enable=true
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: 'Issue and PR Maintenance'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # runs at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Close Stale Issues
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Messaging
|
||||
stale-issue-message: >
|
||||
👋 This issue has been automatically marked as stale due to inactivity.
|
||||
If this issue is still relevant, please comment to keep it open.
|
||||
Without activity, it will be closed in 7 days.
|
||||
|
||||
close-issue-message: >
|
||||
🔒 This issue has been automatically closed due to prolonged inactivity.
|
||||
Feel free to open a new issue if you have further questions or concerns.
|
||||
|
||||
# Timing
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
|
||||
# Labels
|
||||
stale-issue-label: 'stale'
|
||||
remove-stale-when-updated: true
|
||||
only-issue-labels: 'awaiting-requester'
|
||||
|
||||
# Exemptions
|
||||
exempt-assignees: true
|
||||
exempt-milestones: true
|
||||
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Label issues from dropdowns
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
label_from_dropdown:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Apply labels based on dropdown choices
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
const issueNumber = context.issue.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Get the issue body
|
||||
const body = context.payload.issue.body;
|
||||
|
||||
// Helper to find dropdown value in the body (assuming markdown format)
|
||||
function extractSectionValue(heading) {
|
||||
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
|
||||
const match = body.match(regex);
|
||||
if (match) {
|
||||
// Get the first non-empty line after the heading
|
||||
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
|
||||
return lines[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract dropdown selections
|
||||
const category = extractSectionValue('Category');
|
||||
const metrics = extractSectionValue('Affected Metrics');
|
||||
const component = extractSectionValue('Component');
|
||||
|
||||
// Build labels to add
|
||||
let labelsToAdd = [];
|
||||
if (category) labelsToAdd.push(category);
|
||||
if (metrics) labelsToAdd.push(metrics);
|
||||
if (component) labelsToAdd.push(component);
|
||||
|
||||
// Get existing labels in the repo
|
||||
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100
|
||||
});
|
||||
const existingLabelNames = existingLabels.map(l => l.name);
|
||||
|
||||
// Find labels that need to be created
|
||||
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
|
||||
|
||||
// Create missing labels (with a default color)
|
||||
for (const label of labelsToCreate) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: 'ededed' // light gray, you can pick any hex color
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore if label already exists (race condition), otherwise rethrow
|
||||
if (!e || e.status !== 422) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Now apply all labels (they all exist now)
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToAdd
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,12 @@ type UserSettings struct {
|
||||
ChartTime string `json:"chartTime"`
|
||||
NotificationEmails []string `json:"emails"`
|
||||
NotificationWebhooks []string `json:"webhooks"`
|
||||
// Language string `json:"lang"`
|
||||
// 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 {
|
||||
@@ -39,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{},
|
||||
|
||||
@@ -76,6 +76,7 @@ func init() {
|
||||
"Disk",
|
||||
"Temperature",
|
||||
"Bandwidth",
|
||||
"LoadAvg1",
|
||||
"LoadAvg5",
|
||||
"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-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
]
|
||||
@@ -2,14 +2,7 @@ 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, chartMargin } from "@/lib/utils"
|
||||
// import Spinner from '../spinner'
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
@@ -30,7 +23,6 @@ const getNestedValue = (path: string, max = false, data: any): number | null =>
|
||||
|
||||
export default memo(function AreaChartDefault({
|
||||
maxToggled = false,
|
||||
unit = " MB/s",
|
||||
chartName,
|
||||
chartData,
|
||||
max,
|
||||
@@ -38,12 +30,11 @@ export default memo(function AreaChartDefault({
|
||||
contentFormatter,
|
||||
}: {
|
||||
maxToggled?: boolean
|
||||
unit?: string
|
||||
chartName: string
|
||||
chartData: ChartData
|
||||
max?: number
|
||||
tickFormatter?: (value: number) => string
|
||||
contentFormatter?: (value: number) => string
|
||||
tickFormatter: (value: number) => string
|
||||
contentFormatter: ({ value }: { value: number }) => string
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { i18n } = useLingui()
|
||||
@@ -98,15 +89,7 @@ export default memo(function AreaChartDefault({
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
domain={[0, max ?? "auto"]}
|
||||
tickFormatter={(value) => {
|
||||
let val: string
|
||||
if (tickFormatter) {
|
||||
val = tickFormatter(value)
|
||||
} else {
|
||||
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
}
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
tickFormatter={(value) => updateYAxisWidth(tickFormatter(value))}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
@@ -117,12 +100,7 @@ export default memo(function AreaChartDefault({
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={({ value }) => {
|
||||
if (contentFormatter) {
|
||||
return contentFormatter(value)
|
||||
}
|
||||
return decimalString(value) + unit
|
||||
}}
|
||||
contentFormatter={contentFormatter}
|
||||
// indicator="line"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
toFixedFloat,
|
||||
getSizeAndUnit,
|
||||
toFixedWithoutTrailingZeros,
|
||||
} 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 } from "@/lib/stores"
|
||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||
import { ChartData } from "@/types"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { ChartType } from "@/lib/enums"
|
||||
import { ChartType, Unit } from "@/lib/enums"
|
||||
|
||||
export default memo(function ContainerChart({
|
||||
dataKey,
|
||||
@@ -30,6 +21,7 @@ export default memo(function ContainerChart({
|
||||
unit?: string
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const { containerData } = chartData
|
||||
@@ -84,13 +76,14 @@ 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 {
|
||||
obj.tickFormatter = (value) => {
|
||||
const { v, u } = getSizeAndUnit(value, false)
|
||||
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
// tooltip formatter
|
||||
@@ -99,12 +92,14 @@ export default memo(function ContainerChart({
|
||||
try {
|
||||
const sent = item?.payload?.[key]?.ns ?? 0
|
||||
const received = item?.payload?.[key]?.nr ?? 0
|
||||
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
|
||||
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
|
||||
return (
|
||||
<span className="flex">
|
||||
{decimalString(received)} MB/s
|
||||
{decimalString(receivedValue)} {receivedUnit}
|
||||
<span className="opacity-70 ms-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{decimalString(sent)} MB/s
|
||||
{decimalString(sentValue)} {sentUnit}
|
||||
<span className="opacity-70 ms-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
@@ -114,8 +109,8 @@ export default memo(function ContainerChart({
|
||||
}
|
||||
} else if (chartType === ChartType.Memory) {
|
||||
obj.toolTipFormatter = (item: any) => {
|
||||
const { v, u } = getSizeAndUnit(item.value, false)
|
||||
return decimalString(v, 2) + u
|
||||
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
||||
return decimalString(value) + " " + unit
|
||||
}
|
||||
} else {
|
||||
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||
|
||||
@@ -1,17 +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,
|
||||
getSizeAndUnit,
|
||||
} 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 { Unit } from "@/lib/enums"
|
||||
|
||||
export default memo(function DiskChart({
|
||||
dataKey,
|
||||
@@ -53,9 +46,9 @@ export default memo(function DiskChart({
|
||||
minTickGap={6}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const { v, u } = getSizeAndUnit(value)
|
||||
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
|
||||
}}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
@@ -66,8 +59,8 @@ export default memo(function DiskChart({
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={({ value }) => {
|
||||
const { v, u } = getSizeAndUnit(value)
|
||||
return decimalString(v) + u
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return decimalString(convertedValue) + " " + unit
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -1,9 +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 } 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 { Unit } from "@/lib/enums"
|
||||
|
||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
@@ -39,8 +40,8 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedFloat(value, 1)
|
||||
return updateYAxisWidth(val + " GB")
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -54,8 +55,11 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
||||
// indicator="line"
|
||||
contentFormatter={({ value }) => {
|
||||
// mem values are supplied as GB
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -12,17 +12,19 @@ import {
|
||||
useYAxisWidth,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
decimalString,
|
||||
toFixedFloat,
|
||||
chartMargin,
|
||||
formatTemperature,
|
||||
decimalString,
|
||||
} from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { $temperatureFilter } from "@/lib/stores"
|
||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||
const filter = useStore($temperatureFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
if (chartData.systemStats.length === 0) {
|
||||
@@ -72,9 +74,9 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
className="tracking-tighter"
|
||||
domain={[0, "auto"]}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||
return updateYAxisWidth(val + " °C")
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||
return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -88,7 +90,10 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
||||
contentFormatter={(item) => {
|
||||
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
|
||||
return decimalString(value) + " " + unit
|
||||
}}
|
||||
filter={filter}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 { setLang } from "@/lib/i18n"
|
||||
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)
|
||||
}
|
||||
@@ -101,6 +106,108 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Unit preferences</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Change display units for metrics.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="unitTemp">
|
||||
<Trans>Temperature unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitTemp"
|
||||
key={userSettings.unitTemp}
|
||||
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
|
||||
>
|
||||
<SelectTrigger id="unitTemp">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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="unitNet">
|
||||
<Trans>Network unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitNet"
|
||||
key={userSettings.unitNet}
|
||||
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
|
||||
>
|
||||
<SelectTrigger id="unitNet">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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 className="space-y-2">
|
||||
<Label className="block" htmlFor="unitDisk">
|
||||
<Trans>Disk unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitDisk"
|
||||
key={userSettings.unitDisk}
|
||||
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
|
||||
>
|
||||
<SelectTrigger id="unitDisk">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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>
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
$temperatureFilter,
|
||||
} from "@/lib/stores"
|
||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import { ChartType, 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"
|
||||
@@ -21,9 +21,10 @@ import ChartTimeSelect from "../charts/chart-time-select"
|
||||
import {
|
||||
chartTimeData,
|
||||
cn,
|
||||
decimalString,
|
||||
formatBytes,
|
||||
getHostDisplayValue,
|
||||
getPbTimestamp,
|
||||
getSizeAndUnit,
|
||||
listen,
|
||||
toFixedFloat,
|
||||
useLocalStorage,
|
||||
@@ -47,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>()
|
||||
|
||||
@@ -131,6 +133,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||
const [chartLoading, setChartLoading] = useState(true)
|
||||
const isLongerChart = chartTime !== "1h"
|
||||
const userSettings = $userSettings.get()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${name} / Beszel`
|
||||
@@ -472,7 +475,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={t`Average system-wide CPU utilization`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={maxValues} unit="%" />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName="CPU Usage"
|
||||
maxToggled={maxValues}
|
||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{containerFilterBar && (
|
||||
@@ -519,7 +528,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={t`Throughput of root filesystem`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} chartName="dio" maxToggled={maxValues} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName="dio"
|
||||
maxToggled={maxValues}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
@@ -529,7 +550,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
cornerEl={maxValSelect}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} chartName="bw" maxToggled={maxValues} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName="bw"
|
||||
maxToggled={maxValues}
|
||||
tickFormatter={(val) => {
|
||||
let { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
|
||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, true)
|
||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{containerFilterBar && containerData.length > 0 && (
|
||||
@@ -563,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
|
||||
@@ -594,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
|
||||
@@ -606,7 +647,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
title={`${gpu.n} ${t`Usage`}`}
|
||||
description={t`Average utilization of ${gpu.n}`}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName={`g.${id}.u`}
|
||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
@@ -618,8 +664,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
chartData={chartData}
|
||||
chartName={`g.${id}.mu`}
|
||||
max={gpu.mt}
|
||||
tickFormatter={sizeFormatter}
|
||||
contentFormatter={(value) => sizeFormatter(value, 2)}
|
||||
tickFormatter={(val) => {
|
||||
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, Unit.Bytes, true)
|
||||
return decimalString(convertedValue) + " " + unit
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
@@ -653,7 +705,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={t`Throughput of ${extraFsName}`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} chartName={`efs.${extraFsName}`} maxToggled={maxValues} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName={`efs.${extraFsName}`}
|
||||
maxToggled={maxValues}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -63,9 +63,17 @@ import {
|
||||
PenBoxIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { $systems, pb } from "@/lib/stores"
|
||||
import { $systems, $userSettings, pb } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||
import {
|
||||
cn,
|
||||
copyToClipboard,
|
||||
isReadOnlyUser,
|
||||
useLocalStorage,
|
||||
formatTemperature,
|
||||
decimalString,
|
||||
formatBytes,
|
||||
} from "@/lib/utils"
|
||||
import AlertsButton from "../alerts/alert-button"
|
||||
import { $router, Link, navigate } from "../router"
|
||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||
@@ -174,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,
|
||||
@@ -214,53 +222,60 @@ export default function SystemsTable() {
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 50,
|
||||
Icon: EthernetIcon,
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const val = info.getValue() as number
|
||||
return <span className="tabular-nums whitespace-nowrap">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
||||
id: "loadAverage",
|
||||
accessorFn: ({ info }) => {
|
||||
const { l1 = 0, l5 = 0, l15 = 0 } = info
|
||||
return l1 + l5 + l15
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (originalRow) => originalRow.info.l5,
|
||||
id: "l5",
|
||||
name: () => t({ message: "L5", comment: "Load average 5 minutes" }),
|
||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||
size: 0,
|
||||
hideSort: true,
|
||||
Icon: HourglassIcon,
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const val = info.getValue() as number
|
||||
if (!val) {
|
||||
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 (
|
||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
|
||||
{decimalString(val)}
|
||||
</span>
|
||||
<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.l15,
|
||||
id: "l15",
|
||||
name: () => t({ message: "L15", comment: "Load average 15 minutes" }),
|
||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 0,
|
||||
hideSort: true,
|
||||
Icon: HourglassIcon,
|
||||
Icon: EthernetIcon,
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const val = info.getValue() as number
|
||||
if (!val) {
|
||||
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={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
|
||||
{decimalString(val)}
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -278,9 +293,11 @@ export default function SystemsTable() {
|
||||
if (!val) {
|
||||
return null
|
||||
}
|
||||
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(val)} °C
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -531,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>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Operating system */
|
||||
export enum Os {
|
||||
Linux = 0,
|
||||
Darwin,
|
||||
@@ -5,9 +6,18 @@ export enum Os {
|
||||
FreeBSD,
|
||||
}
|
||||
|
||||
/** Type of chart */
|
||||
export enum ChartType {
|
||||
Memory,
|
||||
Disk,
|
||||
Network,
|
||||
CPU,
|
||||
}
|
||||
|
||||
/** Unit of measurement */
|
||||
export enum Unit {
|
||||
Bytes,
|
||||
Bits,
|
||||
Celsius,
|
||||
Fahrenheit,
|
||||
}
|
||||
|
||||
@@ -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,6 +31,9 @@ export const $maxValues = atom(false)
|
||||
export const $userSettings = map<UserSettings>({
|
||||
chartTime: "1h",
|
||||
emails: [pb.authStore.record?.email || ""],
|
||||
// unitTemp: "celsius",
|
||||
// unitNet: "mbps",
|
||||
// unitDisk: "mbps",
|
||||
})
|
||||
// update local storage on change
|
||||
$userSettings.subscribe((value) => {
|
||||
|
||||
@@ -3,7 +3,15 @@ import { toast } from "@/components/ui/use-toast"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
|
||||
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
|
||||
import {
|
||||
AlertInfo,
|
||||
AlertRecord,
|
||||
ChartTimeData,
|
||||
ChartTimes,
|
||||
FingerprintRecord,
|
||||
SystemRecord,
|
||||
UserSettings,
|
||||
} from "@/types"
|
||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
||||
import { WritableAtom } from "nanostores"
|
||||
import { timeDay, timeHour } from "d3-time"
|
||||
@@ -11,6 +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 { Unit } from "./enums"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -73,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()
|
||||
}
|
||||
@@ -225,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(num.toFixed(digits))
|
||||
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, {
|
||||
@@ -266,40 +278,93 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
/** 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 == Unit.Fahrenheit) {
|
||||
return {
|
||||
value: celsius * 1.8 + 32,
|
||||
unit: "°F",
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: celsius,
|
||||
unit: "°C",
|
||||
}
|
||||
}
|
||||
|
||||
/** Format bytes to user's preferred unit */
|
||||
export function formatBytes(
|
||||
size: number,
|
||||
perSecond = false,
|
||||
unit = Unit.Bytes,
|
||||
isMegabytes = false
|
||||
): { 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 == Unit.Bits) {
|
||||
const bits = size * 8
|
||||
const suffix = perSecond ? "ps" : ""
|
||||
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
|
||||
if (bits < 1_000_000) return { value: bits / 1_000, unit: `Kb${suffix}` }
|
||||
if (bits < 1_000_000_000)
|
||||
return {
|
||||
value: bits / 1_000_000,
|
||||
unit: `Mb${suffix}`,
|
||||
}
|
||||
if (bits < 1_000_000_000_000)
|
||||
return {
|
||||
value: bits / 1_000_000_000,
|
||||
unit: `Gb${suffix}`,
|
||||
}
|
||||
return {
|
||||
value: bits / 1_000_000_000_000,
|
||||
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}` }
|
||||
if (size < 1000 * 1024 ** 2)
|
||||
return {
|
||||
value: size / 1024 ** 2,
|
||||
unit: `MB${suffix}`,
|
||||
}
|
||||
if (size < 1000 * 1024 ** 3)
|
||||
return {
|
||||
value: size / 1024 ** 3,
|
||||
unit: `GB${suffix}`,
|
||||
}
|
||||
return {
|
||||
value: size / 1024 ** 4,
|
||||
unit: `TB${suffix}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> = {
|
||||
@@ -342,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: "",
|
||||
|
||||
23
beszel/site/src/types.d.ts
vendored
23
beszel/site/src/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { RecordModel } from "pocketbase"
|
||||
import { Os } from "./lib/enums"
|
||||
import { Unit, Os } from "./lib/enums"
|
||||
|
||||
// global window properties
|
||||
declare global {
|
||||
@@ -44,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 */
|
||||
@@ -187,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 {
|
||||
@@ -200,11 +213,17 @@ export interface ChartTimeData {
|
||||
}
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
export interface UserSettings {
|
||||
// lang?: string
|
||||
chartTime: ChartTimes
|
||||
emails?: string[]
|
||||
webhooks?: string[]
|
||||
unitTemp?: Unit
|
||||
unitNet?: Unit
|
||||
unitDisk?: Unit
|
||||
|
||||
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
|
||||
alertHistoryRetention?: string
|
||||
}
|
||||
|
||||
type ChartDataContainer = {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2021",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user