Compare commits

...

6 Commits

Author SHA1 Message Date
henrygd
e1067fa1a3 make layout width adjustable 2025-11-13 18:50:47 -05:00
henrygd
0a3eb898ae truncate device name in smart table (#1416) 2025-11-13 16:41:15 -05:00
evrial
6c33e9dc93 Set a dynamic upper domain on the YAxis for container chart (#1412) 2025-11-13 16:28:37 -05:00
henrygd
f8ed6ce705 refactor: fix nan when net value is undefined in systems table 2025-11-13 16:25:21 -05:00
henrygd
f64478b75e add SERVICE_PATTERNS env var (#1153) 2025-11-13 16:11:24 -05:00
henrygd
854a3697d7 add services column to all systems table 2025-11-13 15:09:48 -05:00
12 changed files with 252 additions and 20 deletions

View File

@@ -161,8 +161,15 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
} }
// skip updating systemd services if cache time is not the default 60sec interval // skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == 60_000 && a.systemdManager.hasFreshStats { if a.systemdManager != nil && cacheTimeMs == 60_000 {
data.SystemdServices = a.systemdManager.getServiceStats(nil, false) totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 {
numFailed := a.systemdManager.getFailedServiceCount()
data.Info.Services = []uint16{totalCount, numFailed}
}
if a.systemdManager.hasFreshStats {
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
}
} }
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)

View File

@@ -27,6 +27,7 @@ type systemdManager struct {
serviceStatsMap map[string]*systemd.Service serviceStatsMap map[string]*systemd.Service
isRunning bool isRunning bool
hasFreshStats bool hasFreshStats bool
patterns []string
} }
// newSystemdManager creates a new systemdManager. // newSystemdManager creates a new systemdManager.
@@ -39,6 +40,7 @@ func newSystemdManager() (*systemdManager, error) {
manager := &systemdManager{ manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service), serviceStatsMap: make(map[string]*systemd.Service),
patterns: getServicePatterns(),
} }
manager.startWorker(conn) manager.startWorker(conn)
@@ -62,6 +64,24 @@ func (sm *systemdManager) startWorker(conn *dbus.Conn) {
}() }()
} }
// getServiceStatsCount returns the number of systemd services.
func (sm *systemdManager) getServiceStatsCount() int {
return len(sm.serviceStatsMap)
}
// getFailedServiceCount returns the number of systemd services in a failed state.
func (sm *systemdManager) getFailedServiceCount() uint16 {
sm.Lock()
defer sm.Unlock()
count := uint16(0)
for _, service := range sm.serviceStatsMap {
if service.State == systemd.StatusFailed {
count++
}
}
return count
}
// getServiceStats collects statistics for all running systemd services. // getServiceStats collects statistics for all running systemd services.
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service { func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
// start := time.Now() // start := time.Now()
@@ -91,7 +111,7 @@ func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*syst
defer conn.Close() defer conn.Close()
} }
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, []string{"*.service"}) units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
if err != nil { if err != nil {
slog.Error("Error listing systemd service units", "err", err) slog.Error("Error listing systemd service units", "err", err)
return nil return nil
@@ -227,3 +247,26 @@ func unescapeServiceName(name string) string {
} }
return unescaped return unescaped
} }
// getServicePatterns returns the list of service patterns to match.
// It reads from the SERVICE_PATTERNS environment variable if set,
// otherwise defaults to "*service".
func getServicePatterns() []string {
patterns := []string{}
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
for pattern := range strings.SplitSeq(envPatterns, ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if !strings.HasSuffix(pattern, ".service") {
pattern += ".service"
}
patterns = append(patterns, pattern)
}
}
if len(patterns) == 0 {
patterns = []string{"*.service"}
}
return patterns
}

View File

@@ -3,6 +3,7 @@
package agent package agent
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -46,3 +47,112 @@ func TestUnescapeServiceNameInvalid(t *testing.T) {
}) })
} }
} }
func TestGetServicePatterns(t *testing.T) {
tests := []struct {
name string
prefixedEnv string
unprefixedEnv string
expected []string
cleanupEnvVars bool
}{
{
name: "default when no env var set",
prefixedEnv: "",
unprefixedEnv: "",
expected: []string{"*.service"},
cleanupEnvVars: true,
},
{
name: "single pattern with prefixed env",
prefixedEnv: "nginx",
unprefixedEnv: "",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "single pattern with unprefixed env",
prefixedEnv: "",
unprefixedEnv: "nginx",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "prefixed env takes precedence",
prefixedEnv: "nginx",
unprefixedEnv: "apache",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "multiple patterns",
prefixedEnv: "nginx,apache,postgresql",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "patterns with .service suffix",
prefixedEnv: "nginx.service,apache.service",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service"},
cleanupEnvVars: true,
},
{
name: "mixed patterns with and without suffix",
prefixedEnv: "nginx.service,apache,postgresql.service",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "patterns with whitespace",
prefixedEnv: " nginx , apache , postgresql ",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "empty patterns are skipped",
prefixedEnv: "nginx,,apache, ,postgresql",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "wildcard pattern",
prefixedEnv: "*nginx*,*apache*",
unprefixedEnv: "",
expected: []string{"*nginx*.service", "*apache*.service"},
cleanupEnvVars: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up any existing env vars
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
// Set up environment variables
if tt.prefixedEnv != "" {
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
}
if tt.unprefixedEnv != "" {
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
}
// Run the function
result := getServicePatterns()
// Verify results
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
// Cleanup
if tt.cleanupEnvVars {
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
}
})
}
}

