hub(ui): tabs display for system + major frontend/charts refactoring

- System page tabs display option
- Remove very specific chart components (disk usage, container cpu, etc)
and refactor to use more flexible area and line chart components
- Optimizations around chart handling to decrease mem usage. Charts are
only redrawn now if in view.
- Resolve most of the react dev warnings

Co-authored-by: sveng93 <svenvanginkel@icloud.com>
This commit is contained in:
henrygd
2026-03-26 15:21:39 -04:00
parent 35528332fd
commit 62587919f4
34 changed files with 2368 additions and 2019 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import {
ChartContainer,
@@ -11,18 +11,23 @@ import {
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import { AxisDomain } from "recharts/types/util/types"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
export type DataPoint = {
export type DataPoint<T = SystemStatsRecord> = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
dataKey: (data: T) => number | null | undefined
color: number | string
opacity: number
stackId?: string | number
order?: number
strokeOpacity?: number
activeDot?: boolean
}
export default function AreaChartDefault({
chartData,
customData,
max,
maxToggled,
tickFormatter,
@@ -34,96 +39,129 @@ export default function AreaChartDefault({
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
filter,
truncate = false,
}: // logRender = false,
{
chartData: ChartData
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
// logRender?: boolean
}) {
{
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
filter?: string
truncate?: boolean
// logRender?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
// Only update the rendered data while the chart is visible
const [displayData, setDisplayData] = useState(sourceData)
// Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => {
const shouldPrimeData = sourceData.length && !displayData.length
const sourceChanged = sourceData !== displayData
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
if (shouldUpdate) {
setDisplayData(sourceData)
}
}, [displayData, isIntersecting, sourceData])
// Use a stable key derived from data point identities and visual properties
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
const Areas = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Area
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
isAnimationActive={false}
stackId={dataPoint.stackId}
order={dataPoint.order || i}
activeDot={dataPoint.activeDot ?? true}
/>
)
})
}, [areasKey, maxToggled])
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
return useMemo(() => {
if (chartData.systemStats.length === 0) {
if (displayData.length === 0) {
return null
}
// if (logRender) {
// console.log("Rendered at", new Date())
console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
<ChartContainer
ref={ref}
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={displayData}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={chartData.systemStats}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
/>
}
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
{dataPoints?.map((dataPoint) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Area
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
stackId={dataPoint.stackId}
/>
)
})}
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
</AreaChart>
</ChartContainer>
</div>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
filter={filter}
truncate={truncate}
/>
}
/>
{Areas}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</AreaChart>
</ChartContainer>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
}, [displayData, yAxisWidth, showTotal, filter])
}

View File

