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 {