Compare commits

...

18 Commits

Author SHA1 Message Date
Sven van Ginkel
7cdd0907e8 [Feature][0.12.0-Beta] Enhance Load Average Display, Charting & Alert Grouping (#960)
* Add 1m load

* update alart dialog

* fix null data

* Remove omit zero

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

* add unit preferences for networking

* adds options for MB/s and bps.

* supports disk throughput unit preferences
2025-07-14 14:46:13 -04:00
Sven van Ginkel
926e807020 [Chore] Fix CI labels (#964) 2025-07-14 14:03:56 -04:00
Sven van Ginkel
d91847c6c5 [Chrore] Improve Issue Templates with Categorization and Label Automation (#939)
* Update templates

* update script

* update labels

* add PR template

* update dropdown
2025-07-13 19:51:29 -04:00
Alexander Mnich
0abd88270c fix docker edge tag (#959) 2025-07-13 11:18:25 -04:00
henrygd
806c4e51c5 v0.12.0-beta2 release 2025-07-12 21:19:57 -04:00
henrygd
6520783fe9 small ui tweaks 2025-07-12 21:16:40 -04:00
henrygd
48c8a3a4a5 update package-lock.json 2025-07-12 21:06:01 -04:00
henrygd
e0c839f78c add skip_upload: auto to goreleaser homebrew config 2025-07-12 19:56:48 -04:00
NeMeow
1ba362bafe Add 5m and 10m load avg alerts and table values (#816)
Co-authored-by: henrygd <hank@henrygd.me>
2025-07-12 19:47:33 -04:00
henrygd
b5d55ead4a send websocket close message to agent 2025-07-12 18:49:40 -04:00
henrygd
4f879ccc66 Fix universal tokens registering multiple systems 2025-07-12 17:11:53 -04:00
78 changed files with 3968 additions and 3030 deletions

View File

@@ -1,8 +1,19 @@
name: 🐛 Bug report name: 🐛 Bug report
description: Report a new bug or issue. description: Report a new bug or issue.
title: '[Bug]: ' title: '[Bug]: '
labels: ['bug'] labels: ['bug', "needs confirmation"]
body: 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 - type: markdown
attributes: attributes:
value: | value: |
@@ -43,6 +54,39 @@ body:
3. Pour it into a cup. 3. Pour it into a cup.
validations: validations:
required: true 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 - type: input
id: system id: system
attributes: attributes:
@@ -61,7 +105,6 @@ body:
id: install-method id: install-method
attributes: attributes:
label: Installation method label: Installation method
default: 0
options: options:
- Docker - Docker
- Binary - Binary

View File

@@ -1,8 +1,19 @@
name: 🚀 Feature request name: 🚀 Feature request
description: Request a new feature or change. description: Request a new feature or change.
title: "[Feature]: " title: "[Feature]: "
labels: ["enhancement"] labels: ["enhancement", "needs review"]
body: 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 - type: markdown
attributes: attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). 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 label: Describe the feature you would like to see
validations: validations:
required: true 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 - type: textarea
attributes: attributes:
label: Describe how you would like to see this feature implemented label: Describe how you would like to see this feature implemented
validations: validations:
required: true 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
View 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.

View File

@@ -65,7 +65,7 @@ jobs:
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: | tags: |
type=edge,enable=true type=raw,value=edge
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}

View 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

View 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
});
}

View File

@@ -119,8 +119,8 @@ scoops:
repository: repository:
owner: henrygd owner: henrygd
name: beszel-scoops name: beszel-scoops
homepage: 'https://beszel.dev' homepage: "https://beszel.dev"
description: 'Agent for Beszel, a lightweight server monitoring platform.' description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT license: MIT
# # Needs choco installed, so doesn't build on linux / default gh workflow :( # # Needs choco installed, so doesn't build on linux / default gh workflow :(
@@ -152,9 +152,10 @@ brews:
repository: repository:
owner: henrygd owner: henrygd
name: homebrew-beszel name: homebrew-beszel
homepage: 'https://beszel.dev' homepage: "https://beszel.dev"
description: 'Agent for Beszel, a lightweight server monitoring platform.' description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT license: MIT
skip_upload: auto
extra_install: | extra_install: |
(bin/"beszel-agent-launcher").write <<~EOS (bin/"beszel-agent-launcher").write <<~EOS
#!/bin/bash #!/bin/bash
@@ -181,12 +182,12 @@ winget:
package_identifier: henrygd.beszel-agent package_identifier: henrygd.beszel-agent
publisher: henrygd publisher: henrygd
license: MIT license: MIT
license_url: 'https://github.com/henrygd/beszel/blob/main/LICENSE' license_url: "https://github.com/henrygd/beszel/blob/main/LICENSE"
copyright: '2025 henrygd' copyright: "2025 henrygd"
homepage: 'https://beszel.dev' homepage: "https://beszel.dev"
release_notes_url: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}' release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
publisher_support_url: 'https://github.com/henrygd/beszel/issues' publisher_support_url: "https://github.com/henrygd/beszel/issues"
short_description: 'Agent for Beszel, a lightweight server monitoring platform.' short_description: "Agent for Beszel, a lightweight server monitoring platform."
skip_upload: auto skip_upload: auto
description: | description: |
Beszel is a lightweight server monitoring platform that includes Docker Beszel is a lightweight server monitoring platform that includes Docker
@@ -218,5 +219,5 @@ changelog:
sort: asc sort: asc
filters: filters:
exclude: exclude:
- '^docs:' - "^docs:"
- '^test:' - "^test:"

View File

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

View File

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

View File

@@ -475,7 +475,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
}, },
{ {
name: "matching beta version should use CBOR", name: "matching beta version should use CBOR",
hubVersion: "0.12.0-beta1", hubVersion: "0.12.0-beta2",
expectedUsesCbor: true, expectedUsesCbor: true,
}, },
} }

View File

@@ -14,6 +14,7 @@ import (
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem" "github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net" psutilNet "github.com/shirou/gopsutil/v4/net"
) )
@@ -77,6 +78,16 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats.Cpu = twoDecimals(cpuPct[0]) systemStats.Cpu = twoDecimals(cpuPct[0])
} }
// load average
if avgstat, err := load.Avg(); err == nil {
systemStats.LoadAvg1 = twoDecimals(avgstat.Load1)
systemStats.LoadAvg5 = twoDecimals(avgstat.Load5)
systemStats.LoadAvg15 = twoDecimals(avgstat.Load15)
slog.Debug("Load average", "5m", systemStats.LoadAvg5, "15m", systemStats.LoadAvg15)
} else {
slog.Error("Error getting load average", "err", err)
}
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
// swap // swap
@@ -240,6 +251,9 @@ 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.LoadAvg15 = systemStats.LoadAvg15
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.Uptime, _ = host.Uptime()

View File

@@ -47,6 +47,9 @@ 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"`
LoadAvg15 float64 `json:"l15"`
} }
type SystemAlertData struct { type SystemAlertData struct {

View File

@@ -54,6 +54,15 @@ 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":
val = data.Info.LoadAvg5
unit = ""
case "LoadAvg15":
val = data.Info.LoadAvg15
unit = ""
} }
triggered := alertRecord.GetBool("triggered") triggered := alertRecord.GetBool("triggered")
@@ -190,6 +199,12 @@ 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":
alert.val += stats.LoadAvg5
case "LoadAvg15":
alert.val += stats.LoadAvg15
default: default:
continue continue
} }
@@ -247,6 +262,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
if alert.name == "Disk" { if alert.name == "Disk" {
alert.name += " usage" alert.name += " usage"
} }
// format LoadAvg5 and LoadAvg15
if after, ok := strings.CutPrefix(alert.name, "LoadAvg"); ok {
alert.name = after + "m Load"
}
// make title alert name lowercase if not CPU // make title alert name lowercase if not CPU
titleAlertName := alert.name titleAlertName := alert.name

View File

