Compare commits

...

7 Commits

Author SHA1 Message Date
dalton-baker
99c7f7bd8a Add GPU-enabled build target in dockerfile_Agent (nvidia-smi support) (#898) 2025-08-03 13:31:26 -04:00
henrygd
8af3a0eb5b refactor: add getMeterState function 2025-08-02 23:44:07 -04:00
henrygd
5f7950b474 tweaks to custom meter percentages 2025-08-02 20:53:49 -04:00
Sven van Ginkel
df9e2dec28 [Feature] Add custom meter percentages (#942) 2025-08-02 17:58:52 -04:00
henrygd
a0f271545a refactoring (no functionality changes) 2025-08-02 17:04:38 -04:00
Bradley Varol
aa2bc9f118 fix systems table names wrapping (#1027) 2025-08-02 12:28:49 -04:00
henrygd
b22ae87022 disable winget auto pr and reactivate docker workflow 2025-08-01 21:15:37 -04:00
19 changed files with 613 additions and 507 deletions

View File

@@ -3,7 +3,7 @@ name: Make docker images
on: on:
push: push:
tags: tags:
- "xv*" - "v*"
jobs: jobs:
build: build:
@@ -21,6 +21,14 @@ jobs:
- image: henrygd/beszel-agent - image: henrygd/beszel-agent
context: ./beszel context: ./beszel
dockerfile: ./beszel/dockerfile_Agent dockerfile: ./beszel/dockerfile_Agent
target: default-agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: henrygd/beszel-agent-nvidia
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
target: nvidia-agent
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -33,6 +41,14 @@ jobs:
- image: ghcr.io/${{ github.repository }}/beszel-agent - image: ghcr.io/${{ github.repository }}/beszel-agent
context: ./beszel context: ./beszel
dockerfile: ./beszel/dockerfile_Agent dockerfile: ./beszel/dockerfile_Agent
target: default-agent
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
target: nvidia-agent
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -91,3 +107,4 @@ jobs:
push: ${{ github.ref_type == 'tag' }} push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
target: ${{ matrix.target }}

View File

@@ -203,13 +203,13 @@ winget:
name: beszel-winget name: beszel-winget
branch: henrygd.beszel-agent-{{ .Version }} branch: henrygd.beszel-agent-{{ .Version }}
token: "{{ .Env.WINGET_TOKEN }}" token: "{{ .Env.WINGET_TOKEN }}"
pull_request: # pull_request:
enabled: true # enabled: true
draft: false # draft: false
base: # base:
owner: microsoft # owner: microsoft
name: winget-pkgs # name: winget-pkgs
branch: master # branch: master
release: release:
draft: true draft: true

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -25,13 +26,16 @@ func (opts *cmdOptions) parse() bool {
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on") flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
flag.Usage = func() { flag.Usage = func() {
fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0]) builder := strings.Builder{}
fmt.Println("\nCommands:") builder.WriteString("Usage: ")
fmt.Println(" health Check if the agent is running") builder.WriteString(os.Args[0])
fmt.Println(" help Display this help message") builder.WriteString(" [command] [flags]\n")
fmt.Println(" update Update to the latest version") builder.WriteString("\nCommands:\n")
fmt.Println(" version Display the version") builder.WriteString(" health Check if the agent is running\n")
fmt.Println("\nFlags:") builder.WriteString(" help Display this help message\n")
builder.WriteString(" update Update to the latest version\n")
builder.WriteString("\nFlags:\n")
fmt.Print(builder.String())
flag.PrintDefaults() flag.PrintDefaults()
} }
@@ -111,12 +115,12 @@ func main() {
serverConfig.Addr = addr serverConfig.Addr = addr
serverConfig.Network = agent.GetNetwork(addr) serverConfig.Network = agent.GetNetwork(addr)
agent, err := agent.NewAgent() a, err := agent.NewAgent()
if err != nil { if err != nil {
log.Fatal("Failed to create agent: ", err) log.Fatal("Failed to create agent: ", err)
} }
if err := agent.Start(serverConfig); err != nil { if err := a.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err) log.Fatal("Failed to start server: ", err)
} }
} }

