Compare commits

..

4 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
12 changed files with 135 additions and 47 deletions

View File

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

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

@@ -14,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,
@@ -44,7 +32,10 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
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 (zero values can be ignored) // intialize settings with defaults (zero values can be ignored)
settings := UserSettings{ settings := struct {
ChartTime string `json:"chartTime"`
Emails []string `json:"emails"`
}{
ChartTime: "1h", ChartTime: "1h",
} }
record.UnmarshalJSONField("settings", &settings) record.UnmarshalJSONField("settings", &settings)
@@ -59,10 +50,7 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
log.Println("failed to get user email", "err", err) log.Println("failed to get user email", "err", err)
return err return err
} }
settings.NotificationEmails = []string{user.Email} settings.Emails = []string{user.Email}
if len(settings.NotificationWebhooks) == 0 {
settings.NotificationWebhooks = []string{""}
}
record.Set("settings", settings) record.Set("settings", settings)
return e.Next() return e.Next()
} }

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

@@ -22,6 +22,7 @@ import {
decimalString, decimalString,
formatBytes, formatBytes,
formatTemperature, formatTemperature,
getMeterState,
isReadOnlyUser, isReadOnlyUser,
parseSemVer, parseSemVer,
} from "@/lib/utils" } from "@/lib/utils"
@@ -53,6 +54,7 @@ import {
} from "../ui/alert-dialog" } from "../ui/alert-dialog"
import { buttonVariants } from "../ui/button" import { buttonVariants } from "../ui/button"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { MeterState } from "@/lib/enums"
/** /**
* @param viewMode - "table" or "grid" * @param viewMode - "table" or "grid"
@@ -98,7 +100,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorFn: ({ info }) => info.cpu, accessorFn: ({ info }) => info.cpu,
id: "cpu", id: "cpu",
name: () => t`CPU`, name: () => t`CPU`,
cell: CellFormatter, cell: TableCellWithMeter,
Icon: CpuIcon, Icon: CpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
@@ -107,7 +109,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorFn: ({ info }) => info.mp, accessorFn: ({ info }) => info.mp,
id: "memory", id: "memory",
name: () => t`Memory`, name: () => t`Memory`,
cell: CellFormatter, cell: TableCellWithMeter,
Icon: MemoryStickIcon, Icon: MemoryStickIcon,
header: sortableHeader, header: sortableHeader,
}, },
@@ -115,7 +117,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorFn: ({ info }) => info.dp, accessorFn: ({ info }) => info.dp,
id: "disk", id: "disk",
name: () => t`Disk`, name: () => t`Disk`,
cell: CellFormatter, cell: TableCellWithMeter,
Icon: HardDriveIcon, Icon: HardDriveIcon,
header: sortableHeader, header: sortableHeader,
}, },
@@ -123,7 +125,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorFn: ({ info }) => info.g, accessorFn: ({ info }) => info.g,
id: "gpu", id: "gpu",
name: () => "GPU", name: () => "GPU",
cell: CellFormatter, cell: TableCellWithMeter,
Icon: GpuIcon, Icon: GpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
@@ -157,17 +159,18 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
return null return null
} }
function getDotColor() { const normalizedLoad = max / (sysInfo.t ?? 1)
const normalized = max / (sysInfo.t ?? 1) const threshold = getMeterState(normalizedLoad * 100)
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 ( return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight"> <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())} /> <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) => ( {loadAverages?.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span> <span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))} ))}
@@ -279,8 +282,10 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
</Button> </Button>
) )
} }
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const val = Number(info.getValue()) || 0 const val = Number(info.getValue()) || 0
const threshold = getMeterState(val)
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight"> <div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span> <span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
@@ -289,8 +294,8 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
className={cn( className={cn(
"absolute inset-0 w-full h-full origin-left", "absolute inset-0 w-full h-full origin-left",
(info.row.original.status !== "up" && "bg-primary/30") || (info.row.original.status !== "up" && "bg-primary/30") ||
(val < 65 && "bg-green-500") || (threshold === MeterState.Good && "bg-green-500") ||
(val < 90 && "bg-yellow-500") || (threshold === MeterState.Warn && "bg-yellow-500") ||
"bg-red-600" "bg-red-600"
)} )}
style={{ style={{

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