@@ -31,6 +31,9 @@ type Stats struct {
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
} }
type GPUData struct { type GPUData struct {
@@ -89,6 +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"`
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 // Final data structure to return to the hub

View File

@@ -5,10 +5,10 @@ import (
"beszel/internal/hub/expirymap" "beszel/internal/hub/expirymap"
"beszel/internal/hub/ws" "beszel/internal/hub/ws"
"errors" "errors"
"fmt"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/blang/semver" "github.com/blang/semver"
@@ -17,118 +17,96 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
// tokenMap maps tokens to user IDs for universal tokens // agentConnectRequest holds information related to an agent's connection attempt.
var tokenMap *expirymap.ExpiryMap[string]
type agentConnectRequest struct { type agentConnectRequest struct {
hub *Hub
req *http.Request
res http.ResponseWriter
token string token string
agentSemVer semver.Version agentSemVer semver.Version
// for universal token // isUniversalToken is true if the token is a universal token.
isUniversalToken bool isUniversalToken bool
userId string // userId is the user ID associated with the universal token.
remoteAddr string userId string
} }
// validateAgentHeaders validates the required headers from agent connection requests. // universalTokenMap stores active universal tokens and their associated user IDs.
func (h *Hub) validateAgentHeaders(headers http.Header) (string, string, error) { var universalTokenMap tokenMap
token := headers.Get("X-Token")
agentVersion := headers.Get("X-Beszel")
if agentVersion == "" || token == "" || len(token) > 512 { type tokenMap struct {
return "", "", errors.New("") store *expirymap.ExpiryMap[string]
} once sync.Once
return token, agentVersion, nil
} }
// getFingerprintRecord retrieves fingerprint data from the database by token. // getMap returns the expirymap, creating it if necessary.
func (h *Hub) getFingerprintRecord(token string, recordData *ws.FingerprintRecord) error { func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] {
err := h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}"). tm.once.Do(func() {
Bind(dbx.Params{ tm.store = expirymap.New[string](time.Hour)
"token": token, })
}). return tm.store
One(recordData)
return err
} }
// sendResponseError sends an HTTP error response with the given status code and message. // handleAgentConnect is the HTTP handler for an agent's connection request.
func sendResponseError(res http.ResponseWriter, code int, message string) error {
res.WriteHeader(code)
if message != "" {
res.Write([]byte(message))
}
return nil
}
// handleAgentConnect handles the incoming connection request from the agent.
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error { func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
if err := h.agentConnect(e.Request, e.Response); err != nil { agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h}
return err _ = agentRequest.agentConnect()
}
return nil return nil
} }
// agentConnect handles agent connection requests, validating credentials and upgrading to WebSocket. // agentConnect validates agent credentials and upgrades the connection to a WebSocket.
func (h *Hub) agentConnect(req *http.Request, res http.ResponseWriter) (err error) { func (acr *agentConnectRequest) agentConnect() (err error) {
var agentConnectRequest agentConnectRequest
var agentVersion string var agentVersion string
// check if user agent and token are valid
agentConnectRequest.token, agentVersion, err = h.validateAgentHeaders(req.Header) acr.token, agentVersion, err = acr.validateAgentHeaders(acr.req.Header)
if err != nil { if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "") return acr.sendResponseError(acr.res, http.StatusBadRequest, "")
} }
// Pull fingerprint from database matching token // Check if token is an active universal token
var fpRecord ws.FingerprintRecord acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
err = h.getFingerprintRecord(agentConnectRequest.token, &fpRecord)
// if no existing record, check if token is a universal token // Find matching fingerprint records for this token
if err != nil { fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
if err = checkUniversalToken(&agentConnectRequest); err == nil { if len(fpRecords) == 0 && !acr.isUniversalToken {
// if this is a universal token, set the remote address and new record token // Invalid token - no records found and not a universal token
agentConnectRequest.remoteAddr = getRealIP(req) return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token")
fpRecord.Token = agentConnectRequest.token
}
}
// If no matching token, return unauthorized
if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "Invalid token")
} }
// Validate agent version // Validate agent version
agentConnectRequest.agentSemVer, err = semver.Parse(agentVersion) acr.agentSemVer, err = semver.Parse(agentVersion)
if err != nil { if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "Invalid agent version") return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid agent version")
} }
// Upgrade connection to WebSocket // Upgrade connection to WebSocket
conn, err := ws.GetUpgrader().Upgrade(res, req) conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req)
if err != nil { if err != nil {
return sendResponseError(res, http.StatusInternalServerError, "WebSocket upgrade failed") return acr.sendResponseError(acr.res, http.StatusInternalServerError, "WebSocket upgrade failed")
} }
go h.verifyWsConn(conn, agentConnectRequest, fpRecord) go acr.verifyWsConn(conn, fpRecords)
return nil return nil
} }
// verifyWsConn verifies the WebSocket connection using agent's fingerprint and SSH key signature. // verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
func (h *Hub) verifyWsConn(conn *gws.Conn, acr agentConnectRequest, fpRecord ws.FingerprintRecord) (err error) { // SSH key signature, then adds the system to the system manager.
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
wsConn := ws.NewWsConnection(conn) wsConn := ws.NewWsConnection(conn)
// must be set before the read loop
// must set wsConn in connection store before the read loop
conn.Session().Store("wsConn", wsConn) conn.Session().Store("wsConn", wsConn)
// make sure connection is closed if there is an error // make sure connection is closed if there is an error
defer func() { defer func() {
if err != nil { if err != nil {
wsConn.Close() wsConn.Close([]byte(err.Error()))
h.Logger().Error("WebSocket error", "error", err, "system", fpRecord.SystemId)
} }
}() }()
go conn.ReadLoop() go conn.ReadLoop()
signer, err := h.GetSSHKey("") signer, err := acr.hub.GetSSHKey("")
if err != nil { if err != nil {
return err return err
} }
@@ -138,40 +116,152 @@ func (h *Hub) verifyWsConn(conn *gws.Conn, acr agentConnectRequest, fpRecord ws.
return err return err
} }
// Create system if using universal token // Find or create the appropriate system for this token and fingerprint
if acr.isUniversalToken { fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint)
if acr.userId == "" { if err != nil {
return errors.New("token user not found") return err
}
fpRecord.SystemId, err = h.createSystemFromAgentData(&acr, agentFingerprint)
if err != nil {
return fmt.Errorf("failed to create system from universal token: %w", err)
}
} }
switch { return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
// If no current fingerprint, update with new fingerprint (first time connecting)
case fpRecord.Fingerprint == "":
if err := h.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return err
}
// Abort if fingerprint exists but doesn't match (different machine)
case fpRecord.Fingerprint != agentFingerprint.Fingerprint:
return errors.New("fingerprint mismatch")
}
return h.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
} }
// createSystemFromAgentData creates a new system record using data from the agent // validateAgentHeaders extracts and validates the token and agent version from HTTP headers.
func (h *Hub) createSystemFromAgentData(acr *agentConnectRequest, agentFingerprint common.FingerprintResponse) (recordId string, err error) { func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) {
systemsCollection, err := h.FindCollectionByNameOrId("systems") token := headers.Get("X-Token")
if err != nil { agentVersion := headers.Get("X-Beszel")
return "", fmt.Errorf("failed to find systems collection: %w", err)
if agentVersion == "" || token == "" || len(token) > 64 {
return "", "", errors.New("")
} }
return token, agentVersion, nil
}
// sendResponseError writes an HTTP error response.
func (acr *agentConnectRequest) sendResponseError(res http.ResponseWriter, code int, message string) error {
res.WriteHeader(code)
if message != "" {
res.Write([]byte(message))
}
return nil
}
// getFingerprintRecordsByToken retrieves all fingerprint records associated with a given token.
func getFingerprintRecordsByToken(token string, h *Hub) []ws.FingerprintRecord {
var records []ws.FingerprintRecord
// All will populate empty slice even on error
_ = h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
Bind(dbx.Params{
"token": token,
}).
All(&records)
return records
}
// findOrCreateSystemForToken finds an existing system matching the token and fingerprint,
// or creates a new one for a universal token.
func (acr *agentConnectRequest) findOrCreateSystemForToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// No records - only valid for active universal tokens
if len(fpRecords) == 0 {
return acr.handleNoRecords(agentFingerprint)
}
// Single record - handle as regular token
if len(fpRecords) == 1 && !acr.isUniversalToken {
return acr.handleSingleRecord(fpRecords[0], agentFingerprint)
}
// Multiple records or universal token - look for matching fingerprint
return acr.handleMultipleRecordsOrUniversalToken(fpRecords, agentFingerprint)
}
// handleNoRecords handles the case where no fingerprint records are found for a token.
// A new system is created if the token is a valid universal token.
func (acr *agentConnectRequest) handleNoRecords(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
var fpRecord ws.FingerprintRecord
if !acr.isUniversalToken || acr.userId == "" {
return fpRecord, errors.New("no matching fingerprints")
}
return acr.createNewSystemForUniversalToken(agentFingerprint)
}
// handleSingleRecord handles the case with a single fingerprint record. It validates
// the agent's fingerprint against the stored one, or sets it on first connect.
func (acr *agentConnectRequest) handleSingleRecord(fpRecord ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// If no current fingerprint, update with new fingerprint (first time connecting)
if fpRecord.Fingerprint == "" {
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return fpRecord, err
}
// Update the record with the fingerprint that was set
fpRecord.Fingerprint = agentFingerprint.Fingerprint
return fpRecord, nil
}
// Abort if fingerprint exists but doesn't match (different machine)
if fpRecord.Fingerprint != agentFingerprint.Fingerprint {
return fpRecord, errors.New("fingerprint mismatch")
}
return fpRecord, nil
}
// handleMultipleRecordsOrUniversalToken finds a matching fingerprint from multiple records.
// If no match is found and the token is a universal token, a new system is created.
func (acr *agentConnectRequest) handleMultipleRecordsOrUniversalToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// Return existing record with matching fingerprint if found
for i := range fpRecords {
if fpRecords[i].Fingerprint == agentFingerprint.Fingerprint {
return fpRecords[i], nil
}
}
// No matching fingerprint record found, but it's
// an active universal token so create a new system
if acr.isUniversalToken {
return acr.createNewSystemForUniversalToken(agentFingerprint)
}
return ws.FingerprintRecord{}, errors.New("fingerprint mismatch")
}
// createNewSystemForUniversalToken creates a new system and fingerprint record for a universal token.
func (acr *agentConnectRequest) createNewSystemForUniversalToken(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
var fpRecord ws.FingerprintRecord
if !acr.isUniversalToken || acr.userId == "" {
return fpRecord, errors.New("invalid token")
}
fpRecord.Token = acr.token
systemId, err := acr.createSystem(agentFingerprint)
if err != nil {
return fpRecord, err
}
fpRecord.SystemId = systemId
// Set the fingerprint for the new system
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return fpRecord, err
}
// Update the record with the fingerprint that was set
fpRecord.Fingerprint = agentFingerprint.Fingerprint
return fpRecord, nil
}
// createSystem creates a new system record in the database using details from the agent.
func (acr *agentConnectRequest) createSystem(agentFingerprint common.FingerprintResponse) (recordId string, err error) {
systemsCollection, err := acr.hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return "", err
}
remoteAddr := getRealIP(acr.req)
// separate port from address // separate port from address
if agentFingerprint.Hostname == "" { if agentFingerprint.Hostname == "" {
agentFingerprint.Hostname = acr.remoteAddr agentFingerprint.Hostname = remoteAddr
} }
if agentFingerprint.Port == "" { if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876" agentFingerprint.Port = "45876"
@@ -179,14 +269,14 @@ func (h *Hub) createSystemFromAgentData(acr *agentConnectRequest, agentFingerpri
// create new record // create new record
systemRecord := core.NewRecord(systemsCollection) systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname) systemRecord.Set("name", agentFingerprint.Hostname)
systemRecord.Set("host", acr.remoteAddr) systemRecord.Set("host", remoteAddr)
systemRecord.Set("port", agentFingerprint.Port) systemRecord.Set("port", agentFingerprint.Port)
systemRecord.Set("users", []string{acr.userId}) systemRecord.Set("users", []string{acr.userId})
return systemRecord.Id, h.Save(systemRecord) return systemRecord.Id, acr.hub.Save(systemRecord)
} }
// SetFingerprint updates the fingerprint for a given record ID. // SetFingerprint creates or updates a fingerprint record in the database.
func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) { func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) {
// // can't use raw query here because it doesn't trigger SSE // // can't use raw query here because it doesn't trigger SSE
var record *core.Record var record *core.Record
@@ -207,25 +297,8 @@ func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string)
return h.SaveNoValidate(record) return h.SaveNoValidate(record)
} }
func getTokenMap() *expirymap.ExpiryMap[string] { // getRealIP extracts the client's real IP address from request headers,
if tokenMap == nil { // checking common proxy headers before falling back to the remote address.
tokenMap = expirymap.New[string](time.Hour)
}
return tokenMap
}
func checkUniversalToken(acr *agentConnectRequest) (err error) {
if tokenMap == nil {
tokenMap = expirymap.New[string](time.Hour)
}
acr.userId, acr.isUniversalToken = tokenMap.GetOk(acr.token)
if !acr.isUniversalToken {
return errors.New("invalid token")
}
return nil
}
// getRealIP attempts to extract the real IP address from the request headers.
func getRealIP(r *http.Request) string { func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip return ip

File diff suppressed because it is too large Load Diff

View File

@@ -259,7 +259,7 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
} }
tokenMap := getTokenMap() tokenMap := universalTokenMap.GetMap()
userID := info.Auth.Id userID := info.Auth.Id
query := e.Request.URL.Query() query := e.Request.URL.Query()
token := query.Get("token") token := query.Get("token")

