Compare commits

..

4 Commits

Author SHA1 Message Date
henrygd
b722ccc5bc show additional disk percentages in systems table (#1365) 2025-11-12 14:15:45 -05:00
hank
db0315394b New translations 2025-11-12 13:12:05 -05:00
henrygd
a7ef1235f4 specify latest tag for non-alpine agent image
also change capitalization for gpu alert
2025-11-11 16:18:54 -05:00
henrygd
f64a361c60 add EXCLUDE_SMART env var (#1392) 2025-11-11 16:05:00 -05:00
10 changed files with 290 additions and 45 deletions

View File

@@ -33,6 +33,7 @@ jobs:
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
tags: | tags: |
type=raw,value=edge type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
@@ -99,6 +100,7 @@ jobs:
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
tags: | tags: |
type=raw,value=edge type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}

View File

@@ -175,7 +175,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
key = stats.Name key = stats.Name
} }
data.Stats.ExtraFs[key] = stats data.Stats.ExtraFs[key] = stats
// Add percentage info to Info struct for dashboard // Add percentages to Info struct for dashboard
if stats.DiskTotal > 0 { if stats.DiskTotal > 0 {
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct data.Info.ExtraFsPct[key] = pct

View File

@@ -29,6 +29,7 @@ type SmartManager struct {
refreshMutex sync.Mutex refreshMutex sync.Mutex
lastScanTime time.Time lastScanTime time.Time
binPath string binPath string
excludedDevices map[string]struct{}
} }
type scanOutput struct { type scanOutput struct {
@@ -185,6 +186,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
} }
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices) finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices) sm.updateSmartDevices(finalDevices)
if len(finalDevices) == 0 { if len(finalDevices) == 0 {
@@ -232,6 +234,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
return devices, nil return devices, nil
} }
func (sm *SmartManager) refreshExcludedDevices() {
rawValue, _ := GetEnv("EXCLUDE_SMART")
sm.excludedDevices = make(map[string]struct{})
for entry := range strings.SplitSeq(rawValue, ",") {
device := strings.TrimSpace(entry)
if device == "" {
continue
}
sm.excludedDevices[device] = struct{}{}
}
}
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
_, exists := sm.excludedDevices[deviceName]
return exists
}
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
if devices == nil {
return []*DeviceInfo{}
}
excluded := sm.excludedDevices
if len(excluded) == 0 {
return devices
}
filtered := make([]*DeviceInfo, 0, len(devices))
for _, device := range devices {
if device == nil || device.Name == "" {
continue
}
if _, skip := excluded[device.Name]; skip {
continue
}
filtered = append(filtered, device)
}
return filtered
}
// detectSmartOutputType inspects sections that are unique to each smartctl // detectSmartOutputType inspects sections that are unique to each smartctl
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used // JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
// when the reported device type is ambiguous or missing. // when the reported device type is ambiguous or missing.
@@ -378,6 +421,10 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode // Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
// for initial data collection when no cached data exists // for initial data collection when no cached data exists
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
return errNoValidSmartData
}
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name)) // slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
// Check if we have any existing data for this device // Check if we have any existing data for this device
@@ -409,10 +456,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
if !hasValidData { if !hasValidData {
if err != nil { if err != nil {
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err) slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
return err return err
} }
slog.Debug("no valid SMART data found", "device", deviceInfo.Name) slog.Info("no valid SMART data found", "device", deviceInfo.Name)
return errNoValidSmartData return errNoValidSmartData
} }
@@ -915,6 +962,7 @@ func NewSmartManager() (*SmartManager, error) {
sm := &SmartManager{ sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
sm.refreshExcludedDevices()
path, err := sm.detectSmartctl() path, err := sm.detectSmartctl()
if err != nil { if err != nil {
slog.Debug(err.Error()) slog.Debug(err.Error())

View File

@@ -588,3 +588,195 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
}) })
} }
} }
func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct {
name string
envValue string
expectedDevs map[string]struct{}
}{
{
name: "empty env",
envValue: "",
expectedDevs: map[string]struct{}{},
},
{
name: "single device",
envValue: "/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
},
},
{
name: "multiple devices",
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "devices with whitespace",
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "duplicate devices",
envValue: "/dev/sda,/dev/sdb,/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
{
name: "empty entries and whitespace",
envValue: "/dev/sda,, /dev/sdb , , ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
t.Setenv("EXCLUDE_SMART", tt.envValue)
} else {
// Ensure env var is not set for empty test
os.Unsetenv("EXCLUDE_SMART")
}
sm := &SmartManager{}
sm.refreshExcludedDevices()
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
})
}
}
func TestIsExcludedDevice(t *testing.T) {
sm := &SmartManager{
excludedDevices: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
}
tests := []struct {
name string
deviceName string
expectedBool bool
}{
{"excluded device sda", "/dev/sda", true},
{"excluded device nvme0", "/dev/nvme0", true},
{"non-excluded device sdb", "/dev/sdb", false},
{"non-excluded device nvme1", "/dev/nvme1", false},
{"empty device name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sm.isExcludedDevice(tt.deviceName)
assert.Equal(t, tt.expectedBool, result)
})
}
}
func TestFilterExcludedDevices(t *testing.T) {
tests := []struct {
name string
excludedDevs map[string]struct{}
inputDevices []*DeviceInfo
expectedDevs []*DeviceInfo
expectedLength int
}{
{
name: "no exclusions",
excludedDevs: map[string]struct{}{},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedLength: 3,
},
{
name: "some devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
{Name: "/dev/nvme1"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
{Name: "/dev/nvme1"},
},
expectedLength: 2,
},
{
name: "all devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "nil devices",
excludedDevs: map[string]struct{}{},
inputDevices: nil,
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "filter nil and empty name devices",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
nil,
{Name: ""},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
},
expectedLength: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sm := &SmartManager{
excludedDevices: tt.excludedDevs,
}
result := sm.filterExcludedDevices(tt.inputDevices)
assert.Len(t, result, tt.expectedLength)
assert.Equal(t, tt.expectedDevs, result)
})
}
}

