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
description: Report a new bug or issue.
title: '[Bug]: '
labels: ['bug']
labels: ['bug', "needs confirmation"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: |
@@ -43,6 +54,39 @@ body:
3. Pour it into a cup.
validations:
required: true
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true
- type: input
id: system
attributes:
@@ -61,7 +105,6 @@ body:
id: install-method
attributes:
label: Installation method
default: 0
options:
- Docker
- Binary

View File

@@ -1,8 +1,19 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement"]
labels: ["enhancement", "needs review"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
@@ -11,8 +22,55 @@ body:
label: Describe the feature you would like to see
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why do you want this feature? What problem does it solve?
validations:
required: true
- type: textarea
attributes:
label: Describe how you would like to see this feature implemented
validations:
required: true
- type: textarea
id: logs
attributes:
label: Screenshots
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
validations:
required: false
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true

33
.github/pull_request_template.md vendored Normal file
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:
images: ${{ matrix.image }}
tags: |
type=edge,enable=true
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import (
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
@@ -77,6 +78,16 @@ func (a *Agent) getSystemStats() system.Stats {
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
if v, err := mem.VirtualMemory(); err == nil {
// swap
@@ -240,6 +251,9 @@ func (a *Agent) getSystemStats() system.Stats {
// update base system info
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg1 = systemStats.LoadAvg1
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()

View File

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

View File

@@ -54,6 +54,15 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
val = data.Info.DashboardTemp
unit = "°C"
case "LoadAvg1":
val = data.Info.LoadAvg1
unit = ""
case "LoadAvg5":
val = data.Info.LoadAvg5
unit = ""
case "LoadAvg15":
val = data.Info.LoadAvg15
unit = ""
}
triggered := alertRecord.GetBool("triggered")
@@ -190,6 +199,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
alert.mapSums[key] += temp
}
case "LoadAvg1":
alert.val += stats.LoadAvg1
case "LoadAvg5":
alert.val += stats.LoadAvg5
case "LoadAvg15":
alert.val += stats.LoadAvg15
default:
continue
}
@@ -247,6 +262,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
if alert.name == "Disk" {
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
titleAlertName := alert.name

View File

@@ -31,6 +31,9 @@ type Stats struct {
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,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 {
@@ -89,6 +92,9 @@ type Info struct {
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
}
// Final data structure to return to the hub

View File

@@ -5,10 +5,10 @@ import (
"beszel/internal/hub/expirymap"
"beszel/internal/hub/ws"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/blang/semver"
@@ -17,118 +17,96 @@ import (
"github.com/pocketbase/pocketbase/core"
)
// tokenMap maps tokens to user IDs for universal tokens
var tokenMap *expirymap.ExpiryMap[string]
// agentConnectRequest holds information related to an agent's connection attempt.
type agentConnectRequest struct {
hub *Hub
req *http.Request
res http.ResponseWriter
token string
agentSemVer semver.Version
// for universal token
// isUniversalToken is true if the token is a universal token.
isUniversalToken bool
userId string
remoteAddr string
// userId is the user ID associated with the universal token.
userId string
}
// validateAgentHeaders validates the required headers from agent connection requests.
func (h *Hub) validateAgentHeaders(headers http.Header) (string, string, error) {
token := headers.Get("X-Token")
agentVersion := headers.Get("X-Beszel")
// universalTokenMap stores active universal tokens and their associated user IDs.
var universalTokenMap tokenMap
if agentVersion == "" || token == "" || len(token) > 512 {
return "", "", errors.New("")
}
return token, agentVersion, nil
type tokenMap struct {
store *expirymap.ExpiryMap[string]
once sync.Once
}
// getFingerprintRecord retrieves fingerprint data from the database by token.
func (h *Hub) getFingerprintRecord(token string, recordData *ws.FingerprintRecord) error {
err := h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
Bind(dbx.Params{
"token": token,
}).
One(recordData)
return err
// getMap returns the expirymap, creating it if necessary.
func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] {
tm.once.Do(func() {
tm.store = expirymap.New[string](time.Hour)
})
return tm.store
}
// sendResponseError sends an HTTP error response with the given status code and message.
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.
// handleAgentConnect is the HTTP handler for an agent's connection request.
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
if err := h.agentConnect(e.Request, e.Response); err != nil {
return err
}
agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h}
_ = agentRequest.agentConnect()
return nil
}
// agentConnect handles agent connection requests, validating credentials and upgrading to WebSocket.
func (h *Hub) agentConnect(req *http.Request, res http.ResponseWriter) (err error) {
var agentConnectRequest agentConnectRequest
// agentConnect validates agent credentials and upgrades the connection to a WebSocket.
func (acr *agentConnectRequest) agentConnect() (err error) {
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 {
return sendResponseError(res, http.StatusUnauthorized, "")
return acr.sendResponseError(acr.res, http.StatusBadRequest, "")
}
// Pull fingerprint from database matching token
var fpRecord ws.FingerprintRecord
err = h.getFingerprintRecord(agentConnectRequest.token, &fpRecord)
// Check if token is an active universal token
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
// if no existing record, check if token is a universal token
if err != nil {
if err = checkUniversalToken(&agentConnectRequest); err == nil {
// if this is a universal token, set the remote address and new record token
agentConnectRequest.remoteAddr = getRealIP(req)
fpRecord.Token = agentConnectRequest.token
}
}
// If no matching token, return unauthorized
if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "Invalid token")
// Find matching fingerprint records for this token
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
if len(fpRecords) == 0 && !acr.isUniversalToken {
// Invalid token - no records found and not a universal token
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token")
}
// Validate agent version
agentConnectRequest.agentSemVer, err = semver.Parse(agentVersion)
acr.agentSemVer, err = semver.Parse(agentVersion)
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
conn, err := ws.GetUpgrader().Upgrade(res, req)
conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req)
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
}
// verifyWsConn verifies the WebSocket connection using agent's fingerprint and SSH key signature.
func (h *Hub) verifyWsConn(conn *gws.Conn, acr agentConnectRequest, fpRecord ws.FingerprintRecord) (err error) {
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
// 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)
// must be set before the read loop
// must set wsConn in connection store before the read loop
conn.Session().Store("wsConn", wsConn)
// make sure connection is closed if there is an error
defer func() {
if err != nil {
wsConn.Close()
h.Logger().Error("WebSocket error", "error", err, "system", fpRecord.SystemId)
wsConn.Close([]byte(err.Error()))
}
}()
go conn.ReadLoop()
signer, err := h.GetSSHKey("")
signer, err := acr.hub.GetSSHKey("")
if err != nil {
return err
}
@@ -138,40 +116,152 @@ func (h *Hub) verifyWsConn(conn *gws.Conn, acr agentConnectRequest, fpRecord ws.
return err
}
// Create system if using universal token
if acr.isUniversalToken {
if acr.userId == "" {
return errors.New("token user not found")
}
fpRecord.SystemId, err = h.createSystemFromAgentData(&acr, agentFingerprint)
if err != nil {
return fmt.Errorf("failed to create system from universal token: %w", err)
}
// Find or create the appropriate system for this token and fingerprint
fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint)
if err != nil {
return err
}
switch {
// 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)
return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
}
// createSystemFromAgentData creates a new system record using data from the agent
func (h *Hub) createSystemFromAgentData(acr *agentConnectRequest, agentFingerprint common.FingerprintResponse) (recordId string, err error) {
systemsCollection, err := h.FindCollectionByNameOrId("systems")
if err != nil {
return "", fmt.Errorf("failed to find systems collection: %w", err)
// validateAgentHeaders extracts and validates the token and agent version from HTTP headers.
func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) {
token := headers.Get("X-Token")
agentVersion := headers.Get("X-Beszel")
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
if agentFingerprint.Hostname == "" {
agentFingerprint.Hostname = acr.remoteAddr
agentFingerprint.Hostname = remoteAddr
}
if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876"
@@ -179,14 +269,14 @@ func (h *Hub) createSystemFromAgentData(acr *agentConnectRequest, agentFingerpri
// create new record
systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname)
systemRecord.Set("host", acr.remoteAddr)
systemRecord.Set("host", remoteAddr)
systemRecord.Set("port", agentFingerprint.Port)
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) {
// // can't use raw query here because it doesn't trigger SSE
var record *core.Record
@@ -207,25 +297,8 @@ func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string)
return h.SaveNoValidate(record)
}
func getTokenMap() *expirymap.ExpiryMap[string] {
if tokenMap == nil {
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.
// getRealIP extracts the client's real IP address from request headers,
// checking common proxy headers before falling back to the remote address.
func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); 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)
}
tokenMap := getTokenMap()
tokenMap := universalTokenMap.GetMap()
userID := info.Auth.Id
query := e.Request.URL.Query()
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.
func (sys *System) closeWebSocketConnection() {
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:
default:
// 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.
func (ws *WsConn) Close() {
func (ws *WsConn) Close(msg []byte) {
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 {
case <-time.After(10 * time.Second):
ws.Close()
ws.Close(nil)
return gws.ErrConnClosed
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.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
var clientFingerprint common.FingerprintResponse
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return common.FingerprintResponse{}, err
return clientFingerprint, err
}
err = ws.sendMessage(common.HubRequest[any]{
@@ -155,24 +156,19 @@ func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bo
},
})
if err != nil {
return common.FingerprintResponse{}, err
return clientFingerprint, err
}
var message *gws.Message
var clientFingerprint common.FingerprintResponse
select {
case message = <-ws.responseChan:
case <-time.After(10 * time.Second):
return common.FingerprintResponse{}, errors.New("request expired")
return clientFingerprint, errors.New("request expired")
}
defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
if err != nil {
return common.FingerprintResponse{}, err
}
return clientFingerprint, nil
return clientFingerprint, err
}
// IsConnected returns true if the WebSocket connection is active.

View File

@@ -17,7 +17,9 @@ type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
// UnitDisk uint8 `json:"unitDisk"` // 0 for bytes, 1 for bits
}
func NewUserManager(app core.App) *UserManager {
@@ -39,7 +41,6 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,13 +11,14 @@ import {
DialogHeader,
DialogTitle,
} 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 { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types"
import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox"
import { Collapsible } from "../ui/collapsible"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"
@@ -39,13 +40,33 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
/>
</Button>
</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} />}
</DialogContent>
</Dialog>
),
[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 }) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import { useState } from "react"
import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro"
// import { setLang } from "@/lib/i18n"
import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
@@ -101,6 +101,87 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p>
</div>
<Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Unit preferences</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Change display units for metrics.</Trans>
</p>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="block" htmlFor="unitTemp">
<Trans>Temperature unit</Trans>
</Label>
<Select
name="unitTemp"
key={userSettings.unitTemp}
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
>
<SelectTrigger id="unitTemp">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Celsius)}>
<Trans>Celsius (°C)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Fahrenheit)}>
<Trans>Fahrenheit (°F)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="block" htmlFor="unitNet">
<Trans>Network unit</Trans>
</Label>
<Select
name="unitNet"
key={userSettings.unitNet}
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitNet">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="block" htmlFor="unitDisk">
<Trans>Disk unit</Trans>
</Label>
<Select
name="unitDisk"
key={userSettings.unitDisk}
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitDisk">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,3 +121,12 @@ export function GpuIcon(props: SVGProps<SVGSVGElement>) {
</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 {
Linux = 0,
Darwin,
@@ -5,9 +6,18 @@ export enum Os {
FreeBSD,
}
/** Type of chart */
export enum ChartType {
Memory,
Disk,
Network,
CPU,
}
/** Unit of measurement */
export enum Unit {
Bytes,
Bits,
Celsius,
Fahrenheit,
}

View File

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

View File

@@ -3,14 +3,23 @@ import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
import {
AlertInfo,
AlertRecord,
ChartTimeData,
ChartTimes,
FingerprintRecord,
SystemRecord,
UserSettings,
} from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores"
import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "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 { Unit } from "./enums"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -73,7 +82,10 @@ export const updateSystemList = (() => {
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
sessionStorage.setItem("lo", "t")
$systems.set([])
$alerts.set([])
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()
pb.realtime.unsubscribe()
}
@@ -225,17 +237,17 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth }
}
export function toFixedWithoutTrailingZeros(num: number, digits: number) {
return parseFloat(num.toFixed(digits)).toString()
}
/** Format number to x decimal places, without trailing zeros */
export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits))
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
}
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places */
/** Format number to x decimal places, maintaining trailing zeros */
export function decimalString(num: number, digits = 2) {
if (digits === 0) {
return Math.ceil(num).toString()
}
let formatter = decimalFormatters.get(digits)
if (!formatter) {
formatter = new Intl.NumberFormat(undefined, {
@@ -266,40 +278,93 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
return [value, setValue]
}
/** Format temperature to user's preferred unit */
export function formatTemperature(celsius: number, unit?: Unit): { value: number; unit: string } {
if (!unit) {
unit = $userSettings.get().unitTemp || Unit.Celsius
}
// need loose equality check due to form data being strings
if (unit == Unit.Fahrenheit) {
return {
value: celsius * 1.8 + 32,
unit: "°F",
}
}
return {
value: celsius,
unit: "°C",
}
}
/** Format bytes to user's preferred unit */
export function formatBytes(
size: number,
perSecond = false,
unit = Unit.Bytes,
isMegabytes = false
): { value: number; unit: string } {
// Convert MB to bytes if isMegabytes is true
if (isMegabytes) size *= 1024 * 1024
// need loose equality check due to form data being strings
if (unit == Unit.Bits) {
const bits = size * 8
const suffix = perSecond ? "ps" : ""
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
if (bits < 1_000_000) return { value: bits / 1_000, unit: `Kb${suffix}` }
if (bits < 1_000_000_000)
return {
value: bits / 1_000_000,
unit: `Mb${suffix}`,
}
if (bits < 1_000_000_000_000)
return {
value: bits / 1_000_000_000,
unit: `Gb${suffix}`,
}
return {
value: bits / 1_000_000_000_000,
unit: `Tb${suffix}`,
}
}
// bytes
const suffix = perSecond ? "/s" : ""
if (size < 100) return { value: size, unit: `B${suffix}` }
if (size < 1000 * 1024) return { value: size / 1024, unit: `KB${suffix}` }
if (size < 1000 * 1024 ** 2)
return {
value: size / 1024 ** 2,
unit: `MB${suffix}`,
}
if (size < 1000 * 1024 ** 3)
return {
value: size / 1024 ** 3,
unit: `GB${suffix}`,
}
return {
value: size / 1024 ** 4,
unit: `TB${suffix}`,
}
}
/** Fetch or create user settings in database */
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
return
} catch (e) {
console.log("get settings", e)
console.error("get settings", e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.log("create settings", e)
console.error("create settings", e)
}
}
/**
* Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes or megabytes
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) {
return { v: sizeInGB / 1_000, u: " TB" }
} else if (sizeInGB >= 1) {
return { v: sizeInGB, u: " GB" }
}
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
}
export const chartMargin = { top: 12 }
export const alertInfo: Record<string, AlertInfo> = {
@@ -342,6 +407,36 @@ export const alertInfo: Record<string, AlertInfo> = {
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
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) */
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"
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
msgid "Language"
msgstr "اللغة"
@@ -461,6 +471,14 @@ msgstr "التخطيط"
msgid "Light"
msgstr "فاتح"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "تسجيل الخروج"
@@ -828,6 +846,14 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز أي مستشعر عتبة معينة"

View File

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

View File

@@ -448,6 +448,16 @@ msgstr "Neplatná e-mailová adresa."
msgid "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
msgid "Language"
msgstr "Jazyk"
@@ -461,6 +471,14 @@ msgstr "Rozvržení"
msgid "Light"
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
msgid "Log Out"
msgstr "Odhlásit"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Spustí se, když některý senzor překročí prahovou hodnotu"

View File

@@ -448,6 +448,16 @@ msgstr "Ugyldig email adresse."
msgid "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
msgid "Language"
msgstr "Sprog"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light"
msgstr "Lys"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Log ud"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Sprache"
@@ -461,6 +471,14 @@ msgstr "Anordnung"
msgid "Light"
msgstr "Hell"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"

View File

@@ -443,6 +443,16 @@ msgstr "Invalid email address."
msgid "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
msgid "Language"
msgstr "Language"
@@ -456,6 +466,14 @@ msgstr "Layout"
msgid "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
msgid "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."
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
msgid "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"
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
msgid "Language"
msgstr "Idioma"
@@ -461,6 +471,14 @@ msgstr "Diseño"
msgid "Light"
msgstr "Claro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Se activa cuando cualquier sensor supera un umbral"

View File

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

View File

@@ -448,6 +448,16 @@ msgstr "Adresse email invalide."
msgid "Kernel"
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
msgid "Language"
msgstr "Langue"
@@ -461,6 +471,14 @@ msgstr "Disposition"
msgid "Light"
msgstr "Clair"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Jezik"
@@ -461,6 +471,14 @@ msgstr "Izgled"
msgid "Light"
msgstr "Svijetlo"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Odjava"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Nyelv"
@@ -461,6 +471,14 @@ msgstr "Elrendezés"
msgid "Light"
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
msgid "Log Out"
msgstr "Kijelentkezés"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Tungumál"
@@ -461,6 +471,14 @@ msgstr ""
msgid "Light"
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
msgid "Log Out"
msgstr "Útskrá"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Virkjast þegar einhver skynjari fer yfir þröskuld"

View File

@@ -448,6 +448,16 @@ msgstr "Indirizzo email non valido."
msgid "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
msgid "Language"
msgstr "Lingua"
@@ -461,6 +471,14 @@ msgstr "Aspetto"
msgid "Light"
msgstr "Chiaro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Attiva quando un sensore supera una soglia"

View File

@@ -448,6 +448,16 @@ msgstr "無効なメールアドレスです。"
msgid "Kernel"
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
msgid "Language"
msgstr "言語"
@@ -461,6 +471,14 @@ msgstr "レイアウト"
msgid "Light"
msgstr "ライト"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "ログアウト"
@@ -828,6 +846,14 @@ msgstr "トークンはエージェントの接続と登録を可能にします
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "センサーがしきい値を超えたときにトリガーされます"

View File

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

View File

@@ -448,6 +448,16 @@ msgstr "Ongeldig e-mailadres."
msgid "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
msgid "Language"
msgstr "Taal"
@@ -461,6 +471,14 @@ msgstr "Indeling"
msgid "Light"
msgstr "Licht"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Afmelden"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggert wanneer een sensor een drempelwaarde overschrijdt"

View File

@@ -448,6 +448,16 @@ msgstr "Ugyldig e-postadresse."
msgid "Kernel"
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
msgid "Language"
msgstr "Språk"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light"
msgstr "Lyst"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Logg Ut"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Język"
@@ -461,6 +471,14 @@ msgstr "Układ"
msgid "Light"
msgstr "Jasny"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Wyloguj"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Wyzwalane, gdy jakikolwiek czujnik przekroczy ustalony próg."

View File

@@ -448,6 +448,16 @@ msgstr "Endereço de email inválido."
msgid "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
msgid "Language"
msgstr "Idioma"
@@ -461,6 +471,14 @@ msgstr "Aspeto"
msgid "Light"
msgstr "Claro"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Dispara quando qualquer sensor excede um limite"

View File

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

View File

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

View File

@@ -448,6 +448,16 @@ msgstr "Ogiltig e-postadress."
msgid "Kernel"
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
msgid "Language"
msgstr "Språk"
@@ -461,6 +471,14 @@ msgstr "Layout"
msgid "Light"
msgstr "Ljust"
#: src/lib/utils.ts
msgid "Load Average 15m"
msgstr ""
#: src/lib/utils.ts
msgid "Load Average 5m"
msgstr ""
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Logga ut"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when any sensor exceeds a threshold"
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"
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
msgid "Language"
msgstr "Dil"
@@ -461,6 +471,14 @@ msgstr "Düzen"
msgid "Light"
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
msgid "Log Out"
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."
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
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Herhangi bir sensör bir eşiği aştığında tetiklenir"

View File

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

View File

@@ -448,6 +448,16 @@ msgstr "Địa chỉ email không hợp lệ."
msgid "Kernel"
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
msgid "Language"
msgstr "Ngôn ngữ"
@@ -461,6 +471,14 @@ msgstr "Bố cục"
msgid "Light"
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
msgid "Log Out"
msgstr "Đăng xuất"
@@ -828,6 +846,14 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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