View File

@@ -254,5 +254,3 @@ func TestGetSSHKey(t *testing.T) {
} }
}) })
} }
// Helper function to create test records

View File

@@ -365,7 +365,7 @@ func (sys *System) closeSSHConnection() {
// The system will be set as down a few seconds later if the connection is not re-established. // The system will be set as down a few seconds later if the connection is not re-established.
func (sys *System) closeWebSocketConnection() { func (sys *System) closeWebSocketConnection() {
if sys.WsConn != nil { if sys.WsConn != nil {
sys.WsConn.Close() sys.WsConn.Close(nil)
} }
} }

View File

@@ -77,7 +77,7 @@ func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
case wsConn.(*WsConn).responseChan <- message: case wsConn.(*WsConn).responseChan <- message:
default: default:
// close if the connection is not expecting a response // close if the connection is not expecting a response
wsConn.(*WsConn).Close() wsConn.(*WsConn).Close(nil)
} }
} }
@@ -100,9 +100,9 @@ func (h *Handler) OnClose(conn *gws.Conn, err error) {
} }
// Close terminates the WebSocket connection gracefully. // Close terminates the WebSocket connection gracefully.
func (ws *WsConn) Close() { func (ws *WsConn) Close(msg []byte) {
if ws.IsConnected() { if ws.IsConnected() {
ws.conn.WriteClose(1000, nil) ws.conn.WriteClose(1000, msg)
} }
} }
@@ -130,7 +130,7 @@ func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
}) })
select { select {
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
ws.Close() ws.Close(nil)
return gws.ErrConnClosed return gws.ErrConnClosed
case message = <-ws.responseChan: case message = <-ws.responseChan:
} }
@@ -140,11 +140,12 @@ func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint. // GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) { func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
var clientFingerprint common.FingerprintResponse
challenge := []byte(token) challenge := []byte(token)
signature, err := signer.Sign(nil, challenge) signature, err := signer.Sign(nil, challenge)
if err != nil { if err != nil {
return common.FingerprintResponse{}, err return clientFingerprint, err
} }
err = ws.sendMessage(common.HubRequest[any]{ err = ws.sendMessage(common.HubRequest[any]{
@@ -155,24 +156,19 @@ func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bo
}, },
}) })
if err != nil { if err != nil {
return common.FingerprintResponse{}, err return clientFingerprint, err
} }
var message *gws.Message var message *gws.Message
var clientFingerprint common.FingerprintResponse
select { select {
case message = <-ws.responseChan: case message = <-ws.responseChan:
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
return common.FingerprintResponse{}, errors.New("request expired") return clientFingerprint, errors.New("request expired")
} }
defer message.Close() defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint) err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
if err != nil { return clientFingerprint, err
return common.FingerprintResponse{}, err
}
return clientFingerprint, nil
} }
// IsConnected returns true if the WebSocket connection is active. // IsConnected returns true if the WebSocket connection is active.

View File