@@ -1,215 +0,0 @@
// import Spinner from '../spinner'
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 { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { useYAxisWidth } from "./hooks"
export default memo(function ContainerChart({
dataKey,
chartData,
chartType,
chartConfig,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartType: ChartType
chartConfig: ChartConfig
unit?: string
}) {
const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartType === ChartType.Network
// Filter with set lookup
const filteredKeys = useMemo(() => {
if (!filter) {
return new Set<string>()
}
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])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
dataFunction: (key: string, data: any) => number | null
tickFormatter: (value: any) => string
}
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = `${toFixedFloat(value, 2)}%`
return updateYAxisWidth(val)
}
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
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 (
<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) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") {
continue
}
// Skip filtered out containers
if (filteredKeys.has(containerKey)) {
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
}
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}
} else {
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
}
// data function
if (isNetChart) {
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
}
return obj
}, [filteredKeys])
// console.log('rendered at', new Date())
if (containerData.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
data={containerData}
margin={chartMargin}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
domain={pinnedAxisDomain()}
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filteredKeys.has(key)
const fillOpacity = filtered ? 0.05 : 0.4
const strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={dataFunction.bind(null, key)}
name={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,83 +0,0 @@
import { useLingui } from "@lingui/react/macro"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Unit } from "@/lib/enums"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function DiskChart({
dataKey,
diskSize,
chartData,
}: {
dataKey: string | ((data: SystemStatsRecord) => number | undefined)
diskSize: number
chartData: ChartData
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { t } = useLingui()
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, diskSize]}
tickCount={9}
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
}}
/>
}
/>
<Area
dataKey={dataKey}
name={t`Disk Usage`}
type="monotoneX"
fill="var(--chart-4)"
fillOpacity={0.4}
stroke="var(--chart-4)"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,117 +0,0 @@
import { memo, useMemo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData } from "@/types"
import { useYAxisWidth } from "./hooks"
import type { DataPoint } from "./line-chart"
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const packageKey = " package"
const { gpuData, dataPoints } = useMemo(() => {
const dataPoints = [] as DataPoint[]
const gpuData = [] as Record<string, GPUData | string>[]
const addedKeys = new Map<string, number>()
const addKey = (key: string, value: number) => {
addedKeys.set(key, (addedKeys.get(key) ?? 0) + value)
}
for (const stats of chartData.systemStats) {
const gpus = stats.stats?.g ?? {}
const data = { created: stats.created } as Record<string, GPUData | string>
for (const id in gpus) {
const gpu = gpus[id] as GPUData
data[gpu.n] = gpu
addKey(gpu.n, gpu.p ?? 0)
if (gpu.pp) {
data[`${gpu.n}${packageKey}`] = gpu
addKey(`${gpu.n}${packageKey}`, gpu.pp ?? 0)
}
}
gpuData.push(data)
}
const sortedKeys = Array.from(addedKeys.entries())
.sort(([, a], [, b]) => b - a)
.map(([key]) => key)
for (let i = 0; i < sortedKeys.length; i++) {
const id = sortedKeys[i]
dataPoints.push({
label: id,
dataKey: (gpuData: Record<string, GPUData>) => {
return id.endsWith(packageKey) ? (gpuData[id]?.pp ?? 0) : (gpuData[id]?.p ?? 0)
},
color: `hsl(${226 + (((i * 360) / addedKeys.size) % 360)}, 65%, 52%)`,
})
}
return { gpuData, dataPoints }
}, [chartData])
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={gpuData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedFloat(value, 2)
return updateYAxisWidth(`${val}W`)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => `${decimalString(item.value)}W`}
// indicator="line"
/>
}
/>
{dataPoints.map((dataPoint) => (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={dataPoint.color as string}
isAnimationActive={false}
/>
))}
{dataPoints.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,6 +1,9 @@
import { useMemo, useState } from "react"
import { useStore } from "@nanostores/react"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData, SystemStats, SystemStatsRecord } from "@/types"
import type { DataPoint } from "./area-chart"
import { $containerFilter } from "@/lib/stores"
/** Chart configurations for CPU, memory, and network usage charts */
export interface ContainerChartConfigs {
@@ -108,6 +111,44 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth }
}
/** Subscribes to the container filter store and returns filtered DataPoints for container charts */
export function useContainerDataPoints(
chartConfig: ChartConfig,
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataFn: (key: string, data: Record<string, any>) => number | null
) {
const filter = useStore($containerFilter)
const { dataPoints, filteredKeys } = useMemo(() => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = new Set<string>()
const points = Object.keys(chartConfig).map((key) => {
const isFiltered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
if (isFiltered) filtered.add(key)
return {
label: key,
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataKey: (data: Record<string, any>) => dataFn(key, data),
color: chartConfig[key].color ?? "",
opacity: isFiltered ? 0.05 : 0.4,
strokeOpacity: isFiltered ? 0.1 : 1,
activeDot: !isFiltered,
stackId: "a",
}
})
return {
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataPoints: points as DataPoint<Record<string, any>>[],
filteredKeys: filtered,
}
}, [chartConfig, filter])
return { filter, dataPoints, filteredKeys }
}
// Assures consistent colors for network interfaces
export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
const keys = Object.keys(interfaces ?? {})
@@ -124,4 +165,4 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
}))
},
}
}
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
@@ -11,15 +11,22 @@ import {
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
export type DataPoint = {
export type DataPoint<T = SystemStatsRecord> = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
dataKey: (data: T) => number | null | undefined
color: number | string
stackId?: string | number
order?: number
strokeOpacity?: number
activeDot?: boolean
}
export default function LineChartDefault({
chartData,
customData,
max,
maxToggled,
tickFormatter,
@@ -28,38 +35,101 @@ export default function LineChartDefault({
domain,
legend,
itemSorter,
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
filter,
truncate = false,
}: // logRender = false,
{
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
filter?: string
truncate?: boolean
// logRender?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
// Only update the rendered data while the chart is visible
const [displayData, setDisplayData] = useState(sourceData)
// Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => {
const shouldPrimeData = sourceData.length && !displayData.length
const sourceChanged = sourceData !== displayData
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
if (shouldUpdate) {
setDisplayData(sourceData)
}
}, [displayData, isIntersecting, sourceData])
// Use a stable key derived from data point identities and visual properties
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
/>
)
})
}, [linesKey, maxToggled])
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
return useMemo(() => {
if (chartData.systemStats.length === 0) {
if (displayData.length === 0) {
return null
}
// if (logRender) {
// console.log("Rendered at", new Date())
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
<ChartContainer
ref={ref}
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<LineChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={displayData}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
@@ -70,41 +140,27 @@ export default function LineChartDefault({
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
/>
}
/>
{dataPoints?.map((dataPoint) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
)
})}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
filter={filter}
truncate={truncate}
/>
}
/>
{Lines}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
}, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime])
}

View File

