mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-26 23:46:17 +01:00
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:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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"]) {
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
129
internal/site/src/components/routes/system/chart-card.tsx
Normal file
129
internal/site/src/components/routes/system/chart-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
internal/site/src/components/routes/system/chart-data.ts
Normal file
116
internal/site/src/components/routes/system/chart-data.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
232
internal/site/src/components/routes/system/charts/gpu-charts.tsx
Normal file
232
internal/site/src/components/routes/system/charts/gpu-charts.tsx
Normal 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)}%`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
internal/site/src/components/routes/system/lazy-tables.tsx
Normal file
36
internal/site/src/components/routes/system/lazy-tables.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
344
internal/site/src/components/routes/system/use-system-data.ts
Normal file
344
internal/site/src/components/routes/system/use-system-data.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user