@@ -17,7 +17,9 @@ type UserSettings struct {
ChartTime string `json:"chartTime"` ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"` NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"` NotificationWebhooks []string `json:"webhooks"`
// 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
} }
func NewUserManager(app core.App) *UserManager { func NewUserManager(app core.App) *UserManager {
@@ -39,7 +41,6 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record record := e.Record
// intialize settings with defaults // intialize settings with defaults
settings := UserSettings{ settings := UserSettings{
// Language: "en",
ChartTime: "1h", ChartTime: "1h",
NotificationEmails: []string{}, NotificationEmails: []string{},
NotificationWebhooks: []string{}, NotificationWebhooks: []string{},

View File

@@ -75,7 +75,10 @@ func init() {
"Memory", "Memory",
"Disk", "Disk",
"Temperature", "Temperature",
"Bandwidth" "Bandwidth",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
] ]
}, },
{ {

View File

@@ -5,7 +5,7 @@ import (
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
) )
var ( const (
TempAdminEmail = "_@b.b" TempAdminEmail = "_@b.b"
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.12.0-beta1", "version": "0.12.0-beta2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -11,13 +11,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react" import { BellIcon, GlobeIcon, ServerIcon, HourglassIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils" import { alertInfo, cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types" import { AlertRecord, SystemRecord } from "@/types"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox" import { Checkbox } from "../ui/checkbox"
import { Collapsible } from "../ui/collapsible"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system" import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
@@ -39,13 +40,33 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
/> />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-full overflow-auto max-w-[35rem]"> <DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-[37rem]">
{opened && <AlertDialogContent system={system} />} {opened && <AlertDialogContent system={system} />}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
), ),
[opened, hasAlert] [opened, hasAlert]
) )
// return useMemo(
// () => (
// <Sheet>
// <SheetTrigger asChild>
// <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
// <BellIcon
// className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
// "fill-primary": hasAlert,
// })}
// />
// </Button>
// </SheetTrigger>
// <SheetContent className="max-h-full overflow-auto w-[35em] p-4 sm:p-5">
// {opened && <AlertDialogContent system={system} />}
// </SheetContent>
// </Sheet>
// ),
// [opened, hasAlert]
// )
}) })
function AlertDialogContent({ system }: { system: SystemRecord }) { function AlertDialogContent({ system }: { system: SystemRecord }) {

View File

@@ -217,7 +217,7 @@ function AlertContent({ data }: { data: AlertData }) {
const [checked, setChecked] = useState(data.checked || false) const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || 10) const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80)) const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
const Icon = alertInfo[name].icon const Icon = alertInfo[name].icon
@@ -268,7 +268,8 @@ function AlertContent({ data }: { data: AlertData }) {
onValueChange={(val) => { onValueChange={(val) => {
setValue(val[0]) setValue(val[0])
}} }}
min={1} step={data.alert.step ?? 1}
min={data.alert.min ?? 1}
max={alertInfo[name].max ?? 99} max={alertInfo[name].max ?? 99}
/> />
</div> </div>

View File

@@ -2,14 +2,7 @@ import { t } from "@lingui/core/macro"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
@@ -30,7 +23,6 @@ const getNestedValue = (path: string, max = false, data: any): number | null =>
export default memo(function AreaChartDefault({ export default memo(function AreaChartDefault({
maxToggled = false, maxToggled = false,
unit = " MB/s",
chartName, chartName,
chartData, chartData,
max, max,
@@ -38,12 +30,11 @@ export default memo(function AreaChartDefault({
contentFormatter, contentFormatter,
}: { }: {
maxToggled?: boolean maxToggled?: boolean
unit?: string
chartName: string chartName: string
chartData: ChartData chartData: ChartData
max?: number max?: number
tickFormatter?: (value: number) => string tickFormatter: (value: number) => string
contentFormatter?: (value: number) => string contentFormatter: ({ value }: { value: number }) => string
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui() const { i18n } = useLingui()
@@ -98,15 +89,7 @@ export default memo(function AreaChartDefault({
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
domain={[0, max ?? "auto"]} domain={[0, max ?? "auto"]}
tickFormatter={(value) => { tickFormatter={(value) => updateYAxisWidth(tickFormatter(value))}
let val: string
if (tickFormatter) {
val = tickFormatter(value)
} else {
val = toFixedWithoutTrailingZeros(value, 2) + unit
}
return updateYAxisWidth(val)
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
/> />
@@ -117,12 +100,7 @@ export default memo(function AreaChartDefault({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => { contentFormatter={contentFormatter}
if (contentFormatter) {
return contentFormatter(value)
}
return decimalString(value) + unit
}}
// indicator="line" // indicator="line"
/> />
} }

View File

@@ -1,22 +1,13 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
import { import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
useYAxisWidth,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores" import { $containerFilter, $userSettings } from "@/lib/stores"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
import { ChartType } from "@/lib/enums" import { ChartType, Unit } from "@/lib/enums"
export default memo(function ContainerChart({ export default memo(function ContainerChart({
dataKey, dataKey,
@@ -30,6 +21,7 @@ export default memo(function ContainerChart({
unit?: string unit?: string
}) { }) {
const filter = useStore($containerFilter) const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData const { containerData } = chartData
@@ -84,13 +76,14 @@ export default memo(function ContainerChart({
// tick formatter // tick formatter
if (chartType === ChartType.CPU) { if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => { obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit const val = toFixedFloat(value, 2) + unit
return updateYAxisWidth(val) return updateYAxisWidth(val)
} }
} else { } else {
obj.tickFormatter = (value) => { const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
const { v, u } = getSizeAndUnit(value, false) obj.tickFormatter = (val) => {
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`) const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
} }
} }
// tooltip formatter // tooltip formatter
@@ -99,12 +92,14 @@ export default memo(function ContainerChart({
try { try {
const sent = item?.payload?.[key]?.ns ?? 0 const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 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 ( return (
<span className="flex"> <span className="flex">
{decimalString(received)} MB/s {decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span> <span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" /> <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 className="opacity-70 ms-0.5"> tx</span>
</span> </span>
) )
@@ -114,8 +109,8 @@ export default memo(function ContainerChart({
} }
} else if (chartType === ChartType.Memory) { } else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => { obj.toolTipFormatter = (item: any) => {
const { v, u } = getSizeAndUnit(item.value, false) const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return decimalString(v, 2) + u return decimalString(value) + " " + unit
} }
} else { } else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } 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
}
/** Format load average data for chart */
const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
// Define colors for the three load average lines
const colors = {
"1m": "hsl(25, 95%, 53%)", // Orange for 1-minute
"5m": "hsl(217, 91%, 60%)", // Blue for 5-minute
"15m": "hsl(271, 81%, 56%)", // Purple for 15-minute
}
for (let data of chartData.systemStats) {
let newData = { created: data.created } as Record<string, number | string>
// Add load average values if they exist and stats is not null
if (data.stats && data.stats.l1 !== undefined) {
newData["1m"] = data.stats.l1
}
if (data.stats && data.stats.l5 !== undefined) {
newData["5m"] = data.stats.l5
}
if (data.stats && data.stats.l15 !== undefined) {
newData["15m"] = data.stats.l15
}
newChartData.data.push(newData)
}
newChartData.colors = colors
return newChartData
}, [chartData])
const loadKeys = ["1m", "5m", "15m"].filter(key =>
newChartData.data.some(data => data[key] !== undefined)
)
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val)
}}
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)}
/>
}
/>
{loadKeys.map((key) => (
<Line
key={key}
dataKey={key}
name={key === "1m" ? t`1 min` : key === "5m" ? t`5 min` : t`15 min`}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
isAnimationActive={false}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,9 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils" import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { memo } from "react" import { memo } from "react"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { Unit } from "@/lib/enums"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) { export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
@@ -39,8 +40,8 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => { tickFormatter={(value) => {
const val = toFixedFloat(value, 1) const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(val + " GB") return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}} }}
/> />
)} )}
@@ -54,8 +55,11 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.order - b.order} itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"} contentFormatter={({ value }) => {
// indicator="line" // 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
}}
/> />
} }
/> />

View File

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

View File

@@ -12,17 +12,19 @@ import {
useYAxisWidth, useYAxisWidth,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedFloat,
decimalString,
chartMargin, chartMargin,
formatTemperature,
decimalString,
} from "@/lib/utils" } from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
import { $temperatureFilter } from "@/lib/stores" import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
const filter = useStore($temperatureFilter) const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) { if (chartData.systemStats.length === 0) {
@@ -72,9 +74,9 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
className="tracking-tighter" className="tracking-tighter"
domain={[0, "auto"]} domain={[0, "auto"]}
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(value) => { tickFormatter={(val) => {
const val = toFixedWithoutTrailingZeros(value, 2) const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return updateYAxisWidth(val + " °C") return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
}} }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
@@ -88,7 +90,10 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} 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} filter={filter}
/> />
} }

View File

@@ -11,7 +11,7 @@ import { useState } from "react"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
// import { setLang } from "@/lib/i18n" import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -101,6 +101,87 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p> </p>
</div> </div>
<Separator /> <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 />
<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>