@@ -1,83 +0,0 @@
import { t } from "@lingui/core/macro"
import { memo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStats } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { color: string; label: string }[] = [
{
color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }),
},
{
color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }),
},
{
color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }),
},
]
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value)}
/>
}
/>
{keys.map(({ color, label }, i) => (
<Line
key={label}
dataKey={(value: { stats: SystemStats }) => value.stats?.la?.[i]}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,108 +0,0 @@
import { useLingui } from "@lingui/react/macro"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Unit } from "@/lib/enums"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { t } = useLingui()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
// console.log('rendered at', new Date())
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
{totalMem && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
// use "ticks" instead of domain / tickcount if need more control
domain={[0, totalMem]}
tickCount={9}
className="tracking-tighter"
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
)}
{xAxis(chartData)}
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
// @ts-expect-error
itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
showTotal={true}
/>
}
/>
<Area
name={t`Used`}
order={3}
dataKey={({ stats }) => (showMax ? stats?.mm : stats?.mu)}
type="monotoneX"
fill="var(--chart-2)"
fillOpacity={0.4}
stroke="var(--chart-2)"
stackId="1"
isAnimationActive={false}
/>
{/* {chartData.systemStats.at(-1)?.stats.mz && ( */}
<Area
name="ZFS ARC"
order={2}
dataKey={({ stats }) => (showMax ? null : stats?.mz)}
type="monotoneX"
fill="hsla(175 60% 45% / 0.8)"
fillOpacity={0.5}
stroke="hsla(175 60% 45% / 0.8)"
stackId="1"
isAnimationActive={false}
/>
{/* )} */}
<Area
name={t`Cache / Buffers`}
order={1}
dataKey={({ stats }) => (showMax ? null : stats?.mb)}
type="monotoneX"
fill="hsla(160 60% 45% / 0.5)"
fillOpacity={0.4}
stroke="hsla(160 60% 45% / 0.5)"
stackId="1"
isAnimationActive={false}
/>
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,70 +0,0 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const userSettings = useStore($userSettings)
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
// indicator="line"
/>
}
/>
<Area
dataKey="stats.su"
name={t`Used`}
type="monotoneX"
fill="var(--chart-2)"
fillOpacity={0.4}
stroke="var(--chart-2)"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,117 +0,0 @@
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatShortDate, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
/** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const tempSums = {} as Record<string, number>
for (const data of chartData.systemStats) {
const newData = { created: data.created } as Record<string, number | string>
const keys = Object.keys(data.stats?.t ?? {})
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
newData[key] = data.stats.t![key]
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
}
newChartData.data.push(newData)
}
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
for (const key of keys) {
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
}
return newChartData
}, [chartData])
const colors = Object.keys(newChartData.colors)
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={["auto", "auto"]}
width={yAxisWidth}
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return decimalString(value) + " " + unit
}}
filter={filter}
/>
}
/>
{colors.map((key) => {
const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
isAnimationActive={false}
/>
)
})}
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -462,7 +462,6 @@ function ContainerSheet({
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -1,6 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
@@ -14,31 +14,29 @@ export function LangToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">{LangTrans}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{LangTrans}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
<DropdownMenuItem
key={lang}
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">{LangTrans}</span>
<TooltipContent>{LangTrans}</TooltipContent>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
<DropdownMenuItem
key={lang}
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Tooltip>
</DropdownMenu>
)
}

View File

@@ -10,7 +10,7 @@ export function ModeToggle() {
return (
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size="icon"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { XIcon } from "lucide-react"
import React, { type JSX, memo, useCallback, useEffect, useState } from "react"
import { $containerFilter, $maxValues } from "@/lib/stores"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import Spinner from "../../spinner"
import { Button } from "../../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../../ui/card"
import { ChartAverage, ChartMax } from "../../ui/icons"
import { Input } from "../../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
export function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const storeValue = useStore(store)
const [inputValue, setInputValue] = useState(storeValue)
const { t } = useLingui()
useEffect(() => {
setInputValue(storeValue)
}, [storeValue])
useEffect(() => {
if (inputValue === storeValue) {
return
}
const handle = window.setTimeout(() => store.set(inputValue), 80)
return () => clearTimeout(handle)
}, [inputValue, storeValue, store])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}, [])
const handleClear = useCallback(() => {
setInputValue("")
store.set("")
}, [store])
return (
<>
<Input
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}
/>
{inputValue && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={handleClear}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</>
)
}
export const SelectAvgMax = memo(({ max }: { max: boolean }) => {
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
<SelectTrigger className="relative ps-10 pe-5 w-full sm:w-44">
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
<Trans>Average</Trans>
</SelectItem>
<SelectItem key="max" value="max">
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
</SelectItem>
</SelectContent>
</Select>
)
})
export function ChartCard({
title,
description,
children,
grid,
empty,
cornerEl,
legend,
className,
}: {
title: string
description: string
children: React.ReactNode
grid?: boolean
empty?: boolean
cornerEl?: JSX.Element | null
legend?: boolean
className?: string
}) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<Card
className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full min-h-full", { "col-span-full": !grid }, className)}
ref={ref}
>
<CardHeader className="pb-5 pt-4 gap-1 relative max-sm:py-3 max-sm:px-4">
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
</CardHeader>
<div className={cn("ps-0 w-[calc(100%-1.3em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
{
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}
className="group-has-[.opacity-100]:invisible duration-100"
/>
}
{isIntersecting && children}
</div>
</Card>
)
}

View File

@@ -0,0 +1,116 @@
import { timeTicks } from "d3-time"
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
type ChartTimeData = {
time: number
data: {
ticks: number[]
domain: number[]
}
chartTime: ChartTimes
}
export const cache = new Map<
string,
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
>()
// create ticks and domain for charts
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
prev: T[],
newRecords: T[],
expectedInterval: number,
maxLen?: number
): T[] {
if (!newRecords.length) return prev
// Pre-trim prev so the single slice() below is the only copy we make
const trimmed = maxLen && prev.length >= maxLen ? prev.slice(-(maxLen - newRecords.length)) : prev
const result = trimmed.slice()
let prevTime = (trimmed.at(-1)?.created as number) ?? 0
for (const record of newRecords) {
if (record.created !== null) {
if (typeof record.created === "string") {
record.created = new Date(record.created).getTime()
}
if (prevTime && (record.created as number) - prevTime > expectedInterval * 1.5) {
result.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
}
prevTime = record.created as number
}
result.push(record)
}
return result
}
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
collection: string,
systemId: string,
chartTime: ChartTimes
): Promise<T[]> {
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: systemId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
sort: "created",
})
}
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
const result = [] as ChartData["containerData"]
for (const { created, stats } of containers) {
if (!created) {
result.push({ created: null } as ChartData["containerData"][0])
continue
}
result.push(makeContainerPoint(new Date(created).getTime(), stats))
}
return result
}
/** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint(
created: number,
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container
}
return point
}
export function dockerOrPodman(str: string, isPodman: boolean): string {
if (isPodman) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
}

View File

@@ -0,0 +1,99 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { decimalString, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData } from "@/types"
import { pinnedAxisDomain } from "@/components/ui/chart"
import CpuCoresSheet from "../cpu-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function CpuChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`CPU Usage`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: 1,
opacity: 0.4,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
/>
</ChartCard>
)
}
export function ContainerCpuChart({
chartData,
grid,
dataEmpty,
isPodman,
cpuConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
cpuConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(cpuConfig, (key, data) => data[key]?.c ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
description={t`Average CPU utilization of containers`}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}

View File

@@ -0,0 +1,106 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, SelectAvgMax } from "../chart-card"
import { Unit } from "@/lib/enums"
export function DiskCharts({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
systemStats: SystemStatsRecord[]
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = $userSettings.get()
let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
return (
<>
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<AreaChartDefault
chartData={chartData}
domain={[0, diskSize]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
dataPoints={[
{
label: t`Disk Usage`,
color: 4,
opacity: 0.4,
dataKey: ({ stats }) => stats?.du,
},
]}
></AreaChartDefault>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Disk I/O`}
description={t`Throughput of root filesystem`}
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t({ message: "Write", comment: "Disk write" }),
dataKey: ({ stats }: SystemStatsRecord) => {
if (showMax) {
return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024
}
return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024
},
color: 3,
opacity: 0.3,
},
{
label: t({ message: "Read", comment: "Disk read" }),
dataKey: ({ stats }: SystemStatsRecord) => {
if (showMax) {
return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024
}
return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024
},
color: 1,
opacity: 0.3,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
</>
)
}

View File

@@ -0,0 +1,120 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, SelectAvgMax } from "../chart-card"
import { Unit } from "@/lib/enums"
export function ExtraFsCharts({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
systemStats: SystemStatsRecord[]
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = $userSettings.get()
const extraFs = systemStats.at(-1)?.stats.efs
if (!extraFs || Object.keys(extraFs).length === 0) {
return null
}
return (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(extraFs).map((extraFsName) => {
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
return (
<div key={extraFsName} className="contents">
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${extraFsName} ${t`Usage`}`}
description={t`Disk usage of ${extraFsName}`}
>
<AreaChartDefault
chartData={chartData}
domain={[0, diskSize]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
dataPoints={[
{
label: t`Disk Usage`,
color: 4,
opacity: 0.4,
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.du,
},
]}
></AreaChartDefault>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${extraFsName} I/O`}
description={t`Throughput of ${extraFsName}`}
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
showTotal={true}
dataPoints={[
{
label: t`Write`,
dataKey: ({ stats }) => {
if (showMax) {
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
}
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
},
color: 3,
opacity: 0.3,
},
{
label: t`Read`,
dataKey: ({ stats }) => {
if (showMax) {
return stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024
}
return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024
},
color: 1,
opacity: 0.3,
},
]}
maxToggled={showMax}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,232 @@
import { t } from "@lingui/core/macro"
import { useRef, useMemo } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import LineChartDefault from "@/components/charts/line-chart"
import { Unit } from "@/lib/enums"
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
/** GPU power draw chart for the main grid */
export function GpuPowerChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const packageKey = " package"
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive GPU power config key (cheap per render)
let gpuPowerKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (gpus) {
const parts: string[] = []
for (const id in gpus) {
const gpu = gpus[id] as GPUData
if (gpu.p !== undefined) parts.push(`${id}:${gpu.n}`)
if (gpu.pp !== undefined) parts.push(`${id}:${gpu.n}${packageKey}`)
}
gpuPowerKey = parts.sort().join("\0")
break
}
}
const dataPoints = useMemo((): DataPoint[] => {
if (!gpuPowerKey) return []
const totals = new Map<string, { label: string; gpuId: string; isPackage: boolean; total: number }>()
for (const record of statsRef.current) {
const gpus = record.stats?.g
if (!gpus) continue
for (const id in gpus) {
const gpu = gpus[id] as GPUData
const key = gpu.n
const existing = totals.get(key)
if (existing) {
existing.total += gpu.p ?? 0
} else {
totals.set(key, { label: gpu.n, gpuId: id, isPackage: false, total: gpu.p ?? 0 })
}
if (gpu.pp !== undefined) {
const pkgKey = `${gpu.n}${packageKey}`
const existingPkg = totals.get(pkgKey)
if (existingPkg) {
existingPkg.total += gpu.pp
} else {
totals.set(pkgKey, { label: pkgKey, gpuId: id, isPackage: true, total: gpu.pp })
}
}
}
}
const sorted = Array.from(totals.values()).sort((a, b) => b.total - a.total)
return sorted.map(
(entry, i): DataPoint => ({
label: entry.label,
dataKey: (data: SystemStatsRecord) => {
const gpu = data.stats?.g?.[entry.gpuId]
return entry.isPackage ? (gpu?.pp ?? 0) : (gpu?.p ?? 0)
},
color: `hsl(${226 + (((i * 360) / sorted.length) % 360)}, 65%, 52%)`,
opacity: 1,
})
)
}, [gpuPowerKey])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`GPU Power Draw`}
description={t`Average power consumption of GPUs`}
>
<LineChartDefault
legend={dataPoints.length > 1}
chartData={chartData}
dataPoints={dataPoints}
itemSorter={(a: { value: number }, b: { value: number }) => b.value - a.value}
tickFormatter={(val) => `${toFixedFloat(val, 2)}W`}
contentFormatter={({ value }) => `${decimalString(value)}W`}
/>
</ChartCard>
)
}
/** GPU detail grid (engines + per-GPU usage/VRAM) — rendered outside the main 2-col grid */
export function GpuDetailCharts({
chartData,
grid,
dataEmpty,
lastGpus,
hasGpuEnginesData,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
lastGpus: Record<string, GPUData>
hasGpuEnginesData: boolean
}) {
return (
<div className="grid xl:grid-cols-2 gap-4">
{hasGpuEnginesData && (
<ChartCard
legend={true}
empty={dataEmpty}
grid={grid}
title={t`GPU Engines`}
description={t`Average utilization of GPU engines`}
>
<GpuEnginesChart chartData={chartData} />
</ChartCard>
)}
{Object.keys(lastGpus).map((id) => {
const gpu = lastGpus[id] as GPUData
return (
<div key={id} className="contents">
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
color: 1,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
{(gpu.mt ?? 0) > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: 2,
opacity: 0.25,
},
]}
max={gpu.mt}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
/>
</ChartCard>
)}
</div>
)
})}
</div>
)
}
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
// Derive stable engine config key (cheap per render)
let enginesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (gpus[id].e) {
enginesKey = id + "\0" + Object.keys(gpus[id].e).sort().join("\0")
break
}
}
if (enginesKey) break
}
const { gpuId, dataPoints } = useMemo((): { gpuId: string | null; dataPoints: DataPoint[] } => {
if (!enginesKey) return { gpuId: null, dataPoints: [] }
const parts = enginesKey.split("\0")
const gId = parts[0]
const engineNames = parts.slice(1)
return {
gpuId: gId,
dataPoints: engineNames.map((engine, i) => ({
label: engine,
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gId]?.e?.[engine] ?? 0,
color: `hsl(${140 + (((i * 360) / engineNames.length) % 360)}, 65%, 52%)`,
opacity: 0.35,
})),
}
}, [enginesKey])
if (!gpuId) {
return null
}
return (
<LineChartDefault
legend={true}
chartData={chartData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
)
}

