diff --git a/agent/system.go b/agent/system.go index f6b3c7d7..f845b956 100644 --- a/agent/system.go +++ b/agent/system.go @@ -205,6 +205,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2] a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct + a.systemInfo.Battery = systemStats.Battery a.systemInfo.Uptime, _ = host.Uptime() // TODO: in future release, remove MB bandwidth values in favor of bytes a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv) diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 544fa13b..0649666f 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -148,6 +148,7 @@ type Info struct { 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] + Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state] } // Final data structure to return to the hub diff --git a/internal/site/src/components/active-alerts.tsx b/internal/site/src/components/active-alerts.tsx index fca607e5..bf37d3c2 100644 --- a/internal/site/src/components/active-alerts.tsx +++ b/internal/site/src/components/active-alerts.tsx @@ -61,6 +61,11 @@ export const ActiveAlerts = () => { {alert.name === "Status" ? ( Connection is down + ) : info.invert ? ( + + Below {alert.value} + {info.unit} in last + ) : ( Exceeds {alert.value} diff --git a/internal/site/src/components/alerts/alerts-sheet.tsx b/internal/site/src/components/alerts/alerts-sheet.tsx index 645ab100..6d831141 100644 --- a/internal/site/src/components/alerts/alerts-sheet.tsx +++ b/internal/site/src/components/alerts/alerts-sheet.tsx @@ -245,7 +245,7 @@ export function AlertContent({ {!singleDescription && (