View File

@@ -63,7 +63,7 @@ export default function SettingsLayout() {
title: t`Tokens & Fingerprints`, title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }), href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon, icon: FingerprintIcon,
// admin: true, noReadOnly: true,
}, },
{ {
title: t`YAML Config`, title: t`YAML Config`,
@@ -95,8 +95,8 @@ export default function SettingsLayout() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<Separator className="hidden md:block my-5" /> <Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10"> <div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-12">
<aside className="md:max-w-44 min-w-40"> <aside className="md:max-w-52 min-w-40">
<SidebarNav items={sidebarNavItems} /> <SidebarNav items={sidebarNavItems} />
</aside> </aside>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import {
$temperatureFilter, $temperatureFilter,
} from "@/lib/stores" } from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import { ChartType, Os } from "@/lib/enums" import { ChartType, Unit, Os } from "@/lib/enums"
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card" import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -21,9 +21,10 @@ import ChartTimeSelect from "../charts/chart-time-select"
import { import {
chartTimeData, chartTimeData,
cn, cn,
decimalString,
formatBytes,
getHostDisplayValue, getHostDisplayValue,
getPbTimestamp, getPbTimestamp,
getSizeAndUnit,
listen, listen,
toFixedFloat, toFixedFloat,
useLocalStorage, useLocalStorage,
@@ -47,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>()
@@ -131,6 +133,7 @@ export default function SystemDetail({ name }: { name: string }) {
const [bottomSpacing, setBottomSpacing] = useState(0) const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true) const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h" const isLongerChart = chartTime !== "1h"
const userSettings = $userSettings.get()
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
@@ -472,9 +475,27 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Average system-wide CPU utilization`} description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect} 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> </ChartCard>
{/* Load Average chart */}
{(systemStats.at(-1)?.stats.l1 !== undefined || systemStats.at(-1)?.stats.l5 !== undefined || systemStats.at(-1)?.stats.l15 !== undefined) && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
>
<LoadAverageChart chartData={chartData} />
</ChartCard>
)}
{containerFilterBar && ( {containerFilterBar && (
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
@@ -519,7 +540,19 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Throughput of root filesystem`} description={t`Throughput of root filesystem`}
cornerEl={maxValSelect} 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>
<ChartCard <ChartCard
@@ -529,7 +562,19 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={maxValSelect} cornerEl={maxValSelect}
description={t`Network traffic of public interfaces`} 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> </ChartCard>
{containerFilterBar && containerData.length > 0 && ( {containerFilterBar && containerData.length > 0 && (
@@ -594,10 +639,6 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="grid xl:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => { {Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
const sizeFormatter = (value: number, decimals?: number) => {
const { v, u } = getSizeAndUnit(value, false)
return toFixedFloat(v, decimals || 1) + u
}
return ( return (
<div key={id} className="contents"> <div key={id} className="contents">
<ChartCard <ChartCard
@@ -606,7 +647,12 @@ export default function SystemDetail({ name }: { name: string }) {
title={`${gpu.n} ${t`Usage`}`} title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`} 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>
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
@@ -618,8 +664,14 @@ export default function SystemDetail({ name }: { name: string }) {
chartData={chartData} chartData={chartData}
chartName={`g.${id}.mu`} chartName={`g.${id}.mu`}
max={gpu.mt} max={gpu.mt}
tickFormatter={sizeFormatter} tickFormatter={(val) => {
contentFormatter={(value) => sizeFormatter(value, 2)} 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> </ChartCard>
</div> </div>
@@ -653,7 +705,19 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Throughput of ${extraFsName}`} description={t`Throughput of ${extraFsName}`}
cornerEl={maxValSelect} 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> </ChartCard>
</div> </div>
) )

View File

@@ -63,12 +63,20 @@ import {
PenBoxIcon, PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "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 { 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 AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router" import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useLingui, Trans } from "@lingui/react/macro" import { useLingui, Trans } from "@lingui/react/macro"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input" import { Input } from "../ui/input"
@@ -76,6 +84,7 @@ import { ClassValue } from "clsx"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { SystemDialog } from "../add-system" import { SystemDialog } from "../add-system"
import { Dialog } from "../ui/dialog" import { Dialog } from "../ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid"
@@ -83,8 +92,8 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = (info.getValue() as number) || 0 const val = (info.getValue() as number) || 0
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight"> <div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-[3.3em]">{decimalString(val, 1)}%</span> <span className="min-w-8">{decimalString(val, 1)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden"> <span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span <span
className={cn( className={cn(
"absolute inset-0 w-full h-full origin-left", "absolute inset-0 w-full h-full origin-left",
@@ -144,7 +153,6 @@ export default function SystemsTable() {
} }
return [ return [
{ {
// size: 200,
size: 200, size: 200,
minSize: 0, minSize: 0,
accessorKey: "name", accessorKey: "name",
@@ -163,6 +171,7 @@ export default function SystemsTable() {
return false return false
}, },
enableHiding: false, enableHiding: false,
invertSorting: false,
Icon: ServerIcon, Icon: ServerIcon,
cell: (info) => ( cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5"> <span className="flex gap-0.5 items-center text-base md:pe-5">
@@ -181,28 +190,26 @@ export default function SystemsTable() {
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.cpu", accessorFn: (originalRow) => originalRow.info.cpu,
id: "cpu", id: "cpu",
name: () => t`CPU`, name: () => t`CPU`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
Icon: CpuIcon, Icon: CpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.mp", // accessorKey: "info.mp",
accessorFn: (originalRow) => originalRow.info.mp,
id: "memory", id: "memory",
name: () => t`Memory`, name: () => t`Memory`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
Icon: MemoryStickIcon, Icon: MemoryStickIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.dp", accessorFn: (originalRow) => originalRow.info.dp,
id: "disk", id: "disk",
name: () => t`Disk`, name: () => t`Disk`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
Icon: HardDriveIcon, Icon: HardDriveIcon,
header: sortableHeader, header: sortableHeader,
@@ -211,8 +218,6 @@ export default function SystemsTable() {
accessorFn: (originalRow) => originalRow.info.g, accessorFn: (originalRow) => originalRow.info.g,
id: "gpu", id: "gpu",
name: () => "GPU", name: () => "GPU",
invertSorting: true,
sortUndefined: -1,
cell: CellFormatter, cell: CellFormatter,
Icon: GpuIcon, Icon: GpuIcon,
header: sortableHeader, header: sortableHeader,
@@ -221,29 +226,81 @@ export default function SystemsTable() {
accessorFn: (originalRow) => originalRow.info.b || 0, accessorFn: (originalRow) => originalRow.info.b || 0,
id: "net", id: "net",
name: () => t`Net`, name: () => t`Net`,
invertSorting: true, size: 0,
size: 50,
Icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const val = info.getValue() as number const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true)
return ( return (
<span <span className="tabular-nums whitespace-nowrap">
className={cn("tabular-nums whitespace-nowrap", { {decimalString(value, value >= 100 ? 1 : 2)} {unit}
"ps-1": viewMode === "table",
})}
>
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
</span> </span>
) )
}, },
}, },
{
id: "loadAverage",
name: () => t`Load Average`,
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const system = info.row.original;
const l1 = system.info?.l1;
const l5 = system.info?.l5;
const l15 = system.info?.l15;
const cores = system.info?.c || 1;
// If no load average data, return null
if (!l1 && !l5 && !l15) return null;
const loadAverages = [
{ name: "1m", value: l1 },
{ name: "5m", value: l5 },
{ name: "15m", value: l15 }
].filter(la => la.value !== undefined);
if (!loadAverages.length) return null;
function getDotColor(value: number) {
const normalized = value / cores;
if (normalized < 0.7) return "bg-green-500";
if (normalized < 1.0) return "bg-orange-500";
return "bg-red-600";
}
return (
<div className="flex items-center gap-2 w-full">
{loadAverages.map((la, idx) => (
<TooltipProvider key={la.name}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center cursor-pointer">
<span className={cn("inline-block w-2 h-2 rounded-full mr-1", getDotColor(la.value || 0))} />
<span className="tabular-nums">
{decimalString(la.value || 0, 2)}
</span>
{idx < loadAverages.length - 1 && <span className="mx-1 text-muted-foreground">/</span>}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="text-center">
<div className="font-medium">{t`${la.name}`}</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
);
},
},
{ {
accessorFn: (originalRow) => originalRow.info.dt, accessorFn: (originalRow) => originalRow.info.dt,
id: "temp", id: "temp",
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }), name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
invertSorting: true,
sortUndefined: -1,
size: 50, size: 50,
hideSort: true, hideSort: true,
Icon: ThermometerIcon, Icon: ThermometerIcon,
@@ -253,22 +310,20 @@ export default function SystemsTable() {
if (!val) { if (!val) {
return null return null
} }
const userSettings = useStore($userSettings)
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return ( return (
<span <span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
className={cn("tabular-nums whitespace-nowrap", { {decimalString(value, value >= 100 ? 1 : 2)} {unit}
"ps-1.5": viewMode === "table",
})}
>
{decimalString(val)} °C
</span> </span>
) )
}, },
}, },
{ {
accessorKey: "info.v", accessorFn: (originalRow) => originalRow.info.v,
id: "agent", id: "agent",
name: () => t`Agent`, name: () => t`Agent`,
invertSorting: true, // invertSorting: true,
size: 50, size: 50,
Icon: WifiIcon, Icon: WifiIcon,
hideSort: true, hideSort: true,
@@ -280,11 +335,7 @@ export default function SystemsTable() {
} }
const system = info.row.original const system = info.row.original
return ( return (
<span <span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
"ps-1": viewMode === "table",
})}
>
<IndicatorDot <IndicatorDot
system={system} system={system}
className={ className={
@@ -304,7 +355,7 @@ export default function SystemsTable() {
name: () => t({ message: "Actions", comment: "Table column" }), name: () => t({ message: "Actions", comment: "Table column" }),
size: 50, size: 50,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end items-center gap-1"> <div className="flex justify-end items-center gap-1 -ms-3">
<AlertsButton system={row.original} /> <AlertsButton system={row.original} />
<ActionsButton system={row.original} /> <ActionsButton system={row.original} />
</div> </div>
@@ -328,6 +379,9 @@ export default function SystemsTable() {
columnVisibility, columnVisibility,
}, },
defaultColumn: { defaultColumn: {
// sortDescFirst: true,
invertSorting: true,
sortUndefined: "last",
minSize: 0, minSize: 0,
size: 900, size: 900,
maxSize: 900, maxSize: 900,
@@ -511,7 +565,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-2" key={header.id}> <TableHead className="px-1" key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(header.column.columnDef.header, header.getContext())}
</TableHead> </TableHead>
) )

View File

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

View File

@@ -121,3 +121,12 @@ export function GpuIcon(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
// Remix icons (Apache 2.0) https://github.com/Remix-Design/RemixIcon/blob/master/License
export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M4 2h16v4.5L13.5 12l6.5 5.5V22H4v-4.5l6.5-5.5L4 6.5zm12.3 5L18 5.5V4H6v1.5L7.7 7zM12 13.3l-6 5.2V20h1l5-3 5 3h1v-1.5z" />
</svg>
)
}

View File

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

View File

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

View File

@@ -3,14 +3,23 @@ import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores" 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 { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores" import { WritableAtom } from "nanostores"
import { timeDay, timeHour } from "d3-time" import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons" import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
import { prependBasePath } from "@/components/router" import { prependBasePath } from "@/components/router"
import { Unit } from "./enums"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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. */ /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() { export async function logOut() {
sessionStorage.setItem("lo", "t") $systems.set([])
$alerts.set([])
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear() pb.authStore.clear()
pb.realtime.unsubscribe() pb.realtime.unsubscribe()
} }
@@ -225,17 +237,17 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth } return { yAxisWidth, updateYAxisWidth }
} }
export function toFixedWithoutTrailingZeros(num: number, digits: number) { /** Format number to x decimal places, without trailing zeros */
return parseFloat(num.toFixed(digits)).toString()
}
export function toFixedFloat(num: number, digits: number) { export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits)) return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
} }
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map() let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places */ /** Format number to x decimal places, maintaining trailing zeros */
export function decimalString(num: number, digits = 2) { export function decimalString(num: number, digits = 2) {
if (digits === 0) {
return Math.ceil(num).toString()
}
let formatter = decimalFormatters.get(digits) let formatter = decimalFormatters.get(digits)
if (!formatter) { if (!formatter) {
formatter = new Intl.NumberFormat(undefined, { formatter = new Intl.NumberFormat(undefined, {
@@ -266,40 +278,93 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
return [value, setValue] 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() { export async function updateUserSettings() {
try { try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" }) const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings) $userSettings.set(req.settings)
return return
} catch (e) { } catch (e) {
console.log("get settings", e) console.error("get settings", e)
} }
// create user settings if error fetching existing // create user settings if error fetching existing
try { try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id }) const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings) $userSettings.set(createdSettings.settings)
} catch (e) { } catch (e) {
console.log("create settings", e) console.error("create settings", e)
} }
} }
/**
* Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes or megabytes
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) {
return { v: sizeInGB / 1_000, u: " TB" }
} else if (sizeInGB >= 1) {
return { v: sizeInGB, u: " GB" }
}
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
}
export const chartMargin = { top: 12 } export const chartMargin = { top: 12 }
export const alertInfo: Record<string, AlertInfo> = { export const alertInfo: Record<string, AlertInfo> = {
@@ -342,6 +407,36 @@ 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: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
} }
/** /**
@@ -360,3 +455,27 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */ /** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>() export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/**
* Calculate load average percentage relative to CPU cores
* @param loadAverage - The load average value (1m, 5m, or 15m)
* @param cores - Number of CPU cores
* @returns Percentage (0-100) representing CPU utilization
*/
export const calculateLoadAveragePercent = (loadAverage: number, cores: number): number => {
if (!loadAverage || !cores) return 0
return Math.min((loadAverage / cores) * 100, 100)
}
/**
* Get load average opacity based on utilization relative to cores
* @param loadAverage - The load average value
* @param cores - Number of CPU cores
* @returns Opacity value (0.6, 0.8, or 1.0)
*/
export const getLoadAverageOpacity = (loadAverage: number, cores: number): number => {
if (!loadAverage || !cores) return 0.6
if (loadAverage < cores * 0.5) return 0.6
if (loadAverage < cores) return 0.8
return 1.0
}

View File

@@ -448,6 +448,16 @@ msgstr "عنوان البريد الإشباكي غير صالح."
msgid "Kernel" msgid "Kernel"
msgstr "النواة" msgstr "النواة"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "اللغة" msgstr "اللغة"
@@ -461,6 +471,14 @@ msgstr "التخطيط"
msgid "Light" msgid "Light"
msgstr "فاتح" msgstr "فاتح"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "تسجيل الخروج" msgstr "تسجيل الخروج"
@@ -828,6 +846,14 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور." msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز أي مستشعر عتبة معينة" msgstr "يتم التفعيل عندما يتجاوز أي مستشعر عتبة معينة"

View File

@@ -448,6 +448,16 @@ msgstr "Невалиден имейл адрес."
msgid "Kernel" msgid "Kernel"
msgstr "Linux Kernel" msgstr "Linux Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Език" msgstr "Език"
@@ -461,6 +471,14 @@ msgstr "Подреждане"
msgid "Light" msgid "Light"
msgstr "Светъл" msgstr "Светъл"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Изход" msgstr "Изход"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Задейства се, когато някой даден сензор надвиши зададен праг" msgstr "Задейства се, когато някой даден сензор надвиши зададен праг"

View File

@@ -448,6 +448,16 @@ msgstr "Neplatná e-mailová adresa."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Jazyk" msgstr "Jazyk"
@@ -461,6 +471,14 @@ msgstr "Rozvržení"
msgid "Light" msgid "Light"
msgstr "Světlý" msgstr "Světlý"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Odhlásit" msgstr "Odhlásit"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Spustí se, když některý senzor překročí prahovou hodnotu" msgstr "Spustí se, když některý senzor překročí prahovou hodnotu"

View File

@@ -448,6 +448,16 @@ msgstr "Ugyldig email adresse."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Sprog" msgstr "Sprog"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light" msgid "Light"
msgstr "Lys" msgstr "Lys"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Log ud" msgstr "Log ud"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Udløser når en sensor overstiger en tærskel" msgstr "Udløser når en sensor overstiger en tærskel"

View File

@@ -448,6 +448,16 @@ msgstr "Ungültige E-Mail-Adresse."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Sprache" msgstr "Sprache"
@@ -461,6 +471,14 @@ msgstr "Anordnung"
msgid "Light" msgid "Light"
msgstr "Hell" msgstr "Hell"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Abmelden" msgstr "Abmelden"
@@ -828,6 +846,14 @@ msgstr "Tokens ermöglichen es Agents, sich zu verbinden und zu registrieren. Fi
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren." msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet" msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"

View File

@@ -443,6 +443,16 @@ msgstr "Invalid email address."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr "L15"
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr "L5"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Language" msgstr "Language"
@@ -456,6 +466,14 @@ msgstr "Layout"
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr "Load Average 15m"
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr "Load Average 5m"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Log Out" msgstr "Log Out"
@@ -823,6 +841,14 @@ msgstr "Tokens allow agents to connect and register. Fingerprints are stable ide
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr "Triggers when 15 minute load average exceeds a threshold"
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr "Triggers when 5 minute load average exceeds a threshold"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggers when any sensor exceeds a threshold" msgstr "Triggers when any sensor exceeds a threshold"

View File

@@ -448,6 +448,16 @@ msgstr "Dirección de correo electrónico no válida."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
@@ -461,6 +471,14 @@ msgstr "Diseño"
msgid "Light" msgid "Light"
msgstr "Claro" msgstr "Claro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Cerrar Sesión" msgstr "Cerrar Sesión"
@@ -828,6 +846,14 @@ msgstr "Los tokens permiten que los agentes se conecten y registren. Las huellas
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub." msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Se activa cuando cualquier sensor supera un umbral" msgstr "Se activa cuando cualquier sensor supera un umbral"

View File

@@ -448,6 +448,16 @@ msgstr "آدرس ایمیل نامعتبر است."
msgid "Kernel" msgid "Kernel"
msgstr "هسته" msgstr "هسته"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "زبان" msgstr "زبان"
@@ -461,6 +471,14 @@ msgstr "طرح‌بندی"
msgid "Light" msgid "Light"
msgstr "روشن" msgstr "روشن"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "خروج" msgstr "خروج"
@@ -828,6 +846,14 @@ msgstr "توکن‌ها به عامل‌ها اجازه اتصال و ثبت‌
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "توکن‌ها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده می‌شوند." msgstr "توکن‌ها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده می‌شوند."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "هنگامی که هر حسگری از یک آستانه فراتر رود، فعال می‌شود" msgstr "هنگامی که هر حسگری از یک آستانه فراتر رود، فعال می‌شود"

View File

@@ -448,6 +448,16 @@ msgstr "Adresse email invalide."
msgid "Kernel" msgid "Kernel"
msgstr "Noyau" msgstr "Noyau"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Langue" msgstr "Langue"
@@ -461,6 +471,14 @@ msgstr "Disposition"
msgid "Light" msgid "Light"
msgstr "Clair" msgstr "Clair"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Déconnexion" msgstr "Déconnexion"
@@ -828,6 +846,14 @@ msgstr "Les tokens permettent aux agents de se connecter et de s'enregistrer. Le
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connexions WebSocket vers le hub." msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connexions WebSocket vers le hub."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Déclenchement lorsque tout capteur dépasse un seuil" msgstr "Déclenchement lorsque tout capteur dépasse un seuil"

View File

@@ -448,6 +448,16 @@ msgstr "Nevažeća adresa e-pošte."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Jezik" msgstr "Jezik"
@@ -461,6 +471,14 @@ msgstr "Izgled"
msgid "Light" msgid "Light"
msgstr "Svijetlo" msgstr "Svijetlo"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Odjava" msgstr "Odjava"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Pokreće se kada bilo koji senzor prijeđe prag" msgstr "Pokreće se kada bilo koji senzor prijeđe prag"

View File

@@ -448,6 +448,16 @@ msgstr "Érvénytelen e-mail cím."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Nyelv" msgstr "Nyelv"
@@ -461,6 +471,14 @@ msgstr "Elrendezés"
msgid "Light" msgid "Light"
msgstr "Világos" msgstr "Világos"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Kijelentkezés" msgstr "Kijelentkezés"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Bekapcsol, ha bármelyik érzékelő túllép egy küszöbértéket" msgstr "Bekapcsol, ha bármelyik érzékelő túllép egy küszöbértéket"

View File

@@ -448,6 +448,16 @@ msgstr "Ógilt netfang."
msgid "Kernel" msgid "Kernel"
msgstr "" msgstr ""
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Tungumál" msgstr "Tungumál"
@@ -461,6 +471,14 @@ msgstr ""
msgid "Light" msgid "Light"
msgstr "Ljóst" msgstr "Ljóst"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Útskrá" msgstr "Útskrá"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Virkjast þegar einhver skynjari fer yfir þröskuld" msgstr "Virkjast þegar einhver skynjari fer yfir þröskuld"

View File

@@ -448,6 +448,16 @@ msgstr "Indirizzo email non valido."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Lingua" msgstr "Lingua"
@@ -461,6 +471,14 @@ msgstr "Aspetto"
msgid "Light" msgid "Light"
msgstr "Chiaro" msgstr "Chiaro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Disconnetti" msgstr "Disconnetti"
@@ -828,6 +846,14 @@ msgstr "I token consentono agli agenti di connettersi e registrarsi. Le impronte
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "I token e le impronte digitali vengono utilizzati per autenticare le connessioni WebSocket all'hub." msgstr "I token e le impronte digitali vengono utilizzati per autenticare le connessioni WebSocket all'hub."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Attiva quando un sensore supera una soglia" msgstr "Attiva quando un sensore supera una soglia"

View File

@@ -448,6 +448,16 @@ msgstr "無効なメールアドレスです。"
msgid "Kernel" msgid "Kernel"
msgstr "カーネル" msgstr "カーネル"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "言語" msgstr "言語"
@@ -461,6 +471,14 @@ msgstr "レイアウト"
msgid "Light" msgid "Light"
msgstr "ライト" msgstr "ライト"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "ログアウト" msgstr "ログアウト"
@@ -828,6 +846,14 @@ msgstr "トークンはエージェントの接続と登録を可能にします
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。" msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。"
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "センサーがしきい値を超えたときにトリガーされます" msgstr "センサーがしきい値を超えたときにトリガーされます"

View File

@@ -448,6 +448,16 @@ msgstr "잘못된 이메일 주소입니다."
msgid "Kernel" msgid "Kernel"
msgstr "커널" msgstr "커널"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "언어" msgstr "언어"
@@ -461,6 +471,14 @@ msgstr "레이아웃"
msgid "Light" msgid "Light"
msgstr "밝게" msgstr "밝게"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "로그아웃" msgstr "로그아웃"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "센서가 임계값을 초과할 때 트리거됩니다." msgstr "센서가 임계값을 초과할 때 트리거됩니다."

View File

@@ -448,6 +448,16 @@ msgstr "Ongeldig e-mailadres."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Taal" msgstr "Taal"
@@ -461,6 +471,14 @@ msgstr "Indeling"
msgid "Light" msgid "Light"
msgstr "Licht" msgstr "Licht"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Afmelden" msgstr "Afmelden"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggert wanneer een sensor een drempelwaarde overschrijdt" msgstr "Triggert wanneer een sensor een drempelwaarde overschrijdt"

View File

@@ -448,6 +448,16 @@ msgstr "Ugyldig e-postadresse."
msgid "Kernel" msgid "Kernel"
msgstr "Kjerne" msgstr "Kjerne"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Språk" msgstr "Språk"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light" msgid "Light"
msgstr "Lyst" msgstr "Lyst"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Logg Ut" msgstr "Logg Ut"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Slår inn når enhver sensor overstiger en grenseverdi" msgstr "Slår inn når enhver sensor overstiger en grenseverdi"

View File

@@ -448,6 +448,16 @@ msgstr "Nieprawidłowy adres e-mail."
msgid "Kernel" msgid "Kernel"
msgstr "Jądro" msgstr "Jądro"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Język" msgstr "Język"
@@ -461,6 +471,14 @@ msgstr "Układ"
msgid "Light" msgid "Light"
msgstr "Jasny" msgstr "Jasny"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Wyloguj" msgstr "Wyloguj"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Wyzwalane, gdy jakikolwiek czujnik przekroczy ustalony próg." msgstr "Wyzwalane, gdy jakikolwiek czujnik przekroczy ustalony próg."

View File

@@ -448,6 +448,16 @@ msgstr "Endereço de email inválido."
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
@@ -461,6 +471,14 @@ msgstr "Aspeto"
msgid "Light" msgid "Light"
msgstr "Claro" msgstr "Claro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Sair" msgstr "Sair"
@@ -828,6 +846,14 @@ msgstr "Os tokens permitem que os agentes se conectem e registrem. As impressõe
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens e impressões digitais são usados para autenticar conexões WebSocket ao hub." msgstr "Tokens e impressões digitais são usados para autenticar conexões WebSocket ao hub."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Dispara quando qualquer sensor excede um limite" msgstr "Dispara quando qualquer sensor excede um limite"

View File

@@ -448,6 +448,16 @@ msgstr "Неверный адрес электронной почты."
msgid "Kernel" msgid "Kernel"
msgstr "Ядро" msgstr "Ядро"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Язык" msgstr "Язык"
@@ -461,6 +471,14 @@ msgstr "Макет"
msgid "Light" msgid "Light"
msgstr "Светлая" msgstr "Светлая"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Выйти" msgstr "Выйти"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Срабатывает, когда любой датчик превышает порог" msgstr "Срабатывает, когда любой датчик превышает порог"

View File

@@ -448,6 +448,16 @@ msgstr "Napačen e-poštni naslov."
msgid "Kernel" msgid "Kernel"
msgstr "Jedro" msgstr "Jedro"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Jezik" msgstr "Jezik"
@@ -461,6 +471,14 @@ msgstr "Postavitev"
msgid "Light" msgid "Light"
msgstr "Svetlo" msgstr "Svetlo"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Odjava" msgstr "Odjava"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Sproži se, ko kateri koli senzor preseže prag" msgstr "Sproži se, ko kateri koli senzor preseže prag"

View File

@@ -448,6 +448,16 @@ msgstr "Ogiltig e-postadress."
msgid "Kernel" msgid "Kernel"
msgstr "Kärna" msgstr "Kärna"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Språk" msgstr "Språk"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light" msgid "Light"
msgstr "Ljust" msgstr "Ljust"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Logga ut" msgstr "Logga ut"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Utlöses när någon sensor överskrider ett tröskelvärde" msgstr "Utlöses när någon sensor överskrider ett tröskelvärde"

View File

@@ -448,6 +448,16 @@ msgstr "Geçersiz e-posta adresi."
msgid "Kernel" msgid "Kernel"
msgstr "Çekirdek" msgstr "Çekirdek"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Dil" msgstr "Dil"
@@ -461,6 +471,14 @@ msgstr "Düzen"
msgid "Light" msgid "Light"
msgstr "Açık" msgstr "Açık"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Çıkış Yap" msgstr "Çıkış Yap"
@@ -828,6 +846,14 @@ msgstr "Token'lar agentların bağlanıp kaydolmasına izin verir. Parmak izleri
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Token'lar ve parmak izleri hub'a WebSocket bağlantılarını doğrulamak için kullanılır." msgstr "Token'lar ve parmak izleri hub'a WebSocket bağlantılarını doğrulamak için kullanılır."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Herhangi bir sensör bir eşiği aştığında tetiklenir" msgstr "Herhangi bir sensör bir eşiği aştığında tetiklenir"

View File

@@ -448,6 +448,16 @@ msgstr "Неправильна адреса електронної пошти."
msgid "Kernel" msgid "Kernel"
msgstr "Ядро" msgstr "Ядро"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Мова" msgstr "Мова"
@@ -461,6 +471,14 @@ msgstr "Макет"
msgid "Light" msgid "Light"
msgstr "Світлий" msgstr "Світлий"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Вийти" msgstr "Вийти"
@@ -828,6 +846,14 @@ msgstr "Токени дозволяють агентам підключатис
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу." msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу."
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Спрацьовує, коли будь-який датчик перевищує поріг" msgstr "Спрацьовує, коли будь-який датчик перевищує поріг"

View File

@@ -448,6 +448,16 @@ msgstr "Địa chỉ email không hợp lệ."
msgid "Kernel" msgid "Kernel"
msgstr "Nhân" msgstr "Nhân"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "Ngôn ngữ" msgstr "Ngôn ngữ"
@@ -461,6 +471,14 @@ msgstr "Bố cục"
msgid "Light" msgid "Light"
msgstr "Sáng" msgstr "Sáng"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "Đăng xuất" msgstr "Đăng xuất"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "Kích hoạt khi bất kỳ cảm biến nào vượt quá ngưỡng" msgstr "Kích hoạt khi bất kỳ cảm biến nào vượt quá ngưỡng"

View File

@@ -448,6 +448,16 @@ msgstr "无效的电子邮件地址。"
msgid "Kernel" msgid "Kernel"
msgstr "内核" msgstr "内核"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "语言" msgstr "语言"
@@ -461,6 +471,14 @@ msgstr "布局"
msgid "Light" msgid "Light"
msgstr "浅色模式" msgstr "浅色模式"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
@@ -828,6 +846,14 @@ msgstr "令牌允许客户端连接和注册。指纹是每个系统唯一的稳
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指纹用于验证到中心的WebSocket连接。" msgstr "令牌和指纹用于验证到中心的WebSocket连接。"
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "当任何传感器超过阈值时触发" msgstr "当任何传感器超过阈值时触发"

View File

@@ -448,6 +448,16 @@ msgstr "無效的電子郵件地址。"
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "語言" msgstr "語言"
@@ -461,6 +471,14 @@ msgstr "版面配置"
msgid "Light" msgid "Light"
msgstr "淺色" msgstr "淺色"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
@@ -828,6 +846,14 @@ msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。" msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "當任何傳感器超過閾值時觸發" msgstr "當任何傳感器超過閾值時觸發"

View File

@@ -448,6 +448,16 @@ msgstr "無效的電子郵件地址。"
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Kernel"
#. Load average 15 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L15"
msgstr ""
#. Load average 5 minutes
#: src/components/systems-table/systems-table.tsx
msgid "L5"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
msgstr "語言" msgstr "語言"
@@ -461,6 +471,14 @@ msgstr "版面配置"
msgid "Light" msgid "Light"
msgstr "淺色" msgstr "淺色"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
@@ -828,6 +846,14 @@ msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。" msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
#: src/lib/utils.ts
msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
msgstr "當任何感應器超過閾值時觸發" msgstr "當任何感應器超過閾值時觸發"

View File

@@ -1,5 +1,5 @@
import { RecordModel } from "pocketbase" import { RecordModel } from "pocketbase"
import { Os } from "./lib/enums" import { Unit, Os } from "./lib/enums"
// global window properties // global window properties
declare global { declare global {
@@ -44,6 +44,12 @@ 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 */
l5?: number
/** load average 15 minutes */
l15?: number
/** operating system */ /** operating system */
o?: string o?: string
/** uptime */ /** uptime */
@@ -71,6 +77,12 @@ export interface SystemStats {
cpu: number cpu: number
/** peak cpu */ /** peak cpu */
cpum?: number cpum?: number
/** load average 1 minute */
l1?: number
/** load average 5 minutes */
l5?: number
/** load average 15 minutes */
l15?: number
/** total memory (gb) */ /** total memory (gb) */
m: number m: number
/** memory used (gb) */ /** memory used (gb) */
@@ -195,6 +207,9 @@ export type UserSettings = {
chartTime: ChartTimes chartTime: ChartTimes
emails?: string[] emails?: string[]
webhooks?: string[] webhooks?: string[]
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
} }
type ChartDataContainer = { type ChartDataContainer = {
@@ -218,6 +233,9 @@ interface AlertInfo {
icon: any icon: any
desc: () => string desc: () => string
max?: number max?: number
min?: number
step?: number
start?: number
/** Single value description (when there's only one value, like status) */ /** Single value description (when there's only one value, like status) */
singleDesc?: () => string singleDesc?: () => string
} }

View File

@@ -8,7 +8,7 @@ module.exports = {
center: true, center: true,
padding: "1rem", padding: "1rem",
screens: { screens: {
"2xl": "1420px", "2xl": "1440px",
}, },
}, },
extend: { extend: {

View File

@@ -2,10 +2,10 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2021", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ES2021", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {

View File

@@ -3,8 +3,8 @@ package beszel
import "github.com/blang/semver" import "github.com/blang/semver"
const ( const (
Version = "0.12.0-beta1" Version = "0.12.0-beta2"
AppName = "beszel" AppName = "beszel"
) )
var MinVersionCbor = semver.MustParse("0.12.0-beta1") var MinVersionCbor = semver.MustParse("0.12.0-beta2")