View File

@@ -14,13 +14,21 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
RUN rm -rf /tmp/* RUN rm -rf /tmp/*
# ? ------------------------- # --------------------------
FROM scratch # Final image: default scratch-based agent
# --------------------------
FROM scratch AS default-agent
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
# this is so we don't need to create the # this is so we don't need to create the /tmp directory in the scratch container
# /tmp directory in the scratch container
COPY --from=builder /tmp /tmp COPY --from=builder /tmp /tmp
ENTRYPOINT ["/agent"] ENTRYPOINT ["/agent"]
# --------------------------
# Final image: GPU-enabled agent with nvidia-smi
# --------------------------
FROM nvidia/cuda:12.9.0-base-ubuntu22.04 AS nvidia-agent
COPY --from=builder /agent /agent
COPY --from=builder /tmp /tmp
ENTRYPOINT ["/agent"]

View File

@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
cachedData, ok := a.cache.Get(sessionID) data, isCached := a.cache.Get(sessionID)
if ok { if isCached {
slog.Debug("Cached stats", "session", sessionID) slog.Debug("Cached data", "session", sessionID)
return cachedData return data
} }
*cachedData = system.CombinedData{ *data = system.CombinedData{
Stats: a.getSystemStats(), Stats: a.getSystemStats(),
Info: a.systemInfo, Info: a.systemInfo,
} }
slog.Debug("System stats", "data", cachedData) slog.Debug("System data", "data", data)
if a.dockerManager != nil { if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
cachedData.Containers = containerStats data.Containers = containerStats
slog.Debug("Docker stats", "data", cachedData.Containers) slog.Debug("Containers", "data", data.Containers)
} else { } else {
slog.Debug("Docker stats", "err", err) slog.Debug("Containers", "err", err)
} }
} }
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 { if !stats.Root && stats.DiskTotal > 0 {
cachedData.Stats.ExtraFs[name] = stats data.Stats.ExtraFs[name] = stats
} }
} }
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(sessionID, cachedData) a.cache.Set(sessionID, data)
return cachedData return data
} }
// StartAgent initializes and starts the agent with optional WebSocket connection // StartAgent initializes and starts the agent with optional WebSocket connection

View File

@@ -293,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// app.Logger().Error("failed to save alert record", "err", err) // app.Logger().Error("failed to save alert record", "err", err)
return return
} }
// expand the user relation and send the alert am.SendAlert(AlertMessageData{
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 { UserID: alert.alertRecord.GetString("user"),
// app.Logger().Error("failed to expand user relation", "errs", errs) Title: subject,
return Message: body,
} Link: am.hub.MakeLink("system", systemName),
if user := alert.alertRecord.ExpandedOne("user"); user != nil { LinkText: "View " + systemName,
am.SendAlert(AlertMessageData{ })
UserID: user.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}
} }

View File

@@ -6,6 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -13,18 +14,6 @@ type UserManager struct {
app core.App app core.App
} }
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
// UnitDisk uint8 `json:"unitDisk"` // 0 for bytes, 1 for bits
// New field for alert history retention (e.g., "1m", "3m", "6m", "1y")
AlertHistoryRetention string `json:"alertHistoryRetention,omitempty"`
}
func NewUserManager(app core.App) *UserManager { func NewUserManager(app core.App) *UserManager {
return &UserManager{ return &UserManager{
app: app, app: app,
@@ -42,29 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
// Initialize user settings with defaults if not set // Initialize user settings with defaults if not set
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error { func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record record := e.Record
// intialize settings with defaults // intialize settings with defaults (zero values can be ignored)
settings := UserSettings{ settings := struct {
ChartTime: "1h", ChartTime string `json:"chartTime"`
NotificationEmails: []string{}, Emails []string `json:"emails"`
NotificationWebhooks: []string{}, }{
ChartTime: "1h",
} }
record.UnmarshalJSONField("settings", &settings) record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 { // get user email from auth record
// get user email from auth record var user struct {
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 { Email string `db:"email"`
// app.Logger().Error("failed to expand user relation", "errs", errs)
if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")}
} else {
log.Println("Failed to get user email from auth record")
}
} else {
log.Println("failed to expand user relation", "errs", errs)
}
} }
// if len(settings.NotificationWebhooks) == 0 { err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
// settings.NotificationWebhooks = []string{""} "id": record.GetString("user"),
// } }).One(&user)
if err != nil {
log.Println("failed to get user email", "err", err)
return err
}
settings.Emails = []string{user.Email}
record.Set("settings", settings) record.Set("settings", settings)
return e.Next() return e.Next()
} }

View File

@@ -72,18 +72,18 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
// alertsSignature changes only when alerts for this system change /* key to prevent re-rendering */
let alertsSignature = "" const alertsSignature: string[] = []
const systemAlerts = alerts.filter((alert) => { const systemAlerts = alerts.filter((alert) => {
if (alert.system === system.id) { if (alert.system === system.id) {
alertsSignature += alert.name + alert.min + alert.value alertsSignature.push(alert.name, alert.min, alert.value)
return true return true
} }
return false return false
}) as AlertRecord[] }) as AlertRecord[]
return useMemo(() => { return useMemo(() => {
// console.log("render modal", system.name, alertsSignature)
const data = Object.keys(alertInfo).map((name) => { const data = Object.keys(alertInfo).map((name) => {
const alert = alertInfo[name as keyof typeof alertInfo] const alert = alertInfo[name as keyof typeof alertInfo]
return { return {
@@ -149,5 +149,5 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
</Tabs> </Tabs>
</> </>
) )
}, [alertsSignature, overwriteExisting]) }, [alertsSignature.join(""), overwriteExisting])
} }

View File

@@ -18,7 +18,9 @@ export const Home = memo(() => {
const systems = useStore($systems) const systems = useStore($systems)
const { t } = useLingui() const { t } = useLingui()
let alertsKey = "" /* key to prevent re-rendering of active alerts */
const alertsKey: string[] = []
const activeAlerts = useMemo(() => { const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => { const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo const active = alert.triggered && alert.name in alertInfo
@@ -26,7 +28,7 @@ export const Home = memo(() => {
return false return false
} }
alert.sysname = systems.find((system) => system.id === alert.system)?.name alert.sysname = systems.find((system) => system.id === alert.system)?.name
alertsKey += alert.id alertsKey.push(alert.id)
return true return true
}) })
return activeAlerts return activeAlerts
@@ -81,7 +83,7 @@ export const Home = memo(() => {
</div> </div>
</> </>
), ),
[alertsKey] [alertsKey.join("")]
) )
}) })