- {alertKey === "Battery" ? ( + {alertData.invert ? ( Average drops below{" "} diff --git a/internal/site/src/components/routes/system/smart-table.tsx b/internal/site/src/components/routes/system/smart-table.tsx index 77716eff..610f2c83 100644 --- a/internal/site/src/components/routes/system/smart-table.tsx +++ b/internal/site/src/components/routes/system/smart-table.tsx @@ -233,7 +233,7 @@ export const columns: ColumnDef[] = [ if (!cycles && cycles !== 0) { return

N/A
} - return {cycles} + return {cycles.toLocaleString()} }, }, { @@ -329,41 +329,41 @@ export default function DisksTable({ systemId }: { systemId?: string }) { ? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } : { fields: SMART_DEVICE_FIELDS } - ; (async () => { - try { - unsubscribe = await pb.collection("smart_devices").subscribe( - "*", - (event) => { - const record = event.record as SmartDeviceRecord - setSmartDevices((currentDevices) => { - const devices = currentDevices ?? [] - const matchesSystemScope = !systemId || record.system === systemId + ;(async () => { + try { + unsubscribe = await pb.collection("smart_devices").subscribe( + "*", + (event) => { + const record = event.record as SmartDeviceRecord + setSmartDevices((currentDevices) => { + const devices = currentDevices ?? [] + const matchesSystemScope = !systemId || record.system === systemId - if (event.action === "delete") { - return devices.filter((device) => device.id !== record.id) - } + if (event.action === "delete") { + return devices.filter((device) => device.id !== record.id) + } - if (!matchesSystemScope) { - // Record moved out of scope; ensure it disappears locally. - return devices.filter((device) => device.id !== record.id) - } + if (!matchesSystemScope) { + // Record moved out of scope; ensure it disappears locally. + return devices.filter((device) => device.id !== record.id) + } - const existingIndex = devices.findIndex((device) => device.id === record.id) - if (existingIndex === -1) { - return [record, ...devices] - } + const existingIndex = devices.findIndex((device) => device.id === record.id) + if (existingIndex === -1) { + return [record, ...devices] + } - const next = [...devices] - next[existingIndex] = record - return next - }) - }, - pbOptions - ) - } catch (error) { - console.error("Failed to subscribe to SMART device updates:", error) - } - })() + const next = [...devices] + next[existingIndex] = record + return next + }) + }, + pbOptions + ) + } catch (error) { + console.error("Failed to subscribe to SMART device updates:", error) + } + })() return () => { unsubscribe?.() @@ -421,14 +421,14 @@ export default function DisksTable({ systemId }: { systemId?: string }) { event.stopPropagation()}> 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 641ebd19..a714a824 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -1,4 +1,4 @@ -/** biome-ignore-all lint/correctness/useHookAtTopLevel: */ +/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */ import { t } from "@lingui/core/macro" import { Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" @@ -24,7 +24,7 @@ import { import { memo, useMemo, useRef, useState } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import { isReadOnlyUser, pb } from "@/lib/api" -import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums" +import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums" import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { cn, @@ -35,6 +35,7 @@ import { getMeterState, parseSemVer, } from "@/lib/utils" +import { batteryStateTranslations } from "@/lib/i18n" import type { SystemRecord } from "@/types" import { SystemDialog } from "../add-system" import AlertButton from "../alerts/alert-button" @@ -58,7 +59,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu" -import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons" +import { + BatteryMediumIcon, + EthernetIcon, + GpuIcon, + HourglassIcon, + ThermometerIcon, + WebSocketIcon, + BatteryHighIcon, + BatteryLowIcon, + PlugChargingIcon, + BatteryFullIcon, +} from "../ui/icons" const STATUS_COLORS = { [SystemStatus.Up]: "bg-green-500", @@ -261,6 +273,52 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef info.bat?.[0], + id: "battery", + name: () => t({ message: "Bat", comment: "Battery label in systems table header" }), + size: 70, + Icon: BatteryMediumIcon, + header: sortableHeader, + hideSort: true, + cell(info) { + const [pct, state] = info.row.original.info.bat ?? [] + if (pct === undefined) { + return null + } + + const iconColor = pct < 10 ? "text-red-500" : pct < 25 ? "text-yellow-500" : "text-muted-foreground" + + let Icon = PlugChargingIcon + + if (state !== BatteryState.Charging) { + if (pct < 25) { + Icon = BatteryLowIcon + } else if (pct < 75) { + Icon = BatteryMediumIcon + } else if (pct < 95) { + Icon = BatteryHighIcon + } else { + Icon = BatteryFullIcon + } + } + + const stateLabel = + state !== undefined ? (batteryStateTranslations[state as BatteryState]?.() ?? undefined) : undefined + + return ( + + + {pct}% + + ) + }, + }, { accessorFn: ({ info }) => info.sv?.[0], id: "services", @@ -599,5 +657,5 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => { ) - }, [id, status, host, name, t, deleteOpen, editOpen]) + }, [id, status, host, name, system, t, deleteOpen, editOpen]) }) diff --git a/internal/site/src/components/ui/icons.tsx b/internal/site/src/components/ui/icons.tsx index 33a949cb..f12dc775 100644 --- a/internal/site/src/components/ui/icons.tsx +++ b/internal/site/src/components/ui/icons.tsx @@ -131,6 +131,7 @@ export function HourglassIcon(props: SVGProps) { ) } +// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE export function WebSocketIcon(props: SVGProps) { return ( @@ -140,10 +141,47 @@ export function WebSocketIcon(props: SVGProps) { ) } -export function BatteryIcon(props: SVGProps) { +// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE +export function BatteryMediumIcon(props: SVGProps) { return ( - - + + ) -} \ No newline at end of file +} + +// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE +export function BatteryLowIcon(props: SVGProps) { + return ( + + + + ) +} + +// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE +export function BatteryHighIcon(props: SVGProps) { + return ( + + + + ) +} + +// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE +export function BatteryFullIcon(props: SVGProps) { + return ( + + + + ) +} + +// https://github.com/phosphor-icons/core (MIT license) +export function PlugChargingIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/internal/site/src/lib/alerts.ts b/internal/site/src/lib/alerts.ts index d5550e67..dc0c5180 100644 --- a/internal/site/src/lib/alerts.ts +++ b/internal/site/src/lib/alerts.ts @@ -5,7 +5,7 @@ import { EthernetIcon, GpuIcon } from "@/components/ui/icons" import { $alerts } from "@/lib/stores" import type { AlertInfo, AlertRecord } from "@/types" import { pb } from "./api" -import { ThermometerIcon, BatteryIcon, HourglassIcon } from "@/components/ui/icons" +import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/ui/icons" /** Alert info for each alert type */ export const alertInfo: Record = { @@ -87,9 +87,10 @@ export const alertInfo: Record = { Battery: { name: () => t`Battery`, unit: "%", - icon: BatteryIcon, + icon: BatteryMediumIcon, desc: () => t`Triggers when battery charge drops below a threshold`, start: 20, + invert: true, }, } as const diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index df6ac03e..682e6960 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -61,6 +61,8 @@ export interface SystemInfo { mp: number /** disk percent */ dp: number + /** battery percent and state */ + bat?: [number, BatteryState] /** bandwidth (mb) */ b: number /** bandwidth bytes */ @@ -331,6 +333,7 @@ export interface AlertInfo { start?: number /** Single value description (when there's only one value, like status) */ singleDesc?: () => string + invert?: boolean } export type AlertMap = Record>