View File

@@ -0,0 +1,55 @@
import { t } from "@lingui/core/macro"
import type { ChartData } from "@/types"
import { ChartCard } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { decimalString, toFixedFloat } from "@/lib/utils"
export function LoadAverageChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const { major, minor } = chartData.agentVersion
if (major === 0 && minor <= 12) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
legend={true}
>
<LineChartDefault
chartData={chartData}
contentFormatter={(item) => decimalString(item.value)}
tickFormatter={(value) => {
return String(toFixedFloat(value, 2))
}}
legend={true}
dataPoints={[
{
label: t({ message: `1 min`, comment: "Load average" }),
color: "hsl(271, 81%, 60%)", // Purple
dataKey: ({ stats }) => stats?.la?.[0],
},
{
label: t({ message: `5 min`, comment: "Load average" }),
color: "hsl(217, 91%, 60%)", // Blue
dataKey: ({ stats }) => stats?.la?.[1],
},
{
label: t({ message: `15 min`, comment: "Load average" }),
color: "hsl(25, 95%, 53%)", // Orange
dataKey: ({ stats }) => stats?.la?.[2],
},
]}
></LineChartDefault>
</ChartCard>
)
}

View File

@@ -0,0 +1,170 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { Unit } from "@/lib/enums"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { pinnedAxisDomain } from "@/components/ui/chart"
export function MemoryChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Memory Usage`}
description={t`Precise utilization at the recorded time`}
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
domain={[0, totalMem]}
itemSorter={(a, b) => a.order - b.order}
maxToggled={showMax}
showTotal={true}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => (showMax ? stats?.mm : stats?.mu),
color: 2,
opacity: 0.4,
stackId: "1",
order: 3,
},
{
label: "ZFS ARC",
dataKey: ({ stats }) => (showMax ? null : stats?.mz),
color: "hsla(175 60% 45% / 0.8)",
opacity: 0.5,
order: 2,
},
{
label: t`Cache / Buffers`,
dataKey: ({ stats }) => (showMax ? null : stats?.mb),
color: "hsla(160 60% 45% / 0.5)",
opacity: 0.4,
stackId: "1",
order: 1,
},
]}
/>
</ChartCard>
)
}
export function ContainerMemoryChart({
chartData,
grid,
dataEmpty,
isPodman,
memoryConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
memoryConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(memoryConfig, (key, data) => data[key]?.m ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
export function SwapChart({
chartData,
grid,
dataEmpty,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
systemStats: SystemStatsRecord[]
}) {
// const userSettings = useStore($userSettings)
const hasSwapData = (systemStats.at(-1)?.stats.su ?? 0) > 0
if (!hasSwapData) {
return null
}
return (
<ChartCard empty={dataEmpty} grid={grid} title={t`Swap Usage`} description={t`Swap space used by the system`}>
<AreaChartDefault
chartData={chartData}
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => stats?.su,
color: 2,
opacity: 0.4,
},
]}
></AreaChartDefault>
</ChartCard>
)
}

View File

@@ -0,0 +1,183 @@
import { useMemo } from "react"
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { Separator } from "@/components/ui/separator"
import NetworkSheet from "../network-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function BandwidthChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
systemStats: SystemStatsRecord[]
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = $userSettings.get()
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Bandwidth`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`Sent`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
},
color: 5,
opacity: 0.2,
},
{
label: t`Received`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
},
color: 2,
opacity: 0.2,
},
]
// try to place the lesser number in front for better visibility
.sort(() => (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
)
}
export function ContainerNetworkChart({
chartData,
grid,
dataEmpty,
isPodman,
networkConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
networkConfig: ChartConfig
}) {
const userSettings = $userSettings.get()
const { filter, dataPoints, filteredKeys } = useContainerDataPoints(networkConfig, (key, data) => {
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
})
const contentFormatter = useMemo(() => {
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>
)
}
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item
return (item: any, key: string) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") continue
if (filteredKeys.has(containerKey)) 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 {
return null
}
}
}, [filteredKeys, userSettings.unitNet])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={contentFormatter}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}

