Skip to content

Commit 8d41a79

Browse files
committed
add battery charge to systems table
1 parent 570e1cb commit 8d41a79

File tree

9 files changed

+155
-48
lines changed

9 files changed

+155
-48
lines changed

agent/system.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
205205
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
206206
a.systemInfo.MemPct = systemStats.MemPct
207207
a.systemInfo.DiskPct = systemStats.DiskPct
208+
a.systemInfo.Battery = systemStats.Battery
208209
a.systemInfo.Uptime, _ = host.Uptime()
209210
// TODO: in future release, remove MB bandwidth values in favor of bytes
210211
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)

internal/entities/system/system.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ type Info struct {
148148
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
149149
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
150150
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
151+
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
151152
}
152153

153154
// Final data structure to return to the hub

internal/site/src/components/active-alerts.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export const ActiveAlerts = () => {
6161
<AlertDescription>
6262
{alert.name === "Status" ? (
6363
<Trans>Connection is down</Trans>
64+
) : info.invert ? (
65+
<Trans>
66+
Below {alert.value}
67+
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
68+
</Trans>
6469
) : (
6570
<Trans>
6671
Exceeds {alert.value}

internal/site/src/components/alerts/alerts-sheet.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export function AlertContent({
245245
{!singleDescription && (
246246
<div>
247247
<p id={`v${name}`} className="text-sm block h-8">
248-
{alertKey === "Battery" ? (
248+
{alertData.invert ? (
249249
<Trans>
250250
Average drops below{" "}
251251
<strong className="text-foreground">

internal/site/src/components/routes/system/smart-table.tsx

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
233233
if (!cycles && cycles !== 0) {
234234
return <div className="text-muted-foreground ms-1.5">N/A</div>
235235
}
236-
return <span className="ms-1.5">{cycles}</span>
236+
return <span className="ms-1.5">{cycles.toLocaleString()}</span>
237237
},
238238
},
239239
{
@@ -329,41 +329,41 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
329329
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
330330
: { fields: SMART_DEVICE_FIELDS }
331331

332-
; (async () => {
333-
try {
334-
unsubscribe = await pb.collection("smart_devices").subscribe(
335-
"*",
336-
(event) => {
337-
const record = event.record as SmartDeviceRecord
338-
setSmartDevices((currentDevices) => {
339-
const devices = currentDevices ?? []
340-
const matchesSystemScope = !systemId || record.system === systemId
341-
342-
if (event.action === "delete") {
343-
return devices.filter((device) => device.id !== record.id)
344-
}
345-
346-
if (!matchesSystemScope) {
347-
// Record moved out of scope; ensure it disappears locally.
348-
return devices.filter((device) => device.id !== record.id)
349-
}
350-
351-
const existingIndex = devices.findIndex((device) => device.id === record.id)
352-
if (existingIndex === -1) {
353-
return [record, ...devices]
354-
}
355-
356-
const next = [...devices]
357-
next[existingIndex] = record
358-
return next
359-
})
360-
},
361-
pbOptions
362-
)
363-
} catch (error) {
364-
console.error("Failed to subscribe to SMART device updates:", error)
365-
}
366-
})()
332+
;(async () => {
333+
try {
334+
unsubscribe = await pb.collection("smart_devices").subscribe(
335+
"*",
336+
(event) => {
337+
const record = event.record as SmartDeviceRecord
338+
setSmartDevices((currentDevices) => {
339+
const devices = currentDevices ?? []
340+
const matchesSystemScope = !systemId || record.system === systemId
341+
342+
if (event.action === "delete") {
343+
return devices.filter((device) => device.id !== record.id)
344+
}
345+
346+
if (!matchesSystemScope) {
347+
// Record moved out of scope; ensure it disappears locally.
348+
return devices.filter((device) => device.id !== record.id)
349+
}
350+
351+
const existingIndex = devices.findIndex((device) => device.id === record.id)
352+
if (existingIndex === -1) {
353+
return [record, ...devices]
354+
}
355+
356+
const next = [...devices]
357+
next[existingIndex] = record
358+
return next
359+
})
360+
},
361+
pbOptions
362+
)
363+
} catch (error) {
364+
console.error("Failed to subscribe to SMART device updates:", error)
365+
}
366+
})()
367367

368368
return () => {
369369
unsubscribe?.()
@@ -421,14 +421,14 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
421421
<Button
422422
variant="ghost"
423423
size="icon"
424-
className="size-8"
424+
className="size-10"
425425
onClick={(event) => event.stopPropagation()}
426426
onMouseDown={(event) => event.stopPropagation()}
427427
>
428428
<span className="sr-only">
429429
<Trans>Open menu</Trans>
430430
</span>
431-
<MoreHorizontalIcon className="size-4" />
431+
<MoreHorizontalIcon className="w-5" />
432432
</Button>
433433
</DropdownMenuTrigger>
434434
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>

internal/site/src/components/systems-table/systems-table-columns.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */
1+
/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
22
import { t } from "@lingui/core/macro"
33
import { Trans, useLingui } from "@lingui/react/macro"
44
import { useStore } from "@nanostores/react"
@@ -24,7 +24,7 @@ import {
2424
import { memo, useMemo, useRef, useState } from "react"
2525
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
2626
import { isReadOnlyUser, pb } from "@/lib/api"
27-
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
27+
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
2828
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
2929
import {
3030
cn,
@@ -35,6 +35,7 @@ import {
3535
getMeterState,
3636
parseSemVer,
3737
} from "@/lib/utils"
38+
import { batteryStateTranslations } from "@/lib/i18n"
3839
import type { SystemRecord } from "@/types"
3940
import { SystemDialog } from "../add-system"
4041
import AlertButton from "../alerts/alert-button"
@@ -58,7 +59,18 @@ import {
5859
DropdownMenuSeparator,
5960
DropdownMenuTrigger,
6061
} from "../ui/dropdown-menu"
61-
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons"
62+
import {
63+
BatteryMediumIcon,
64+
EthernetIcon,
65+
GpuIcon,
66+
HourglassIcon,
67+
ThermometerIcon,
68+
WebSocketIcon,
69+
BatteryHighIcon,
70+
BatteryLowIcon,
71+
PlugChargingIcon,
72+
BatteryFullIcon,
73+
} from "../ui/icons"
6274

6375
const STATUS_COLORS = {
6476
[SystemStatus.Up]: "bg-green-500",
@@ -261,6 +273,52 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
261273
)
262274
},
263275
},
276+
{
277+
accessorFn: ({ info }) => info.bat?.[0],
278+
id: "battery",
279+
name: () => t({ message: "Bat", comment: "Battery label in systems table header" }),
280+
size: 70,
281+
Icon: BatteryMediumIcon,
282+
header: sortableHeader,
283+
hideSort: true,
284+
cell(info) {
285+
const [pct, state] = info.row.original.info.bat ?? []
286+
if (pct === undefined) {
287+
return null
288+
}
289+
290+
const iconColor = pct < 10 ? "text-red-500" : pct < 25 ? "text-yellow-500" : "text-muted-foreground"
291+
292+
let Icon = PlugChargingIcon
293+
294+
if (state !== BatteryState.Charging) {
295+
if (pct < 25) {
296+
Icon = BatteryLowIcon
297+
} else if (pct < 75) {
298+
Icon = BatteryMediumIcon
299+
} else if (pct < 95) {
300+
Icon = BatteryHighIcon
301+
} else {
302+
Icon = BatteryFullIcon
303+
}
304+
}
305+
306+
const stateLabel =
307+
state !== undefined ? (batteryStateTranslations[state as BatteryState]?.() ?? undefined) : undefined
308+
309+
return (
310+
<Link
311+
tabIndex={-1}
312+
href={getPagePath($router, "system", { id: info.row.original.id })}
313+
className="flex items-center gap-1 tabular-nums tracking-tight relative z-10"
314+
title={stateLabel}
315+
>
316+
<Icon className={cn("size-3.5", iconColor)} />
317+
<span className="min-w-10">{pct}%</span>
318+
</Link>
319+
)
320+
},
321+
},
264322
{
265323
accessorFn: ({ info }) => info.sv?.[0],
266324
id: "services",
@@ -599,5 +657,5 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
599657
</AlertDialog>
600658
</>
601659
)
602-
}, [id, status, host, name, t, deleteOpen, editOpen])
660+
}, [id, status, host, name, system, t, deleteOpen, editOpen])
603661
})

internal/site/src/components/ui/icons.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
131131
)
132132
}
133133

134+
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
134135
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
135136
return (
136137
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
@@ -140,10 +141,47 @@ export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
140141
)
141142
}
142143

143-
export function BatteryIcon(props: SVGProps<SVGSVGElement>) {
144+
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
145+
export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
146+
return (
147+
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
148+
<path d="M16 13H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
149+
</svg>
150+
)
151+
}
152+
153+
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
154+
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
155+
return (
156+
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
157+
<path d="M16 20H8V6h8m.67-2H15V2H9v2H7.33C6.6 4 6 4.6 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34c.74 0 1.33-.59 1.33-1.33V5.33C18 4.6 17.4 4 16.67 4M15 16H9v3h6zm0-4.5H9v3h6z" />
158+
</svg>
159+
)
160+
}
161+
162+
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
163+
export function BatteryHighIcon(props: SVGProps<SVGSVGElement>) {
164+
return (
165+
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
166+
<path d="M16 9H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
167+
</svg>
168+
)
169+
}
170+
171+
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
172+
export function BatteryFullIcon(props: SVGProps<SVGSVGElement>) {
173+
return (
174+
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
175+
<path d="M16.67 4H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
176+
</svg>
177+
)
178+
}
179+
180+
// https://github.com/phosphor-icons/core (MIT license)
181+
export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
144182
return (
145183
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
146-
<path d="M176,32H80A24,24,0,0,0,56,56V224a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V56A24,24,0,0,0,176,32Zm8,192a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h96a8,8,0,0,1,8,8Zm-16-24a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,200ZM88,8a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,8Zm80,152a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Z"></path>
184+
<path d="M224,48H180V16a12,12,0,0,0-24,0V48H100V16a12,12,0,0,0-24,0V48H32.55C24.4,48,20,54.18,20,60A12,12,0,0,0,32,72H44v92a44.05,44.05,0,0,0,44,44h28v32a12,12,0,0,0,24,0V208h28a44.05,44.05,0,0,0,44-44V72h12a12,12,0,0,0,0-24ZM188,164a20,20,0,0,1-20,20H88a20,20,0,0,1-20-20V72H188Zm-85.86-29.17a12,12,0,0,1-1.38-11l12-32a12,12,0,1,1,22.48,8.42L129.32,116H144a12,12,0,0,1,11.24,16.21l-12,32a12,12,0,0,1-22.48-8.42L126.68,140H112A12,12,0,0,1,102.14,134.83Z" />
147185
</svg>
148186
)
149-
}
187+
}

