improve container network stats granularity by using bytes instead of MB

Changes container network statistics to use raw byte values instead of converting to megabytes agent-side, providing more accurate measurements for low-bandwidth containers. Maintains backward compatibility with older agents/hubs through fallback logic.

- Agent now sends Bandwidth field as [sent_bytes, recv_bytes] array
- Deprecated NetworkSent/NetworkRecv fields still populated for compatibility
- Hub and frontend fall back to deprecated fields when Bandwidth is zero
- Record averaging correctly handles both old and new formats
- TODO markers added for cleanup in version 0.19+
This commit is contained in:
henrygd
2026-01-31 14:05:55 -05:00
parent 41f3705b6b
commit 79ca31d770
11 changed files with 182 additions and 121 deletions

View File

@@ -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) { func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = twoDecimals(cpuPct) stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory)) 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.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta)) stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime stats.PrevReadTime = readTime
@@ -403,6 +405,8 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
// reset current stats // reset current stats
stats.Cpu = 0 stats.Cpu = 0
stats.Mem = 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.NetworkSent = 0
stats.NetworkRecv = 0 stats.NetworkRecv = 0

View File

@@ -184,11 +184,12 @@ func TestUpdateContainerStatsValues(t *testing.T) {
// Check memory (should be converted to MB: 1048576 bytes = 1 MB) // Check memory (should be converted to MB: 1048576 bytes = 1 MB)
assert.Equal(t, 1.0, stats.Mem) assert.Equal(t, 1.0, stats.Mem)
// Check network sent (should be converted to MB: 524288 bytes = 0.5 MB) // Check bandwidth (raw bytes)
assert.Equal(t, 0.5, stats.NetworkSent) assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
// Check network recv (should be converted to MB: 262144 bytes = 0.25 MB) // Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, 0.25, stats.NetworkRecv) 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 // Check read time
assert.Equal(t, testTime, stats.PrevReadTime) 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, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem) assert.Equal(t, 2.0, stats.Mem)
assert.Equal(t, 1.0, stats.NetworkSent) assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
assert.Equal(t, 0.5, stats.NetworkRecv) // 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) assert.Equal(t, testTime, stats.PrevReadTime)
} }
@@ -689,6 +692,8 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
assert.Equal(t, cpuPct, testStats.Cpu) assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem) 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(1000000), testStats.NetworkSent)
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv) assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime) assert.Equal(t, testTime, testStats.PrevReadTime)

View File

