diff --git a/agent/docker.go b/agent/docker.go
index 44558b24..f89e7497 100644
--- a/agent/docker.go
+++ b/agent/docker.go
@@ -335,6 +335,8 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
+ stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
+ // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime
@@ -403,6 +405,8 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
// reset current stats
stats.Cpu = 0
stats.Mem = 0
+ stats.Bandwidth = [2]uint64{0, 0}
+ // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = 0
stats.NetworkRecv = 0
diff --git a/agent/docker_test.go b/agent/docker_test.go
index 972ae56f..11a1945a 100644
--- a/agent/docker_test.go
+++ b/agent/docker_test.go
@@ -184,11 +184,12 @@ func TestUpdateContainerStatsValues(t *testing.T) {
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
assert.Equal(t, 1.0, stats.Mem)
- // Check network sent (should be converted to MB: 524288 bytes = 0.5 MB)
- assert.Equal(t, 0.5, stats.NetworkSent)
+ // Check bandwidth (raw bytes)
+ assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
- // Check network recv (should be converted to MB: 262144 bytes = 0.25 MB)
- assert.Equal(t, 0.25, stats.NetworkRecv)
+ // Deprecated fields still populated for backward compatibility with older hubs
+ assert.Equal(t, 0.5, stats.NetworkSent) // 524288 bytes = 0.5 MB
+ assert.Equal(t, 0.25, stats.NetworkRecv) // 262144 bytes = 0.25 MB
// Check read time
assert.Equal(t, testTime, stats.PrevReadTime)
@@ -527,8 +528,10 @@ func TestContainerStatsInitialization(t *testing.T) {
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
- assert.Equal(t, 1.0, stats.NetworkSent)
- assert.Equal(t, 0.5, stats.NetworkRecv)
+ assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
+ // Deprecated fields still populated for backward compatibility with older hubs
+ assert.Equal(t, 1.0, stats.NetworkSent) // 1048576 bytes = 1 MB
+ assert.Equal(t, 0.5, stats.NetworkRecv) // 524288 bytes = 0.5 MB
assert.Equal(t, testTime, stats.PrevReadTime)
}
@@ -689,6 +692,8 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
+ assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
+ // Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime)
diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go
index 051f0dff..a0d96c71 100644
--- a/internal/entities/container/container.go
+++ b/internal/entities/container/container.go
@@ -129,11 +129,12 @@ var DockerHealthStrings = map[string]DockerHealth{
// Docker container stats
type Stats struct {
- Name string `json:"n" cbor:"0,keyasint"`
- Cpu float64 `json:"c" cbor:"1,keyasint"`
- Mem float64 `json:"m" cbor:"2,keyasint"`
- NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
- NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
+ Name string `json:"n" cbor:"0,keyasint"`
+ Cpu float64 `json:"c" cbor:"1,keyasint"`
+ Mem float64 `json:"m" cbor:"2,keyasint"`
+ NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
+ NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
+ Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go
index 3ccd9cb4..2a5f50f6 100644
--- a/internal/entities/system/system.go
+++ b/internal/entities/system/system.go
@@ -27,8 +27,8 @@ type Stats struct {
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
- NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
- NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
+ NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
+ NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go
index 0c02e61d..109db35a 100644
--- a/internal/hub/systems/system.go
+++ b/internal/hub/systems/system.go
@@ -317,7 +317,11 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu
params["memory"+suffix] = container.Mem
- params["net"+suffix] = container.NetworkSent + container.NetworkRecv
+ netBytes := container.Bandwidth[0] + container.Bandwidth[1]
+ if netBytes == 0 {
+ netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
+ }
+ params["net"+suffix] = netBytes
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
diff --git a/internal/records/records.go b/internal/records/records.go
index eaf4ef7f..8d4b6580 100644
--- a/internal/records/records.go
+++ b/internal/records/records.go
@@ -461,19 +461,24 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
- sums[stat.Name].NetworkSent += stat.NetworkSent
- sums[stat.Name].NetworkRecv += stat.NetworkRecv
+ sentBytes := stat.Bandwidth[0]
+ recvBytes := stat.Bandwidth[1]
+ if sentBytes == 0 && recvBytes == 0 && (stat.NetworkSent != 0 || stat.NetworkRecv != 0) {
+ sentBytes = uint64(stat.NetworkSent * 1024 * 1024)
+ recvBytes = uint64(stat.NetworkRecv * 1024 * 1024)
+ }
+ sums[stat.Name].Bandwidth[0] += sentBytes
+ sums[stat.Name].Bandwidth[1] += recvBytes
}
}
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
- Name: value.Name,
- Cpu: twoDecimals(value.Cpu / count),
- Mem: twoDecimals(value.Mem / count),
- NetworkSent: twoDecimals(value.NetworkSent / count),
- NetworkRecv: twoDecimals(value.NetworkRecv / count),
+ Name: value.Name,
+ Cpu: twoDecimals(value.Cpu / count),
+ Mem: twoDecimals(value.Mem / count),
+ Bandwidth: [2]uint64{uint64(float64(value.Bandwidth[0]) / count), uint64(float64(value.Bandwidth[1]) / count)},
})
}
return result
diff --git a/internal/site/src/components/charts/container-chart.tsx b/internal/site/src/components/charts/container-chart.tsx
index f3b99271..36b5571a 100644
--- a/internal/site/src/components/charts/container-chart.tsx
+++ b/internal/site/src/components/charts/container-chart.tsx
@@ -2,7 +2,14 @@
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
-import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ pinnedAxisDomain,
+ xAxis,
+} from "@/components/ui/chart"
import { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
@@ -47,27 +54,49 @@ export default memo(function ContainerChart({
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
- const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
+ const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart)
return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`)
}
}
// tooltip formatter
if (isNetChart) {
+ const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
+ if (record?.b?.length && record.b.length >= 2) {
+ return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
+ }
+ return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
+ }
+ const formatRxTx = (recv: number, sent: number) => {
+ const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
+ const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
+ return (
+
+ {decimalString(receivedValue)} {receivedUnit}
+ rx
+
+ {decimalString(sentValue)} {sentUnit}
+ tx
+
+ )
+ }
obj.toolTipFormatter = (item: any, key: string) => {
try {
- const sent = item?.payload?.[key]?.ns ?? 0
- const received = item?.payload?.[key]?.nr ?? 0
- const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
- const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
- return (
-
- {decimalString(receivedValue)} {receivedUnit}
- rx
-
- {decimalString(sentValue)} {sentUnit}
- tx
-
- )
+ if (key === "__total__") {
+ let totalSent = 0
+ let totalRecv = 0
+ const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
+ for (const value of Object.values(payloadData)) {
+ if (!value || typeof value !== "object") {
+ continue
+ }
+ const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
+ totalSent += sent
+ totalRecv += recv
+ }
+ return formatRxTx(totalRecv, totalSent)
+ }
+ const [sent, recv] = getRxTxBytes(item?.payload?.[key])
+ return formatRxTx(recv, sent)
} catch (e) {
return null
}
@@ -82,7 +111,15 @@ export default memo(function ContainerChart({
}
// data function
if (isNetChart) {
- obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
+ obj.dataFunction = (key: string, data: any) => {
+ const payload = data[key]
+ if (!payload) {
+ return null
+ }
+ const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
+ const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
+ return sent + recv
+ }
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
@@ -94,11 +131,16 @@ export default memo(function ContainerChart({
if (!filter) {
return new Set()
}
- const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
- return new Set(Object.keys(chartConfig).filter((key) => {
- const keyLower = key.toLowerCase()
- return !filterTerms.some(term => keyLower.includes(term))
- }))
+ const filterTerms = filter
+ .toLowerCase()
+ .split(" ")
+ .filter((term) => term.length > 0)
+ return new Set(
+ Object.keys(chartConfig).filter((key) => {
+ const keyLower = key.toLowerCase()
+ return !filterTerms.some((term) => keyLower.includes(term))
+ })
+ )
}, [chartConfig, filter])
// console.log('rendered at', new Date())
diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts
index c619ffb8..33c41d5b 100644
--- a/internal/site/src/components/charts/hooks.ts
+++ b/internal/site/src/components/charts/hooks.ts
@@ -50,10 +50,12 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
const currentCpu = totalUsage.cpu.get(containerName) ?? 0
const currentMemory = totalUsage.memory.get(containerName) ?? 0
const currentNetwork = totalUsage.network.get(containerName) ?? 0
+ const sentBytes = containerStats.b?.[0] ?? (containerStats.ns ?? 0) * 1024 * 1024
+ const recvBytes = containerStats.b?.[1] ?? (containerStats.nr ?? 0) * 1024 * 1024
totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0))
- totalUsage.network.set(containerName, currentNetwork + (containerStats.nr ?? 0) + (containerStats.ns ?? 0))
+ totalUsage.network.set(containerName, currentNetwork + sentBytes + recvBytes)
}
}
diff --git a/internal/site/src/components/containers-table/containers-table-columns.tsx b/internal/site/src/components/containers-table/containers-table-columns.tsx
index 0f5ad3c3..80d70ccb 100644
--- a/internal/site/src/components/containers-table/containers-table-columns.tsx
+++ b/internal/site/src/components/containers-table/containers-table-columns.tsx
@@ -20,7 +20,14 @@ import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
// Unit names and their corresponding number of seconds for converting docker status strings
-const unitSeconds = [["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000]] as const
+const unitSeconds = [
+ ["s", 1],
+ ["mi", 60],
+ ["h", 3600],
+ ["d", 86400],
+ ["w", 604800],
+ ["mo", 2592000],
+] as const
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
function getStatusValue(status: string): number {
const [_, num, unit] = status.split(" ")
@@ -97,7 +104,7 @@ export const containerChartCols: ColumnDef[] = [
header: ({ column }) => ,
cell: ({ getValue }) => {
const val = getValue() as number
- const formatted = formatBytes(val, true, undefined, true)
+ const formatted = formatBytes(val, true, undefined, false)
return (
{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}
)
@@ -113,13 +120,14 @@ export const containerChartCols: ColumnDef[] = [
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
return (
-
-
+
{healthStatus}
)
@@ -129,7 +137,9 @@ export const containerChartCols: ColumnDef[] = [
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image,
- header: ({ column }) => ,
+ header: ({ column }) => (
+
+ ),
cell: ({ getValue }) => {
return {getValue() as string}
},
@@ -151,20 +161,27 @@ export const containerChartCols: ColumnDef[] = [
header: ({ column }) => ,
cell: ({ getValue }) => {
const timestamp = getValue() as number
- return (
-
- {hourWithSeconds(new Date(timestamp).toISOString())}
-
- )
+ return {hourWithSeconds(new Date(timestamp).toISOString())}
},
},
]
-function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) {
+function HeaderButton({
+ column,
+ name,
+ Icon,
+}: {
+ column: Column
+ name: string
+ Icon: React.ElementType
+}) {
const isSorted = column.getIsSorted()
return (
)
-}
\ No newline at end of file
+}
diff --git a/internal/site/src/components/ui/chart.tsx b/internal/site/src/components/ui/chart.tsx
index f0ac885a..5fc4e2fe 100644
--- a/internal/site/src/components/ui/chart.tsx
+++ b/internal/site/src/components/ui/chart.tsx
@@ -94,18 +94,18 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps &
- React.ComponentProps<"div"> & {
- hideLabel?: boolean
- indicator?: "line" | "dot" | "dashed"
- nameKey?: string
- labelKey?: string
- unit?: string
- filter?: string
- contentFormatter?: (item: any, key: string) => React.ReactNode | string
- truncate?: boolean
- showTotal?: boolean
- totalLabel?: React.ReactNode
- }
+ React.ComponentProps<"div"> & {
+ hideLabel?: boolean
+ indicator?: "line" | "dot" | "dashed"
+ nameKey?: string
+ labelKey?: string
+ unit?: string
+ filter?: string
+ contentFormatter?: (item: any, key: string) => React.ReactNode | string
+ truncate?: boolean
+ showTotal?: boolean
+ totalLabel?: React.ReactNode
+ }
>(
(
{
@@ -139,10 +139,13 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => {
if (filter) {
- const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
+ const filterTerms = filter
+ .toLowerCase()
+ .split(" ")
+ .filter((term) => term.length > 0)
payload = payload?.filter((item) => {
const itemName = (item.name as string)?.toLowerCase()
- return filterTerms.some(term => itemName?.includes(term))
+ return filterTerms.some((term) => itemName?.includes(term))
})
}
if (itemSorter) {
@@ -158,7 +161,6 @@ const ChartTooltipContent = React.forwardRef<
let totalValue = 0
let hasNumericValue = false
- const aggregatedNestedValues: Record = {}
for (const item of payload) {
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
@@ -166,19 +168,6 @@ const ChartTooltipContent = React.forwardRef<
totalValue += numericValue
hasNumericValue = true
}
-
- if (content && item?.payload) {
- const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
- const nestedPayload = (item.payload as Record | undefined)?.[payloadKey]
-
- if (nestedPayload && typeof nestedPayload === "object") {
- for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) {
- if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
- aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue
- }
- }
- }
- }
}
if (!hasNumericValue) {
@@ -194,24 +183,11 @@ const ChartTooltipContent = React.forwardRef<
}
if (content) {
- const basePayload =
- payload[0]?.payload && typeof payload[0].payload === "object"
- ? { ...(payload[0].payload as Record) }
- : {}
- totalItem.payload = {
- ...basePayload,
- [totalKey]: aggregatedNestedValues,
- }
+ totalItem.payload = payload[0]?.payload
}
if (typeof formatter === "function") {
- return formatter(
- totalValue,
- totalName,
- totalItem,
- payload.length,
- totalItem.payload ?? payload[0]?.payload
- )
+ return formatter(totalValue, totalName, totalItem, payload.length, totalItem.payload ?? payload[0]?.payload)
}
if (content) {
@@ -343,11 +319,11 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
- Pick & {
- hideIcon?: boolean
- nameKey?: string
- reverse?: boolean
- }
+ Pick & {
+ hideIcon?: boolean
+ nameKey?: string
+ reverse?: boolean
+ }
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
// const { config } = useChart()
@@ -457,13 +433,16 @@ export {
}
export function pinnedAxisDomain(): AxisDomain {
- return [0, (dataMax: number) => {
- if (dataMax > 10) {
- return Math.round(dataMax)
- }
- if (dataMax > 1) {
- return Math.round(dataMax / 0.1) * 0.1
- }
- return dataMax
- }]
-}
\ No newline at end of file
+ return [
+ 0,
+ (dataMax: number) => {
+ if (dataMax > 10) {
+ return Math.round(dataMax)
+ }
+ if (dataMax > 1) {
+ return Math.round(dataMax / 0.1) * 0.1
+ }
+ return dataMax
+ },
+ ]
+}
diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts
index 20363a29..53dce80c 100644
--- a/internal/site/src/types.d.ts
+++ b/internal/site/src/types.d.ts
@@ -215,9 +215,11 @@ interface ContainerStats {
/** memory used (gb) */
m: number
// network sent (mb)
- ns: number
+ ns?: number
// network received (mb)
- nr: number
+ nr?: number
+ /** bandwidth bytes [sent, recv] */
+ b?: [number, number]
}
export interface SystemStatsRecord extends RecordModel {