Compare commits

...

6 Commits

Author SHA1 Message Date
henrygd
1bec07359c refactor to show multiple percentages and simplify data structure 2025-11-11 17:30:51 -05:00
Sven van Ginkel
9eefacd482 Merge branch 'henrygd:main' into extra-disk-system-table 2025-11-11 19:54:43 +01:00
henrygd
aaa788bc2f add gpu usage alerts 2025-11-11 12:38:47 -05:00
Sven van Ginkel
85b786d11a Merge branch 'henrygd:main' into extra-disk-system-table 2025-11-06 19:20:13 +01:00
Sven van Ginkel
0cb7dba050 Merge branch 'henrygd:main' into extra-disk-system-table 2025-11-04 18:57:31 +01:00
Sven van Ginkel
c98472ca0b Add extra disks to system table 2025-11-02 20:59:34 +01:00
8 changed files with 126 additions and 21 deletions

View File

@@ -166,6 +166,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
} }
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 { if !stats.Root && stats.DiskTotal > 0 {
// Use custom name if available, otherwise use device name // Use custom name if available, otherwise use device name
@@ -174,6 +175,11 @@ 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
if stats.DiskTotal > 0 {
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct
}
} }
} }
slog.Debug("Extra FS", "data", data.Stats.ExtraFs) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)

View File

@@ -45,10 +45,15 @@ type SystemAlertStats struct {
Disk float64 `json:"dp"` Disk float64 `json:"dp"`
NetSent float64 `json:"ns"` NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"` NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
} }
type SystemAlertGPUData struct {
Usage float64 `json:"u"`
}
type SystemAlertData struct { type SystemAlertData struct {
systemRecord *core.Record systemRecord *core.Record
alertRecord *core.Record alertRecord *core.Record

View File

@@ -64,6 +64,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "LoadAvg15": case "LoadAvg15":
val = data.Info.LoadAvg[2] val = data.Info.LoadAvg[2]
unit = "" unit = ""
case "GPU":
val = data.Info.GpuPct
} }
triggered := alertRecord.GetBool("triggered") triggered := alertRecord.GetBool("triggered")
@@ -206,6 +208,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
alert.val += stats.LoadAvg[1] alert.val += stats.LoadAvg[1]
case "LoadAvg15": case "LoadAvg15":
alert.val += stats.LoadAvg[2] alert.val += stats.LoadAvg[2]
case "GPU":
if len(stats.GPU) == 0 {
continue
}
maxUsage := 0.0
for _, gpu := range stats.GPU {
if gpu.Usage > maxUsage {
maxUsage = gpu.Usage
}
}
alert.val += maxUsage
default: default:
continue continue
} }

View File

@@ -99,6 +99,7 @@ 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
@@ -146,6 +147,7 @@ type Info struct {
// TODO: remove load fields in future release in favor of load avg array // TODO: remove load fields in future release in favor of load avg array
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"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

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

View File

@@ -20,6 +20,7 @@ import {
WifiIcon, WifiIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useMemo, useRef, useState } from "react" import { memo, useMemo, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
import { isReadOnlyUser, pb } from "@/lib/api" import { isReadOnlyUser, pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums" import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
@@ -153,7 +154,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: TableCellWithMeter, cell: DiskCellWithMultiple,
Icon: HardDriveIcon, Icon: HardDriveIcon,
header: sortableHeader, header: sortableHeader,
}, },
@@ -354,6 +355,74 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
) )
} }
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
const rootDiskPct = sysInfo.dp
const extraFsData = sysInfo.efs
const extraFsCount = extraFsData ? Object.keys(extraFsData).length : 0
function getMeterClass(pct: number) {
const threshold = getMeterState(pct)
return cn(
"h-full",
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down
)
}
// No extra disks - show simple meter
if (extraFsCount === 0) {
return TableCellWithMeter(info)
}
// Has extra disks - show with tooltip
return (
<Tooltip>
<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">
<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="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
{extraFsData && Object.entries(extraFsData).slice(0, 2).map(([_name, pct], index) => (
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
))}
</span>
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs pb-2">
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tabular-nums">{t`Root`}</div>
<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="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden">
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
</span>
</div>
</div>
{extraFsData && Object.entries(extraFsData).map(([name, pct]) => {
return (
<div key={name} className="flex flex-col gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider truncate">{name}</div>
<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="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden">
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
</span>
</div>
</div>
)
})}
</div>
</TooltipContent>
</Tooltip>
)
}
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || "" className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
return ( return (

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase" import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons" import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores" import { $alerts } from "@/lib/stores"
import type { AlertInfo, AlertRecord } from "@/types" import type { AlertInfo, AlertRecord } from "@/types"
import { pb } from "./api" import { pb } from "./api"
@@ -41,6 +41,12 @@ export const alertInfo: Record<string, AlertInfo> = {
desc: () => t`Triggers when combined up/down exceeds a threshold`, desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125, max: 125,
}, },
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: { Temperature: {
name: () => t`Temperature`, name: () => t`Temperature`,
unit: "°C", unit: "°C",

View File

@@ -77,8 +77,11 @@ export interface SystemInfo {
os?: Os os?: Os
/** connection type */ /** connection type */
ct?: ConnectionType ct?: ConnectionType
/** extra filesystem percentages */
efs?: Record<string, number>
} }
export interface SystemStats { export interface SystemStats {
/** cpu percent */ /** cpu percent */
cpu: number cpu: number
@@ -301,18 +304,18 @@ export interface ChartData {
chartTime: ChartTimes chartTime: ChartTimes
} }
// interface AlertInfo { export interface AlertInfo {
// name: () => string name: () => string
// unit: string unit: string
// icon: any icon: any
// desc: () => string desc: () => string
// max?: number max?: number
// min?: number min?: number
// step?: number step?: number
// start?: number start?: number
// /** Single value description (when there's only one value, like status) */ /** Single value description (when there's only one value, like status) */
// singleDesc?: () => string singleDesc?: () => string
// } }
export type AlertMap = Record<string, Map<string, AlertRecord>> export type AlertMap = Record<string, Map<string, AlertRecord>>