@@ -129,11 +129,12 @@ var DockerHealthStrings = map[string]DockerHealth{
// Docker container stats // Docker container stats
type Stats struct { type Stats struct {
Name string `json:"n" cbor:"0,keyasint"` Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"` Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,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" cbor:"4,keyasint"` 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"` Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"` Status string `json:"-" cbor:"6,keyasint"`

View File

@@ -27,8 +27,8 @@ type Stats struct {
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"` DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"` NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"` NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`

View File

@@ -317,7 +317,11 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
params["health"+suffix] = container.Health params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu params["cpu"+suffix] = container.Cpu
params["memory"+suffix] = container.Mem 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( 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", "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",

View File

@@ -461,19 +461,24 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
} }
sums[stat.Name].Cpu += stat.Cpu sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent sentBytes := stat.Bandwidth[0]
sums[stat.Name].NetworkRecv += stat.NetworkRecv 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)) result := make([]container.Stats, 0, len(sums))
for _, value := range sums { for _, value := range sums {
result = append(result, container.Stats{ result = append(result, container.Stats{
Name: value.Name, Name: value.Name,
Cpu: twoDecimals(value.Cpu / count), Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count), Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count), Bandwidth: [2]uint64{uint64(float64(value.Bandwidth[0]) / count), uint64(float64(value.Bandwidth[1]) / count)},
NetworkRecv: twoDecimals(value.NetworkRecv / count),
}) })
} }
return result return result

View File

@@ -2,7 +2,14 @@
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" 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 { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores" import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
@@ -47,27 +54,49 @@ export default memo(function ContainerChart({
} else { } else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => { 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}`) return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`)
} }
} }
// tooltip formatter // tooltip formatter
if (isNetChart) { 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 (
<span className="flex">
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
}
obj.toolTipFormatter = (item: any, key: string) => { obj.toolTipFormatter = (item: any, key: string) => {
try { try {
const sent = item?.payload?.[key]?.ns ?? 0 if (key === "__total__") {
const received = item?.payload?.[key]?.nr ?? 0 let totalSent = 0
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true) let totalRecv = 0
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true) const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
return ( for (const value of Object.values(payloadData)) {
<span className="flex"> if (!value || typeof value !== "object") {
{decimalString(receivedValue)} {receivedUnit} continue
<span className="opacity-70 ms-0.5"> rx </span> }
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" /> const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
{decimalString(sentValue)} {sentUnit} totalSent += sent
<span className="opacity-70 ms-0.5"> tx</span> totalRecv += recv
</span> }
) return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
} catch (e) { } catch (e) {
return null return null
} }
@@ -82,7 +111,15 @@ export default memo(function ContainerChart({
} }
// data function // data function
if (isNetChart) { 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 { } else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
} }
@@ -94,11 +131,16 @@ export default memo(function ContainerChart({
if (!filter) { if (!filter) {
return new Set<string>() return new Set<string>()
} }
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0) const filterTerms = filter
return new Set(Object.keys(chartConfig).filter((key) => { .toLowerCase()
const keyLower = key.toLowerCase() .split(" ")
return !filterTerms.some(term => keyLower.includes(term)) .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]) }, [chartConfig, filter])
// console.log('rendered at', new Date()) // console.log('rendered at', new Date())

View File

@@ -50,10 +50,12 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
const currentCpu = totalUsage.cpu.get(containerName) ?? 0 const currentCpu = totalUsage.cpu.get(containerName) ?? 0
const currentMemory = totalUsage.memory.get(containerName) ?? 0 const currentMemory = totalUsage.memory.get(containerName) ?? 0
const currentNetwork = totalUsage.network.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.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 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)
} }
} }

View File

@@ -20,7 +20,14 @@ import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
// Unit names and their corresponding number of seconds for converting docker status strings // 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.) // Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
function getStatusValue(status: string): number { function getStatusValue(status: string): number {
const [_, num, unit] = status.split(" ") const [_, num, unit] = status.split(" ")
@@ -97,7 +104,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const val = getValue() as number const val = getValue() as number
const formatted = formatBytes(val, true, undefined, true) const formatted = formatBytes(val, true, undefined, false)
return ( return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span> <span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
) )
@@ -113,13 +120,14 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown" const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
return ( return (
<Badge variant="outline" className="dark:border-white/12"> <Badge variant="outline" className="dark:border-white/12">
<span className={cn("size-2 me-1.5 rounded-full", { <span
"bg-green-500": healthValue === ContainerHealth.Healthy, className={cn("size-2 me-1.5 rounded-full", {
"bg-red-500": healthValue === ContainerHealth.Unhealthy, "bg-green-500": healthValue === ContainerHealth.Healthy,
"bg-yellow-500": healthValue === ContainerHealth.Starting, "bg-red-500": healthValue === ContainerHealth.Unhealthy,
"bg-zinc-500": healthValue === ContainerHealth.None, "bg-yellow-500": healthValue === ContainerHealth.Starting,
})}> "bg-zinc-500": healthValue === ContainerHealth.None,
</span> })}
></span>
{healthStatus} {healthStatus}
</Badge> </Badge>
) )
@@ -129,7 +137,9 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
id: "image", id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image), sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image, accessorFn: (record) => record.image,
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />, header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
),
cell: ({ getValue }) => { cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span> return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
}, },
@@ -151,20 +161,27 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const timestamp = getValue() as number const timestamp = getValue() as number
return ( return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
)
}, },
}, },
] ]
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) { function HeaderButton({
column,
name,
Icon,
}: {
column: Column<ContainerRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
return ( return (
<Button <Button
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")} className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
@@ -173,4 +190,4 @@ function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>;
<ArrowUpDownIcon className="size-4" /> <ArrowUpDownIcon className="size-4" />
</Button> </Button>
) )
} }

View File

@@ -94,18 +94,18 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
unit?: string unit?: string
filter?: string filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean truncate?: boolean
showTotal?: boolean showTotal?: boolean
totalLabel?: React.ReactNode totalLabel?: React.ReactNode
} }
>( >(
( (
{ {
@@ -139,10 +139,13 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => { React.useMemo(() => {
if (filter) { 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) => { payload = payload?.filter((item) => {
const itemName = (item.name as string)?.toLowerCase() const itemName = (item.name as string)?.toLowerCase()
return filterTerms.some(term => itemName?.includes(term)) return filterTerms.some((term) => itemName?.includes(term))
}) })
} }
if (itemSorter) { if (itemSorter) {
@@ -158,7 +161,6 @@ const ChartTooltipContent = React.forwardRef<
let totalValue = 0 let totalValue = 0
let hasNumericValue = false let hasNumericValue = false
const aggregatedNestedValues: Record<string, number> = {}
for (const item of payload) { for (const item of payload) {
const numericValue = typeof item.value === "number" ? item.value : Number(item.value) const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
@@ -166,19 +168,6 @@ const ChartTooltipContent = React.forwardRef<
totalValue += numericValue totalValue += numericValue
hasNumericValue = true hasNumericValue = true
} }
if (content && item?.payload) {
const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
const nestedPayload = (item.payload as Record<string, unknown> | 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) { if (!hasNumericValue) {
@@ -194,24 +183,11 @@ const ChartTooltipContent = React.forwardRef<
} }
if (content) { if (content) {
const basePayload = totalItem.payload = payload[0]?.payload
payload[0]?.payload && typeof payload[0].payload === "object"
? { ...(payload[0].payload as Record<string, unknown>) }
: {}
totalItem.payload = {
...basePayload,
[totalKey]: aggregatedNestedValues,
}
} }
if (typeof formatter === "function") { if (typeof formatter === "function") {
return formatter( return formatter(totalValue, totalName, totalItem, payload.length, totalItem.payload ?? payload[0]?.payload)
totalValue,
totalName,
totalItem,
payload.length,
totalItem.payload ?? payload[0]?.payload
)
} }
if (content) { if (content) {
@@ -343,11 +319,11 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
reverse?: boolean reverse?: boolean
} }
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => { >(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
// const { config } = useChart() // const { config } = useChart()
@@ -457,13 +433,16 @@ export {
} }
export function pinnedAxisDomain(): AxisDomain { export function pinnedAxisDomain(): AxisDomain {
return [0, (dataMax: number) => { return [
if (dataMax > 10) { 0,
return Math.round(dataMax) (dataMax: number) => {
} if (dataMax > 10) {
if (dataMax > 1) { return Math.round(dataMax)
return Math.round(dataMax / 0.1) * 0.1 }
} if (dataMax > 1) {
return dataMax return Math.round(dataMax / 0.1) * 0.1
}] }
} return dataMax
},
]
}

View File

@@ -215,9 +215,11 @@ interface ContainerStats {
/** memory used (gb) */ /** memory used (gb) */
m: number m: number
// network sent (mb) // network sent (mb)
ns: number ns?: number
// network received (mb) // network received (mb)
nr: number nr?: number
/** bandwidth bytes [sent, recv] */
b?: [number, number]
} }
export interface SystemStatsRecord extends RecordModel { export interface SystemStatsRecord extends RecordModel {