View File

@@ -147,6 +147,7 @@ type Info struct {
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -124,6 +124,7 @@ export default memo(function ContainerChart({
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
direction="ltr" direction="ltr"
domain={[0, 'dataMax']}
orientation={chartData.orientation} orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}

View File

@@ -2,14 +2,17 @@
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react" import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useStore } from "@nanostores/react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums" import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { $userSettings } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils" import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types" import type { UserSettings } from "@/types"
import { saveSettings } from "./layout" import { saveSettings } from "./layout"
@@ -17,6 +20,8 @@ import { saveSettings } from "./layout"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui() const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? 1480
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
@@ -73,6 +78,27 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</Select> </Select>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans>Layout width</Trans>
</h3>
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
</Label>
</div>
<Slider
id="layoutWidth"
name="layoutWidth"
value={[layoutWidth]}
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
min={1000}
max={2000}
step={10}
className="w-full mb-1"
/>
</div>
<Separator />
<div className="grid gap-2"> <div className="grid gap-2">
<div className="mb-2"> <div className="mb-2">
<h3 className="mb-1 text-lg font-medium"> <h3 className="mb-1 text-lg font-medium">

View File

@@ -118,7 +118,9 @@ export const columns: ColumnDef<DiskInfo>[] = [
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device), sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />, header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="font-medium ms-1.5">{row.getValue("device")}</div> <div className="font-medium max-w-50 truncate ms-1.5" title={row.getValue("device")}>
{row.getValue("device")}
</div>
), ),
}, },
{ {

View File

@@ -16,6 +16,7 @@ import {
PenBoxIcon, PenBoxIcon,
PlayCircleIcon, PlayCircleIcon,
ServerIcon, ServerIcon,
TerminalSquareIcon,
Trash2Icon, Trash2Icon,
WifiIcon, WifiIcon,
} from "lucide-react" } from "lucide-react"
@@ -69,7 +70,7 @@ const STATUS_COLORS = {
* @param viewMode - "table" or "grid" * @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table * @returns - Column definitions for the systems table
*/ */
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] { export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
return [ return [
{ {
// size: 200, // size: 200,
@@ -134,7 +135,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: ({ info }) => info.cpu, accessorFn: ({ info }) => info.cpu || undefined,
id: "cpu", id: "cpu",
name: () => t`CPU`, name: () => t`CPU`,
cell: TableCellWithMeter, cell: TableCellWithMeter,
@@ -143,7 +144,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
}, },
{ {
// accessorKey: "info.mp", // accessorKey: "info.mp",
accessorFn: ({ info }) => info.mp, accessorFn: ({ info }) => info.mp || undefined,
id: "memory", id: "memory",
name: () => t`Memory`, name: () => t`Memory`,
cell: TableCellWithMeter, cell: TableCellWithMeter,
@@ -151,7 +152,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: ({ info }) => info.dp, accessorFn: ({ info }) => info.dp || undefined,
id: "disk", id: "disk",
name: () => t`Disk`, name: () => t`Disk`,
cell: DiskCellWithMultiple, cell: DiskCellWithMultiple,
@@ -159,7 +160,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: ({ info }) => info.g, accessorFn: ({ info }) => info.g || undefined,
id: "gpu", id: "gpu",
name: () => "GPU", name: () => "GPU",
cell: TableCellWithMeter, cell: TableCellWithMeter,
@@ -172,9 +173,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
const sum = info.la?.reduce((acc, curr) => acc + curr, 0) const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array // TODO: remove this in future release in favor of la array
if (!sum) { if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
} }
return sum return sum || undefined
}, },
name: () => t({ message: "Load Avg", comment: "Short label for load average" }), name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0, size: 0,
@@ -217,7 +218,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
}, },
}, },
{ {
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024, accessorFn: ({ info }) => (info.bb || (info.b || 0) * 1024 * 1024) || undefined,
id: "net", id: "net",
name: () => t`Net`, name: () => t`Net`,
size: 0, size: 0,
@@ -229,10 +230,10 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
if (sys.status === SystemStatus.Paused) { if (sys.status === SystemStatus.Paused) {
return null return null
} }
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false) const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
return ( return (
<span className="tabular-nums whitespace-nowrap"> <span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit} {decimalString(value , value >= 100 ? 1 : 2)} {unit}
</span> </span>
) )
}, },
@@ -259,11 +260,46 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
) )
}, },
}, },
{
accessorFn: ({ info }) => info.sv?.[0],
id: "services",
name: () => t`Services`,
size: 50,
Icon: TerminalSquareIcon,
header: sortableHeader,
hideSort: true,
sortingFn: (a, b) => {
// sort priorities: 1) failed services, 2) total services
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0]
if (numFailedA !== numFailedB) {
return numFailedA - numFailedB
}
return totalCountA - totalCountB
},
cell(info) {
const sys = info.row.original
const [totalCount, numFailed] = sys.info.sv ?? [0, 0]
if (sys.status !== SystemStatus.Up || totalCount === 0) {
return null
}
return (
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
<span
className={cn("block size-2 rounded-full", {
[STATUS_COLORS[SystemStatus.Down]]: numFailed > 0,
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
})}
/>
{totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span>
</span>
)
},
},
{ {
accessorFn: ({ info }) => info.v, accessorFn: ({ info }) => info.v,
id: "agent", id: "agent",
name: () => t`Agent`, name: () => t`Agent`,
// invertSorting: true,
size: 50, size: 50,
Icon: WifiIcon, Icon: WifiIcon,
hideSort: true, hideSort: true,

View File

@@ -47,7 +47,7 @@ import type { SystemRecord } from "@/types"
import AlertButton from "../alerts/alert-button" import AlertButton from "../alerts/alert-button"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns" import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid"
type StatusFilter = "all" | SystemRecord["status"] type StatusFilter = "all" | SystemRecord["status"]

View File

@@ -34,6 +34,7 @@
--table-header: hsl(225, 6%, 97%); --table-header: hsl(225, 6%, 97%);
--chart-saturation: 65%; --chart-saturation: 65%;
--chart-lightness: 50%; --chart-lightness: 50%;
--container: 1480px;
} }
.dark { .dark {
@@ -149,7 +150,8 @@
} }
@utility container { @utility container {
@apply max-w-370 mx-auto px-4; max-width: var(--container);
@apply mx-auto px-4;
} }
@utility link { @utility link {

View File

@@ -14,7 +14,7 @@ import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts" import { alertManager } from "@/lib/alerts"
import { pb, updateUserSettings } from "@/lib/api.ts" import { pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n" import { dynamicActivate, getLocale } from "@/lib/i18n"
import { $authenticated, $copyContent, $direction, $publicKey } from "@/lib/stores.ts" import { $authenticated, $copyContent, $direction, $publicKey, $userSettings } from "@/lib/stores.ts"
import * as systemsManager from "@/lib/systemsManager.ts" import * as systemsManager from "@/lib/systemsManager.ts"
const LoginPage = lazy(() => import("@/components/login/login.tsx")) const LoginPage = lazy(() => import("@/components/login/login.tsx"))
@@ -71,6 +71,7 @@ const Layout = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const copyContent = useStore($copyContent) const copyContent = useStore($copyContent)
const direction = useStore($direction) const direction = useStore($direction)
const userSettings = useStore($userSettings)
useEffect(() => { useEffect(() => {
document.documentElement.dir = direction document.documentElement.dir = direction
@@ -96,7 +97,7 @@ const Layout = () => {
<LoginPage /> <LoginPage />
</Suspense> </Suspense>
) : ( ) : (
<> <div style={{"--container": `${userSettings.layoutWidth ?? 1480}px`} as React.CSSProperties}>
<div className="container"> <div className="container">
<Navbar /> <Navbar />
</div> </div>
@@ -108,7 +109,7 @@ const Layout = () => {
</Suspense> </Suspense>
)} )}
</div> </div>
</> </div>
)} )}
</DirectionProvider> </DirectionProvider>
) )

View File

@@ -79,6 +79,8 @@ export interface SystemInfo {
ct?: ConnectionType ct?: ConnectionType
/** extra filesystem percentages */ /** extra filesystem percentages */
efs?: Record<string, number> efs?: Record<string, number>
/** services [totalServices, numFailedServices] */
sv?: [number, number]
} }
export interface SystemStats { export interface SystemStats {
@@ -279,6 +281,7 @@ export interface UserSettings {
colorWarn?: number colorWarn?: number
colorCrit?: number colorCrit?: number
hourFormat?: HourFormat hourFormat?: HourFormat
layoutWidth?: number
} }
type ChartDataContainer = { type ChartDataContainer = {