View File

@@ -0,0 +1,160 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { batteryStateTranslations } from "@/lib/i18n"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { useStore } from "@nanostores/react"
import { useRef, useMemo } from "react"
export function BatteryChart({
chartData,
grid,
dataEmpty,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
maxValues: boolean
}) {
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
if (!showBatteryChart) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Battery`}
description={`${t({
message: "Current state",
comment: "Context: Battery state",
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`Charge`,
dataKey: ({ stats }) => stats?.bat?.[0],
color: 1,
opacity: 0.35,
},
]}
domain={[0, 100]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
)
}
export function TemperatureChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const showTempChart = chartData.systemStats.at(-1)?.stats.t
const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive sensor names key from latest data point
let sensorNamesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const t = chartData.systemStats[i].stats?.t
if (t) {
sensorNamesKey = Object.keys(t).sort().join("\0")
break
}
}
// Only recompute colors and dataKey functions when sensor names change
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
const stats = statsRef.current
const tempSums = {} as Record<string, number>
for (const data of stats) {
const t = data.stats?.t
if (!t) continue
for (const key of Object.keys(t)) {
tempSums[key] = (tempSums[key] ?? 0) + t[key]
}
}
const sorted = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
const colorMap = {} as Record<string, string>
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
for (let i = 0; i < sorted.length; i++) {
const key = sorted[i]
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.t?.[key]
}
return { colorMap, dataKeys, sortedKeys: sorted }
}, [sensorNamesKey])
const dataPoints = useMemo(() => {
return sortedKeys.map((key) => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
opacity: strokeOpacity,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
if (!showTempChart) {
return null
}
const legend = Object.keys(chartData.systemStats.at(-1)?.stats.t ?? {}).length < 12
return (
<div className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
legend={legend}
>
<LineChartDefault
chartData={chartData}
itemSorter={(a, b) => b.value - a.value}
domain={["auto", "auto"]}
legend={legend}
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return `${toFixedFloat(value, 2)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return `${decimalString(value)} ${unit}`
}}
dataPoints={dataPoints}
></LineChartDefault>
</ChartCard>
</div>
)
}

View File

@@ -1,14 +1,14 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../system"
import { ChartCard } from "./chart-card"
const minAgentVersion = parseSemVer("0.15.3")
@@ -42,41 +42,54 @@ export default memo(function CpuCoresSheet({
const numCores = cpus.length
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
// make sure all individual core data points have the same y axis domain to make relative comparison easier
let highestCpuCorePct = 1
if (hasOpened.current) {
for (let i = 0; i < numCores; i++) {
for (let j = 0; j < chartData.systemStats.length; j++) {
const pct = chartData.systemStats[j].stats?.cpus?.[i] ?? 0
if (pct > highestCpuCorePct) {
highestCpuCorePct = pct
}
}
}
}
const breakdownDataPoints = [
{
label: "System",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
color: 3,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "User",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
color: 1,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "IOWait",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
color: 4,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "Steal",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
color: 5,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "Idle",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
color: 2,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: t`Other`,
@@ -86,11 +99,10 @@ export default memo(function CpuCoresSheet({
},
color: `hsl(80, 65%, 52%)`,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
] as DataPoint[]
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
@@ -151,7 +163,7 @@ export default memo(function CpuCoresSheet({
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
opacity: 0.35,
stackId: "a"
stackId: "a",
}))}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
@@ -174,7 +186,7 @@ export default memo(function CpuCoresSheet({
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={false}
domain={[0, highestCpuCorePct]}
dataPoints={[
{
label: t`Usage`,

View File

@@ -1,20 +1,28 @@
import { plural } from "@lingui/core/macro"
import { useLingui } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MemoryStickIcon,
MonitorIcon,
Rows,
Settings2Icon,
} from "lucide-react"
import { useMemo } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -27,12 +35,16 @@ export default function InfoBar({
chartData,
grid,
setGrid,
displayMode,
setDisplayMode,
details,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
displayMode: "default" | "tabs"
setDisplayMode: (mode: "default" | "tabs") => void
details: SystemDetailsRecord | null
}) {
const { t } = useLingui()
@@ -190,24 +202,53 @@ export default function InfoBar({
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t`Toggle grid`}
aria-label={t`View options`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
<Settings2Icon className="size-4 opacity-90" />
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel className="px-3.5">
<Trans>Display</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={displayMode}
onValueChange={(v) => setDisplayMode(v as "default" | "tabs")}
>
<DropdownMenuRadioItem value="default" onSelect={(e) => e.preventDefault()}>
<Trans>Default</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="tabs" onSelect={(e) => e.preventDefault()}>
<Trans>Tabs</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-3.5">
<Trans>Chart width</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={grid ? "grid" : "full"}
onValueChange={(v) => setGrid(v === "grid")}
>
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()}>
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full" onSelect={(e) => e.preventDefault()}>
<Trans>Full</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>

View File

@@ -0,0 +1,36 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
export function LazyContainersTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <ContainersTable systemId={systemId} />}
</div>
)
}
const SmartTable = lazy(() => import("./smart-table"))
export function LazySmartTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SmartTable systemId={systemId} />}
</div>
)
}
const SystemdTable = lazy(() => import("../../systemd-table/systemd-table"))
export function LazySystemdTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SystemdTable systemId={systemId} />}
</div>
)
}

View File

@@ -11,7 +11,7 @@ import { DialogTitle } from "@/components/ui/dialog"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { ChartCard } from "../system"
import { ChartCard } from "./chart-card"
export default memo(function NetworkSheet({
chartData,

View File

@@ -636,7 +636,6 @@ const SmartDevicesTable = memo(function SmartDevicesTable({
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (

View File

@@ -0,0 +1,344 @@
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { subscribeKeys } from "nanostores"
import { useEffect, useMemo, useRef, useState } from "react"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import {
$allSystemsById,
$allSystemsByName,
$chartTime,
$containerFilter,
$direction,
$maxValues,
$systems,
$userSettings,
} from "@/lib/stores"
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
import type {
ChartData,
ContainerStatsRecord,
SystemDetailsRecord,
SystemInfo,
SystemRecord,
SystemStats,
SystemStatsRecord,
} from "@/types"
import { $router, navigate } from "../../router"
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
export function useSystemData(id: string) {
const direction = useStore($direction)
const systems = useStore($systems)
const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues)
const [grid, setGrid] = useBrowserStorage("grid", true)
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
const [activeTab, setActiveTabRaw] = useState("core")
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
const tabsRef = useRef<string[]>(["core", "disk"])
function setActiveTab(tab: string) {
setActiveTabRaw(tab)
setMountedTabs((prev) => (prev.has(tab) ? prev : new Set([...prev, tab])))
}
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const persistChartTime = useRef(false)
const statsRequestId = useRef(0)
const [chartLoading, setChartLoading] = useState(true)
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
useEffect(() => {
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
}
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
}
}, [id])
// find matching system and update when it changes
useEffect(() => {
if (!systems.length) {
return
}
// allow old system-name slug to work
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
return subscribeKeys(store, [id], (newSystems) => {
const sys = newSystems[id]
if (sys) {
setSystem(sys)
document.title = `${sys?.name} / Beszel`
}
})
}, [id, systems.length])
// hide 1m chart time if system agent version is less than 0.13.0
useEffect(() => {
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
$chartTime.set("1h")
}
}, [system?.info?.v])
// fetch system details
useEffect(() => {
// if system.info.m exists, agent is old version without system details
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then(setDetails)
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
return
}
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
$chartTime.set("1h")
return
}
let isFirst = true
pb.realtime
.subscribe(
`rt_metrics`,
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
const now = Date.now()
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
const containerPoint =
data.container?.length > 0
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
: null
// on first message, make sure we clear out data from other time periods
if (isFirst) {
isFirst = false
setSystemStats([statsPoint])
setContainerData(containerPoint ? [containerPoint] : [])
return
}
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
if (containerPoint) {
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
}
},
{ query: { system: system.id } }
)
.then((us) => {
unsub = us
})
return () => {
unsub?.()
}
}, [chartTime, system.id])
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion,
}
}, [systemStats, containerData, direction])
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => {
if (!system.id || !chartTime || chartTime === "1m") {
return
}
const systemId = system.id
const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
const requestId = ++statsRequestId.current
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
// Render from cache immediately if available
if (cachedSystemStats?.length) {
setSystemStats(cachedSystemStats)
setContainerData(cachedContainerData || [])
setChartLoading(false)
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
if (lastCreated && Date.now() - lastCreated < expectedInterval) {
return
}
} else {
setChartLoading(true)
}
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {
return
}
setChartLoading(false)
// make new system stats
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === "fulfilled" && systemStats.value.length) {
systemData = appendData(systemData, systemStats.value, expectedInterval, 100)
cache.set(ss_cache_key, systemData)
}
setSystemStats(systemData)
// make new container stats
let containerData = (cache.get(cs_cache_key) || []) as ChartData["containerData"]
if (containerStats.status === "fulfilled" && containerStats.value.length) {
containerData = appendData(containerData, makeContainerData(containerStats.value), expectedInterval, 100)
cache.set(cs_cache_key, containerData)
}
setContainerData(containerData)
})
}, [system, chartTime])
// keyboard navigation between systems
// in tabs mode: arrow keys switch tabs, shift+arrow switches systems
// in default mode: arrow keys switch systems
useEffect(() => {
if (!systems.length) {
return
}
const handleKeyUp = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.ctrlKey ||
e.metaKey ||
e.altKey
) {
return
}
const isLeft = e.key === "ArrowLeft" || e.key === "h"
const isRight = e.key === "ArrowRight" || e.key === "l"
if (!isLeft && !isRight) {
return
}
// in tabs mode, plain arrows switch tabs, shift+arrows switch systems
if (displayMode === "tabs") {
if (!e.shiftKey) {
// skip if focused in tablist (Radix handles it natively)
if (e.target instanceof HTMLElement && e.target.closest('[role="tablist"]')) {
return
}
const tabs = tabsRef.current
const currentIdx = tabs.indexOf(activeTab)
const nextIdx = isLeft ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length
setActiveTab(tabs[nextIdx])
return
}
} else if (e.shiftKey) {
return
}
const currentIndex = systems.findIndex((s) => s.id === id)
if (currentIndex === -1 || systems.length <= 1) {
return
}
if (isLeft) {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
}
if (isRight) {
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
}
}
return listen(document, "keyup", handleKeyUp)
}, [id, systems, displayMode, activeTab])
// derived values
const isLongerChart = !["1m", "1h"].includes(chartTime)
const showMax = maxValues && isLongerChart
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
const isPodman = details?.podman ?? system.info?.p ?? false
let hasGpuData = false
let hasGpuEnginesData = false
let hasGpuPowerData = false
if (lastGpus) {
hasGpuData = Object.keys(lastGpus).length > 0
for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) {
const gpus = systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (!hasGpuEnginesData && gpus[id].e !== undefined) {
hasGpuEnginesData = true
}
if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) {
hasGpuPowerData = true
}
if (hasGpuEnginesData && hasGpuPowerData) break
}
}
}
return {
system,
systemStats,
containerData,
chartData,
containerChartConfigs,
details,
grid,
setGrid,
displayMode,
setDisplayMode,
activeTab,
setActiveTab,
mountedTabs,
tabsRef,
maxValues,
isLongerChart,
showMax,
dataEmpty,
isPodman,
lastGpus,
hasGpuData,
hasGpuEnginesData,
hasGpuPowerData,
}
}

View File

@@ -614,7 +614,6 @@ function SystemdSheet({
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -391,7 +391,6 @@ function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
const { t } = useLingui()
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer hover:text-foreground",
className
)}
{...props}

View File

@@ -147,6 +147,12 @@
button {
cursor: pointer;
}
/* cosmetic patch for half pixel gap in table headers when scrolling content shows at top */
thead.sticky:before {
content: "";
@apply absolute -top-2 left-0 w-full h-4 bg-table-header z-50
}
}
@utility container {