Compare commits

...

11 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
35 changed files with 907 additions and 185 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

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

@@ -251,6 +251,7 @@ func (a *Agent) getSystemStats() system.Stats {
// update base system info
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg1 = systemStats.LoadAvg1
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
a.systemInfo.MemPct = systemStats.MemPct

View File

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

View File

@@ -54,6 +54,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
val = data.Info.DashboardTemp
unit = "°C"
case "LoadAvg1":
val = data.Info.LoadAvg1
unit = ""
case "LoadAvg5":
val = data.Info.LoadAvg5
unit = ""
@@ -196,6 +199,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
alert.mapSums[key] += temp
}
case "LoadAvg1":
alert.val += stats.LoadAvg1
case "LoadAvg5":
alert.val += stats.LoadAvg5
case "LoadAvg15":

View File

@@ -92,8 +92,9 @@ type Info struct {
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"15,keyasint,omitempty,omitzero"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"16,keyasint,omitempty,omitzero"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
}
// Final data structure to return to the hub

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

@@ -76,6 +76,7 @@ func init() {
"Disk",
"Temperature",
"Bandwidth",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
]

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"

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`,

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,9 +63,17 @@ import {
PenBoxIcon,
} from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $systems, pb } from "@/lib/stores"
import { $systems, $userSettings, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import {
cn,
copyToClipboard,
isReadOnlyUser,
useLocalStorage,
formatTemperature,
decimalString,
formatBytes,
} from "@/lib/utils"
import AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
@@ -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"
@@ -217,52 +226,75 @@ export default function SystemsTable() {
accessorFn: (originalRow) => originalRow.info.b || 0,
id: "net",
name: () => t`Net`,
size: 50,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
return <span className="tabular-nums whitespace-nowrap">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
},
},
{
accessorFn: (originalRow) => originalRow.info.l5,
id: "l5",
name: () => t({ message: "L5", comment: "Load average 5 minutes" }),
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
if (!val) {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, true)
return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
{decimalString(val)}
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: (originalRow) => originalRow.info.l15,
id: "l15",
name: () => t({ message: "L15", comment: "Load average 15 minutes" }),
id: "loadAverage",
name: () => t`Load Average`,
size: 0,
hideSort: true,
Icon: HourglassIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
if (!val) {
return null
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 (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-1")}>
{decimalString(val)}
</span>
)
<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>
);
},
},
{
@@ -278,9 +310,11 @@ export default function SystemsTable() {
if (!val) {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return (
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
{decimalString(val)} °C
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},

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

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

@@ -1,5 +1,5 @@
import { RecordModel } from "pocketbase"
import { Os } from "./lib/enums"
import { Unit, Os } from "./lib/enums"
// global window properties
declare global {
@@ -44,6 +44,8 @@ export interface SystemInfo {
c: number
/** cpu model */
m: string
/** load average 1 minute */
l1?: number
/** load average 5 minutes */
l5?: number
/** load average 15 minutes */
@@ -205,6 +207,9 @@ export type UserSettings = {
chartTime: ChartTimes
emails?: string[]
webhooks?: string[]
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
}
type ChartDataContainer = {

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": {