View File

@@ -11,6 +11,7 @@ import { useState } from "react"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { Input } from "@/components/ui/input"
import { Unit } from "@/lib/enums" import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
@@ -133,7 +134,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="block" htmlFor="unitNet"> <Label className="block" htmlFor="unitNet">
<Trans comment="Context: Bytes or bits">Network unit</Trans> <Trans comment="Context: Bytes or bits">Network unit</Trans>
@@ -156,7 +156,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="block" htmlFor="unitDisk"> <Label className="block" htmlFor="unitDisk">
<Trans>Disk unit</Trans> <Trans>Disk unit</Trans>
@@ -182,6 +181,47 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div> </div>
</div> </div>
<Separator /> <Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Warning thresholds</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Set percentage thresholds for meter colors.</Trans>
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
<div className="space-y-1">
<Label htmlFor="colorWarn">
<Trans>Warning (%)</Trans>
</Label>
<Input
id="colorWarn"
name="colorWarn"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorWarn ?? 65}
/>
</div>
<div className="space-y-1">
<Label htmlFor="colorCrit">
<Trans>Critical (%)</Trans>
</Label>
<Input
id="colorCrit"
name="colorCrit"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorCrit ?? 90}
/>
</div>
</div>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}> <Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />} {isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans> <Trans>Save Settings</Trans>

View File

@@ -10,7 +10,7 @@ import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react" import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts" import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts" import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js" import { UserSettings } from "@/types"
import General from "./general.tsx" import General from "./general.tsx"
import Notifications from "./notifications.tsx" import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx" import ConfigYaml from "./config-yaml.tsx"

View File

@@ -0,0 +1,425 @@
import { SystemRecord } from "@/types"
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
import { ClassValue } from "clsx"
import {
ArrowUpDownIcon,
CopyIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
MoreHorizontalIcon,
PauseCircleIcon,
PenBoxIcon,
PlayCircleIcon,
ServerIcon,
Trash2Icon,
WifiIcon,
} from "lucide-react"
import { Button } from "../ui/button"
import {
cn,
copyToClipboard,
decimalString,
formatBytes,
formatTemperature,
getMeterState,
isReadOnlyUser,
parseSemVer,
} from "@/lib/utils"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useStore } from "@nanostores/react"
import { $userSettings, pb } from "@/lib/stores"
import { Trans, useLingui } from "@lingui/react/macro"
import { useMemo, useRef, useState } from "react"
import { memo } from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import AlertButton from "../alerts/alert-button"
import { Dialog } from "../ui/dialog"
import { SystemDialog } from "../add-system"
import { AlertDialog } from "../ui/alert-dialog"
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog"
import { buttonVariants } from "../ui/button"
import { t } from "@lingui/core/macro"
import { MeterState } from "@/lib/enums"
/**
* @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table
*/
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
const statusTranslations = {
up: () => t`Up`.toLowerCase(),
down: () => t`Down`.toLowerCase(),
paused: () => t`Paused`.toLowerCase(),
}
return [
{
size: 200,
minSize: 0,
accessorKey: "name",
id: "system",
name: () => t`System`,
filterFn: (row, _, filterVal) => {
const filterLower = filterVal.toLowerCase()
const { name, status } = row.original
// Check if the filter matches the name or status for this row
if (
name.toLowerCase().includes(filterLower) ||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
) {
return true
}
return false
},
enableHiding: false,
invertSorting: false,
Icon: ServerIcon,
cell: (info) => (
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} />
{info.getValue() as string}
</span>
),
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.cpu,
id: "cpu",
name: () => t`CPU`,
cell: TableCellWithMeter,
Icon: CpuIcon,
header: sortableHeader,
},
{
// accessorKey: "info.mp",
accessorFn: ({ info }) => info.mp,
id: "memory",
name: () => t`Memory`,
cell: TableCellWithMeter,
Icon: MemoryStickIcon,
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.dp,
id: "disk",
name: () => t`Disk`,
cell: TableCellWithMeter,
Icon: HardDriveIcon,
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.g,
id: "gpu",
name: () => "GPU",
cell: TableCellWithMeter,
Icon: GpuIcon,
header: sortableHeader,
},
{
id: "loadAverage",
accessorFn: ({ info }) => {
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array
if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
}
return sum
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
// agent version
const { minor, patch } = parseSemVer(sysInfo.v)
let loadAverages = sysInfo.la
// use legacy load averages if agent version is less than 12.1.0
if (!loadAverages || (minor === 12 && patch < 1)) {
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
}
const max = Math.max(...loadAverages)
if (max === 0 && (status === "paused" || minor < 12)) {
return null
}
const normalizedLoad = max / (sysInfo.t ?? 1)
const threshold = getMeterState(normalizedLoad * 100)
return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span
className={cn("inline-block size-2 rounded-full me-0.5", {
"bg-green-500": threshold === MeterState.Good,
"bg-yellow-500": threshold === MeterState.Warn,
"bg-red-600": threshold === MeterState.Crit,
})}
/>
{loadAverages?.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))}
</div>
)
},
},
{
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
id: "net",
name: () => t`Net`,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
const sys = info.row.original
if (sys.status === "paused") {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: ({ info }) => info.dt,
id: "temp",
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
size: 50,
hideSort: true,
Icon: ThermometerIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
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(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: ({ info }) => info.v,
id: "agent",
name: () => t`Agent`,
// invertSorting: true,
size: 50,
Icon: WifiIcon,
hideSort: true,
header: sortableHeader,
cell(info) {
const version = info.getValue() as string
if (!version) {
return null
}
const system = info.row.original
return (
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
<IndicatorDot
system={system}
className={
(system.status !== "up" && "bg-primary/30") ||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
"bg-yellow-500"
}
/>
<span className="truncate max-w-14">{info.getValue() as string}</span>
</span>
)
},
},
{
id: "actions",
// @ts-ignore
name: () => t({ message: "Actions", comment: "Table column" }),
size: 50,
cell: ({ row }) => (
<div className="flex justify-end items-center gap-1 -ms-3">
<AlertButton system={row.original} />
<ActionsButton system={row.original} />
</div>
),
},
] as ColumnDef<SystemRecord>[]
}
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context
// @ts-ignore
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
return (
<Button
variant="ghost"
className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="me-2 size-4" />}
{name()}
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button>
)
}
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const val = Number(info.getValue()) || 0
const threshold = getMeterState(val)
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</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",
(info.row.original.status !== "up" && "bg-primary/30") ||
(threshold === MeterState.Good && "bg-green-500") ||
(threshold === MeterState.Warn && "bg-yellow-500") ||
"bg-red-600"
)}
style={{
transform: `scalex(${val / 100})`,
}}
></span>
</span>
</div>
)
}
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= {
"bg-green-500": system.status === "up",
"bg-red-500": system.status === "down",
"bg-primary/40": system.status === "paused",
"bg-yellow-500": system.status === "pending",
}
return (
<span
className={cn("flex-shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }}
/>
)
}
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { t } = useLingui()
const { id, status, host, name } = system
return useMemo(() => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy name</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}, [id, status, host, name, t, deleteOpen, editOpen])
})