internal/site/src/lib/alerts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
55
import { $alerts } from "@/lib/stores"
66
import type { AlertInfo, AlertRecord } from "@/types"
77
import { pb } from "./api"
8-
import { ThermometerIcon, BatteryIcon, HourglassIcon } from "@/components/ui/icons"
8+
import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/ui/icons"
99

1010
/** Alert info for each alert type */
1111
export const alertInfo: Record<string, AlertInfo> = {
@@ -87,9 +87,10 @@ export const alertInfo: Record<string, AlertInfo> = {
8787
Battery: {
8888
name: () => t`Battery`,
8989
unit: "%",
90-
icon: BatteryIcon,
90+
icon: BatteryMediumIcon,
9191
desc: () => t`Triggers when battery charge drops below a threshold`,
9292
start: 20,
93+
invert: true,
9394
},
9495
} as const
9596

internal/site/src/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface SystemInfo {
6161
mp: number
6262
/** disk percent */
6363
dp: number
64+
/** battery percent and state */
65+
bat?: [number, BatteryState]
6466
/** bandwidth (mb) */
6567
b: number
6668
/** bandwidth bytes */
@@ -331,6 +333,7 @@ export interface AlertInfo {
331333
start?: number
332334
/** Single value description (when there's only one value, like status) */
333335
singleDesc?: () => string
336+
invert?: boolean
334337
}
335338

336339
export type AlertMap = Record<string, Map<string, AlertRecord>>

0 commit comments

Comments
 (0)