diff --git a/agent/agent.go b/agent/agent.go index 9f8ec78b..75f51cbc 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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 - if a.systemdManager != nil && cacheTimeMs == 60_000 && a.systemdManager.hasFreshStats { - data.SystemdServices = a.systemdManager.getServiceStats(nil, false) + if a.systemdManager != nil && cacheTimeMs == 60_000 { + 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) diff --git a/agent/systemd.go b/agent/systemd.go index da27982b..ea63fa4d 100644 --- a/agent/systemd.go +++ b/agent/systemd.go @@ -62,6 +62,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. func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service { // start := time.Now() diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index c2f7728e..544fa13b 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -147,6 +147,7 @@ type Info struct { LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` 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 diff --git a/internal/site/src/components/systems-table/systems-table-columns.tsx b/internal/site/src/components/systems-table/systems-table-columns.tsx index 8eb9b3c3..a732e7b5 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -16,6 +16,7 @@ import { PenBoxIcon, PlayCircleIcon, ServerIcon, + TerminalSquareIcon, Trash2Icon, WifiIcon, } from "lucide-react" @@ -57,6 +58,7 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons" +import { Separator } from "../ui/separator" const STATUS_COLORS = { [SystemStatus.Up]: "bg-green-500", @@ -69,7 +71,7 @@ const STATUS_COLORS = { * @param viewMode - "table" or "grid" * @returns - Column definitions for the systems table */ -export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef[] { +export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef[] { return [ { // size: 200, @@ -134,7 +136,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD header: sortableHeader, }, { - accessorFn: ({ info }) => info.cpu, + accessorFn: ({ info }) => info.cpu || undefined, id: "cpu", name: () => t`CPU`, cell: TableCellWithMeter, @@ -143,7 +145,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD }, { // accessorKey: "info.mp", - accessorFn: ({ info }) => info.mp, + accessorFn: ({ info }) => info.mp || undefined, id: "memory", name: () => t`Memory`, cell: TableCellWithMeter, @@ -151,7 +153,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD header: sortableHeader, }, { - accessorFn: ({ info }) => info.dp, + accessorFn: ({ info }) => info.dp || undefined, id: "disk", name: () => t`Disk`, cell: DiskCellWithMultiple, @@ -159,7 +161,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD header: sortableHeader, }, { - accessorFn: ({ info }) => info.g, + accessorFn: ({ info }) => info.g || undefined, id: "gpu", name: () => "GPU", cell: TableCellWithMeter, @@ -172,9 +174,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD 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 (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" }), size: 0, @@ -217,7 +219,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", name: () => t`Net`, size: 0, @@ -259,11 +261,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 ( + + 0, + [STATUS_COLORS[SystemStatus.Up]]: numFailed === 0, + })} + /> + {totalCount} ({t`Failed`.toLowerCase()}: {numFailed}) + + ) + }, + }, { accessorFn: ({ info }) => info.v, id: "agent", name: () => t`Agent`, - // invertSorting: true, size: 50, Icon: WifiIcon, hideSort: true, diff --git a/internal/site/src/components/systems-table/systems-table.tsx b/internal/site/src/components/systems-table/systems-table.tsx index f2a18863..3172402d 100644 --- a/internal/site/src/components/systems-table/systems-table.tsx +++ b/internal/site/src/components/systems-table/systems-table.tsx @@ -47,7 +47,7 @@ import type { SystemRecord } from "@/types" import AlertButton from "../alerts/alert-button" import { $router, Link } from "../router" 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 StatusFilter = "all" | SystemRecord["status"] diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 07dad0ac..c4d5df32 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -79,6 +79,8 @@ export interface SystemInfo { ct?: ConnectionType /** extra filesystem percentages */ efs?: Record + /** services [totalServices, numFailedServices] */ + sv?: [number, number] } export interface SystemStats {