View File

@@ -1,5 +1,4 @@
import { import {
CellContext,
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
getFilteredRowModel, getFilteredRowModel,
@@ -9,14 +8,13 @@ import {
VisibilityState, VisibilityState,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
HeaderContext,
Row, Row,
Table as TableType, Table as TableType,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -29,105 +27,30 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
import { import {
MoreHorizontalIcon,
ArrowUpDownIcon, ArrowUpDownIcon,
MemoryStickIcon,
CopyIcon,
PauseCircleIcon,
PlayCircleIcon,
Trash2Icon,
WifiIcon,
HardDriveIcon,
ServerIcon,
CpuIcon,
LayoutGridIcon, LayoutGridIcon,
LayoutListIcon, LayoutListIcon,
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
Settings2Icon, Settings2Icon,
EyeIcon, EyeIcon,
PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react" import { memo, useEffect, useMemo, useState } from "react"
import { $systems, $userSettings, pb } from "@/lib/stores" import { $systems } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { import { cn, useLocalStorage } from "@/lib/utils"
cn,
copyToClipboard,
isReadOnlyUser,
useLocalStorage,
formatTemperature,
decimalString,
formatBytes,
parseSemVer,
} from "@/lib/utils"
import AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router" import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useLingui, Trans } from "@lingui/react/macro" import { useLingui, Trans } from "@lingui/react/macro"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { ClassValue } from "clsx"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { SystemDialog } from "../add-system" import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
import { Dialog } from "../ui/dialog" import AlertButton from "../alerts/alert-button"
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid"
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = Number(info.getValue()) || 0
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</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",
(info.row.original.status !== "up" && "bg-primary/30") ||
(val < 65 && "bg-green-500") ||
(val < 90 && "bg-yellow-500") ||
"bg-red-600"
)}
style={{
transform: `scalex(${val / 100})`,
}}
></span>
</span>
</div>
)
}
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context
// @ts-ignore
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
return (
<Button
variant="ghost"
className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="me-2 size-4" />}
{name()}
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button>
)
}
export default function SystemsTable() { export default function SystemsTable() {
const data = useStore($systems) const data = useStore($systems)
const { i18n, t } = useLingui() const { i18n, t } = useLingui()
@@ -145,212 +68,7 @@ export default function SystemsTable() {
} }
}, [filter]) }, [filter])
const columnDefs = useMemo(() => { const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [])
const statusTranslations = {
up: () => t`Up`.toLowerCase(),
down: () => t`Down`.toLowerCase(),
paused: () => t`Paused`.toLowerCase(),
}
return [
{
size: 200,
minSize: 0,
accessorKey: "name",
id: "system",
name: () => t`System`,
filterFn: (row, _, filterVal) => {
const filterLower = filterVal.toLowerCase()
const { name, status } = row.original
// Check if the filter matches the name or status for this row
if (
name.toLowerCase().includes(filterLower) ||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
) {
return true
}
return false
},
enableHiding: false,
invertSorting: false,
Icon: ServerIcon,
cell: (info) => (
<span className="flex gap-2 items-center md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} />
<span className="font-medium text-sm">
{info.getValue() as string}
</span>
</span>
),
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.cpu,
id: "cpu",
name: () => t`CPU`,
cell: CellFormatter,
Icon: CpuIcon,
header: sortableHeader,
},
{
// accessorKey: "info.mp",
accessorFn: ({ info }) => info.mp,
id: "memory",
name: () => t`Memory`,
cell: CellFormatter,
Icon: MemoryStickIcon,
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.dp,
id: "disk",
name: () => t`Disk`,
cell: CellFormatter,
Icon: HardDriveIcon,
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.g,
id: "gpu",
name: () => "GPU",
cell: CellFormatter,
Icon: GpuIcon,
header: sortableHeader,
},
{
id: "loadAverage",
accessorFn: ({ info }) => {
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array
if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
}
return sum
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
// agent version
const { minor, patch } = parseSemVer(sysInfo.v)
let loadAverages = sysInfo.la
// use legacy load averages if agent version is less than 12.1.0
if (!loadAverages || (minor === 12 && patch < 1)) {
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
}
const max = Math.max(...loadAverages)
if (max === 0 && (status === "paused" || minor < 12)) {
return null
}
function getDotColor() {
const normalized = max / (sysInfo.t ?? 1)
if (status !== "up") return "bg-primary/30"
if (normalized < 0.7) return "bg-green-500"
if (normalized < 1) return "bg-yellow-500"
return "bg-red-600"
}
return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
{loadAverages?.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))}
</div>
)
},
},
{
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
id: "net",
name: () => t`Net`,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
const sys = info.row.original
if (sys.status === "paused") {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: ({ info }) => info.dt,
id: "temp",
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
size: 50,
hideSort: true,
Icon: ThermometerIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
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(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: ({ info }) => info.v,
id: "agent",
name: () => t`Agent`,
// invertSorting: true,
size: 50,
Icon: WifiIcon,
hideSort: true,
header: sortableHeader,
cell(info) {
const version = info.getValue() as string
if (!version) {
return null
}
const system = info.row.original
return (
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
<IndicatorDot
system={system}
className={
(system.status !== "up" && "bg-primary/30") ||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
"bg-yellow-500"
}
/>
<span className="truncate max-w-14">{info.getValue() as string}</span>
</span>
)
},
},
{
id: "actions",
// @ts-ignore
name: () => t({ message: "Actions", comment: "Table column" }),
size: 50,
cell: ({ row }) => (
<div className="flex justify-end items-center gap-1 -ms-3">
<AlertsButton system={row.original} />
<ActionsButton system={row.original} />
</div>
),
},
] as ColumnDef<SystemRecord>[]
}, [])
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -628,7 +346,7 @@ const SystemCard = memo(
</CardTitle> </CardTitle>
{table.getColumn("actions")?.getIsVisible() && ( {table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10"> <div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} /> <AlertButton system={system} />
<ActionsButton system={system} /> <ActionsButton system={system} />
</div> </div>
)} )}
@@ -663,120 +381,3 @@ const SystemCard = memo(
}, [system, colLength, t]) }, [system, colLength, t])
} }
) )
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { t } = useLingui()
const { id, status, host, name } = system
return useMemo(() => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy name</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}, [id, status, host, name, t, deleteOpen, editOpen])
})
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= {
"bg-green-500": system.status === "up",
"bg-red-500": system.status === "down",
"bg-primary/40": system.status === "paused",
"bg-yellow-500": system.status === "pending",
}
return (
<span
className={cn("flex-shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }}
/>
)
}

View File

@@ -21,3 +21,10 @@ export enum Unit {
Celsius, Celsius,
Fahrenheit, Fahrenheit,
} }
/** Meter state for color */
export enum MeterState {
Good,
Warn,
Crit,
}