View File

@@ -281,9 +281,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
alert.name = after + "m Load" alert.name = after + "m Load"
} }
// make title alert name lowercase if not CPU // make title alert name lowercase if not CPU or GPU
titleAlertName := alert.name titleAlertName := alert.name
if titleAlertName != "CPU" { if titleAlertName != "CPU" && titleAlertName != "GPU" {
titleAlertName = strings.ToLower(titleAlertName) titleAlertName = strings.ToLower(titleAlertName)
} }

View File

@@ -99,7 +99,6 @@ type FsStats struct {
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"` MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
} }
type NetIoStats struct { type NetIoStats struct {
BytesRecv uint64 BytesRecv uint64
BytesSent uint64 BytesSent uint64

View File

@@ -356,10 +356,18 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
} }
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) { function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original const { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {})
// No extra disks - show basic meter
if (extraFs.length === 0) {
return TableCellWithMeter(info)
}
const rootDiskPct = sysInfo.dp const rootDiskPct = sysInfo.dp
const extraFsData = sysInfo.efs
const extraFsCount = extraFsData ? Object.keys(extraFsData).length : 0 // sort extra disks by percentage descending
extraFs.sort((a, b) => b[1] - a[1])
function getMeterClass(pct: number) { function getMeterClass(pct: number) {
const threshold = getMeterState(pct) const threshold = getMeterState(pct)
@@ -372,45 +380,42 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
) )
} }
// No extra disks - show simple meter
if (extraFsCount === 0) {
return TableCellWithMeter(info)
}
// Has extra disks - show with tooltip
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href={getPagePath($router, "system", { id: info.row.original.id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10"> <Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
<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 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span> <span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden"> <span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
{/* Root disk */}
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span> <span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
{extraFsData && Object.entries(extraFsData).slice(0, 2).map(([_name, pct], index) => ( {/* Extra disks */}
{extraFs.map(([_name, pct], index) => (
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span> <span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
))} ))}
</span> </span>
</div> </div>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-xs pb-2"> <TooltipContent side="right" className="max-w-xs pb-2">
<div className="flex flex-col gap-1.5"> <div className="grid gap-1.5">
<div className="flex flex-col gap-0.5"> <div className="grid gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tabular-nums">{t`Root`}</div> <div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums">{t`Root`}</div>
<div className="flex gap-2 items-center tabular-nums text-xs"> <div className="flex gap-2 items-center tabular-nums text-xs">
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span> <span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden"> <span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span> <span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
</span> </span>
</div> </div>
</div> </div>
{extraFsData && Object.entries(extraFsData).map(([name, pct]) => { {extraFs.map(([name, pct]) => {
return ( return (
<div key={name} className="flex flex-col gap-0.5"> <div key={name} className="grid gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider truncate">{name}</div> <div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
<div className="flex gap-2 items-center tabular-nums text-xs"> <div className="flex gap-2 items-center tabular-nums text-xs">
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span> <span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden"> <span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span> <span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
</span> </span>
</div> </div>

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n" "Language: fr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-28 22:59\n" "PO-Revision-Date: 2025-11-11 19:25\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: French\n" "Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -990,7 +990,7 @@ msgstr "Réinitialiser le mot de passe"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Resolved" msgid "Resolved"
msgstr "Résolution" msgstr "Résolu"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Resume" msgid "Resume"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sv\n" "Language: sv\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-28 23:00\n" "PO-Revision-Date: 2025-11-10 01:57\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Swedish\n" "Language-Team: Swedish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -113,11 +113,11 @@ msgstr "Justera visningsalternativ för diagram."
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -448,7 +448,7 @@ msgstr "Urladdar"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Disk" msgid "Disk"
msgstr "" msgstr "Disk"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Disk I/O" msgid "Disk I/O"
@@ -625,7 +625,7 @@ msgstr "FreeBSD kommando"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Full" msgid "Full"
msgstr "" msgstr "Full"
#. Context: General settings #. Context: General settings
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
@@ -746,7 +746,7 @@ msgstr "Manuella installationsinstruktioner"
#. Chart select field. Please try to keep this short. #. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Max 1 min" msgid "Max 1 min"
msgstr "" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -938,7 +938,7 @@ msgstr "Vänligen logga in på ditt konto"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1183,7 +1183,7 @@ msgstr "Växla tema"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "Token" msgstr "Nyckel"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1260,7 +1260,7 @@ msgstr "Enhetsinställningar"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token" msgid "Universal token"
msgstr "Universal token" msgstr "Universell nyckel"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts

View File

@@ -81,7 +81,6 @@ export interface SystemInfo {
efs?: Record<string, number> efs?: Record<string, number>
} }
export interface SystemStats { export interface SystemStats {
/** cpu percent */ /** cpu percent */
cpu: number cpu: number