View File

@@ -24,13 +24,21 @@ export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
/** Whether to display average or max chart values */ /** Whether to display average or max chart values */
export const $maxValues = atom(false) export const $maxValues = atom(false)
// export const UserSettingsSchema = v.object({
// chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
// emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
// webhooks: v.optional(v.array(v.string())),
// colorWarn: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// colorDanger: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// unitTemp: v.optional(v.enum(Unit)),
// unitNet: v.optional(v.enum(Unit)),
// unitDisk: v.optional(v.enum(Unit)),
// })
/** User settings */ /** User settings */
export const $userSettings = map<UserSettings>({ export const $userSettings = map<UserSettings>({
chartTime: "1h", chartTime: "1h",
emails: [pb.authStore.record?.email || ""], emails: [pb.authStore.record?.email || ""],
// unitTemp: "celsius",
// unitNet: "mbps",
// unitDisk: "mbps",
}) })
// update local storage on change // update local storage on change
$userSettings.subscribe((value) => { $userSettings.subscribe((value) => {

View File

@@ -20,7 +20,7 @@ import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons" import { EthernetIcon, HourglassIcon, ThermometerIcon } from "@/components/ui/icons"
import { prependBasePath } from "@/components/router" import { prependBasePath } from "@/components/router"
import { Unit } from "./enums" import { MeterState, Unit } from "./enums"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -507,3 +507,9 @@ export const parseSemVer = (semVer = ""): SemVer => {
const parts = semVer.split(".").map(Number) const parts = semVer.split(".").map(Number)
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 } return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
} }
/** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}

View File

@@ -224,13 +224,14 @@ export interface ChartTimeData {
} }
export interface UserSettings { export interface UserSettings {
// lang?: string
chartTime: ChartTimes chartTime: ChartTimes
emails?: string[] emails?: string[]
webhooks?: string[] webhooks?: string[]
unitTemp?: Unit unitTemp?: Unit
unitNet?: Unit unitNet?: Unit
unitDisk?: Unit unitDisk?: Unit
colorWarn?: number
colorCrit?: number
} }
type ChartDataContainer = { type ChartDataContainer = {

View File

@@ -1,9 +1,11 @@
services: services:
beszel-agent: beszel-agent:
image: 'henrygd/beszel-agent' image: 'henrygd/beszel-agent' #Or henrygd/beszel-agent-nvidia
container_name: 'beszel-agent' container_name: 'beszel-agent'
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
# Only when using henrygd/beszel-agent-nvidia
# runtime: nvidia
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems # monitor other disks / partitions by mounting a folder in /extra-filesystems
@@ -11,3 +13,6 @@ services:
environment: environment:
PORT: 45876 PORT: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY' KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
# Only when using henrygd/beszel-agent-nvidia
# NVIDIA_VISIBLE_DEVICES: all
# NVIDIA_DRIVER_CAPABILITIES: compute,video,utility

View File

@@ -11,13 +11,16 @@ services:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
beszel-agent: beszel-agent:
image: 'henrygd/beszel-agent' image: 'henrygd/beszel-agent' #Add -nvidia for nvidia gpus
container_name: 'beszel-agent' container_name: 'beszel-agent'
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
# runtime: nvidia # when using beszel-agent-nvidia
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
PORT: 45876 PORT: 45876
KEY: '...' KEY: '...'
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats # FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
# NVIDIA_VISIBLE_DEVICES: all # when using beszel-agent-nvidia
# NVIDIA_DRIVER_CAPABILITIES: utility # when using beszel-agent-nvidia