mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-05 12:31:49 +02: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 { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -11,18 +11,23 @@ import {
|
|||||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
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
|
label: string
|
||||||
dataKey: (data: SystemStatsRecord) => number | undefined
|
dataKey: (data: T) => number | null | undefined
|
||||||
color: number | string
|
color: number | string
|
||||||
opacity: number
|
opacity: number
|
||||||
stackId?: string | number
|
stackId?: string | number
|
||||||
|
order?: number
|
||||||
|
strokeOpacity?: number
|
||||||
|
activeDot?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
chartData,
|
chartData,
|
||||||
|
customData,
|
||||||
max,
|
max,
|
||||||
maxToggled,
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
@@ -34,96 +39,129 @@ export default function AreaChartDefault({
|
|||||||
showTotal = false,
|
showTotal = false,
|
||||||
reverseStackOrder = false,
|
reverseStackOrder = false,
|
||||||
hideYAxis = false,
|
hideYAxis = false,
|
||||||
|
filter,
|
||||||
|
truncate = false,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
maxToggled?: boolean
|
customData?: any[]
|
||||||
tickFormatter: (value: number, index: number) => string
|
max?: number
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
maxToggled?: boolean
|
||||||
dataPoints?: DataPoint[]
|
tickFormatter: (value: number, index: number) => string
|
||||||
domain?: AxisDomain
|
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||||
legend?: boolean
|
contentFormatter: (item: any, key: string) => ReactNode
|
||||||
showTotal?: boolean
|
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
|
||||||
itemSorter?: (a: any, b: any) => number
|
dataPoints?: DataPoint<any>[]
|
||||||
reverseStackOrder?: boolean
|
domain?: AxisDomain
|
||||||
hideYAxis?: boolean
|
legend?: boolean
|
||||||
// logRender?: 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 { 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(() => {
|
return useMemo(() => {
|
||||||
if (chartData.systemStats.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date())
|
console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<div>
|
<ChartContainer
|
||||||
<ChartContainer
|
ref={ref}
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
"opacity-100": yAxisWidth || hideYAxis,
|
"opacity-100": yAxisWidth || hideYAxis,
|
||||||
"ps-4": hideYAxis,
|
"ps-4": hideYAxis,
|
||||||
})}
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
reverseStackOrder={reverseStackOrder}
|
||||||
|
accessibilityLayer
|
||||||
|
data={displayData}
|
||||||
|
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<CartesianGrid vertical={false} />
|
||||||
reverseStackOrder={reverseStackOrder}
|
{!hideYAxis && (
|
||||||
accessibilityLayer
|
<YAxis
|
||||||
data={chartData.systemStats}
|
direction="ltr"
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
orientation={chartData.orientation}
|
||||||
>
|
className="tracking-tighter"
|
||||||
<CartesianGrid vertical={false} />
|
width={yAxisWidth}
|
||||||
{!hideYAxis && (
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
<YAxis
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
direction="ltr"
|
tickLine={false}
|
||||||
orientation={chartData.orientation}
|
axisLine={false}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{dataPoints?.map((dataPoint) => {
|
)}
|
||||||
let { color } = dataPoint
|
{xAxis(chartData)}
|
||||||
if (typeof color === "number") {
|
<ChartTooltip
|
||||||
color = `var(--chart-${color})`
|
animationEasing="ease-out"
|
||||||
}
|
animationDuration={150}
|
||||||
return (
|
// @ts-expect-error
|
||||||
<Area
|
itemSorter={itemSorter}
|
||||||
key={dataPoint.label}
|
content={
|
||||||
dataKey={dataPoint.dataKey}
|
<ChartTooltipContent
|
||||||
name={dataPoint.label}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
type="monotoneX"
|
contentFormatter={contentFormatter}
|
||||||
fill={color}
|
showTotal={showTotal}
|
||||||
fillOpacity={dataPoint.opacity}
|
filter={filter}
|
||||||
stroke={color}
|
truncate={truncate}
|
||||||
isAnimationActive={false}
|
/>
|
||||||
stackId={dataPoint.stackId}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
{Areas}
|
||||||
})}
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
|
</AreaChart>
|
||||||
</AreaChart>
|
</ChartContainer>
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}, [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 { useMemo, useState } from "react"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
import type { ChartConfig } from "@/components/ui/chart"
|
import type { ChartConfig } from "@/components/ui/chart"
|
||||||
import type { ChartData, SystemStats, SystemStatsRecord } from "@/types"
|
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 */
|
/** Chart configurations for CPU, memory, and network usage charts */
|
||||||
export interface ContainerChartConfigs {
|
export interface ContainerChartConfigs {
|
||||||
@@ -108,6 +111,44 @@ export function useYAxisWidth() {
|
|||||||
return { yAxisWidth, updateYAxisWidth }
|
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
|
// Assures consistent colors for network interfaces
|
||||||
export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
|
export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
|
||||||
const keys = Object.keys(interfaces ?? {})
|
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 { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -11,15 +11,22 @@ import {
|
|||||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
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
|
label: string
|
||||||
dataKey: (data: SystemStatsRecord) => number | undefined
|
dataKey: (data: T) => number | null | undefined
|
||||||
color: number | string
|
color: number | string
|
||||||
|
stackId?: string | number
|
||||||
|
order?: number
|
||||||
|
strokeOpacity?: number
|
||||||
|
activeDot?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LineChartDefault({
|
export default function LineChartDefault({
|
||||||
chartData,
|
chartData,
|
||||||
|
customData,
|
||||||
max,
|
max,
|
||||||
maxToggled,
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
@@ -28,38 +35,101 @@ export default function LineChartDefault({
|
|||||||
domain,
|
domain,
|
||||||
legend,
|
legend,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
|
showTotal = false,
|
||||||
|
reverseStackOrder = false,
|
||||||
|
hideYAxis = false,
|
||||||
|
filter,
|
||||||
|
truncate = false,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
|
customData?: any[]
|
||||||
max?: number
|
max?: number
|
||||||
maxToggled?: boolean
|
maxToggled?: boolean
|
||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||||
dataPoints?: DataPoint[]
|
contentFormatter: (item: any, key: string) => ReactNode
|
||||||
domain?: [number, number]
|
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
|
||||||
|
dataPoints?: DataPoint<any>[]
|
||||||
|
domain?: AxisDomain
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
|
showTotal?: boolean
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||||
itemSorter?: (a: any, b: any) => number
|
itemSorter?: (a: any, b: any) => number
|
||||||
|
reverseStackOrder?: boolean
|
||||||
|
hideYAxis?: boolean
|
||||||
|
filter?: string
|
||||||
|
truncate?: boolean
|
||||||
// logRender?: boolean
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
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(() => {
|
return useMemo(() => {
|
||||||
if (chartData.systemStats.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date())
|
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<div>
|
<ChartContainer
|
||||||
<ChartContainer
|
ref={ref}
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
"opacity-100": yAxisWidth,
|
"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
|
<YAxis
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
@@ -70,41 +140,27 @@ export default function LineChartDefault({
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
{xAxis(chartData)}
|
)}
|
||||||
<ChartTooltip
|
{xAxis(chartData)}
|
||||||
animationEasing="ease-out"
|
<ChartTooltip
|
||||||
animationDuration={150}
|
animationEasing="ease-out"
|
||||||
// @ts-expect-error
|
animationDuration={150}
|
||||||
itemSorter={itemSorter}
|
// @ts-expect-error
|
||||||
content={
|
itemSorter={itemSorter}
|
||||||
<ChartTooltipContent
|
content={
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
<ChartTooltipContent
|
||||||
contentFormatter={contentFormatter}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
/>
|
contentFormatter={contentFormatter}
|
||||||
}
|
showTotal={showTotal}
|
||||||
/>
|
filter={filter}
|
||||||
{dataPoints?.map((dataPoint) => {
|
truncate={truncate}
|
||||||
let { color } = dataPoint
|
/>
|
||||||
if (typeof color === "number") {
|
}
|
||||||
color = `var(--chart-${color})`
|
/>
|
||||||
}
|
{Lines}
|
||||||
return (
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
<Line
|
</LineChart>
|
||||||
key={dataPoint.label}
|
</ChartContainer>
|
||||||
dataKey={dataPoint.dataKey}
|
|
||||||
name={dataPoint.label}
|
|
||||||
type="monotoneX"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke={color}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}, [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> }) {
|
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
||||||
return (
|
return (
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
<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) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon } from "lucide-react"
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
@@ -14,31 +14,29 @@ export function LangToggle() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenuTrigger className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
|
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
||||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
<span className="sr-only">{LangTrans}</span>
|
||||||
<span className="sr-only">{LangTrans}</span>
|
<TooltipContent>{LangTrans}</TooltipContent>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{LangTrans}</TooltipContent>
|
<DropdownMenuContent className="grid grid-cols-3">
|
||||||
</Tooltip>
|
{languages.map(([lang, label, e]) => (
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuItem
|
||||||
<DropdownMenuContent className="grid grid-cols-3">
|
key={lang}
|
||||||
{languages.map(([lang, label, e]) => (
|
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
|
||||||
<DropdownMenuItem
|
onClick={() => dynamicActivate(lang)}
|
||||||
key={lang}
|
>
|
||||||
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
|
<span>
|
||||||
onClick={() => dynamicActivate(lang)}
|
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
|
||||||
>
|
</span>{" "}
|
||||||
<span>
|
{label}
|
||||||
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
|
</DropdownMenuItem>
|
||||||
</span>{" "}
|
))}
|
||||||
{label}
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</Tooltip>
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ModeToggle() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={"ghost"}
|
variant={"ghost"}
|
||||||
size="icon"
|
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 { t } from "@lingui/core/macro"
|
||||||
import { MoreHorizontalIcon } from "lucide-react"
|
import { MoreHorizontalIcon } from "lucide-react"
|
||||||
import { memo, useRef, useState } from "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 ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { DialogTitle } from "@/components/ui/dialog"
|
import { DialogTitle } from "@/components/ui/dialog"
|
||||||
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
|
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { ChartCard } from "../system"
|
import { ChartCard } from "./chart-card"
|
||||||
|
|
||||||
const minAgentVersion = parseSemVer("0.15.3")
|
const minAgentVersion = parseSemVer("0.15.3")
|
||||||
|
|
||||||
@@ -42,41 +42,54 @@ export default memo(function CpuCoresSheet({
|
|||||||
const numCores = cpus.length
|
const numCores = cpus.length
|
||||||
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
|
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 = [
|
const breakdownDataPoints = [
|
||||||
{
|
{
|
||||||
label: "System",
|
label: "System",
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "User",
|
label: "User",
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
|
||||||
color: 1,
|
color: 1,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "IOWait",
|
label: "IOWait",
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
|
||||||
color: 4,
|
color: 4,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Steal",
|
label: "Steal",
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
|
||||||
color: 5,
|
color: 5,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Idle",
|
label: "Idle",
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
|
||||||
color: 2,
|
color: 2,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Other`,
|
label: t`Other`,
|
||||||
@@ -86,11 +99,10 @@ export default memo(function CpuCoresSheet({
|
|||||||
},
|
},
|
||||||
color: `hsl(80, 65%, 52%)`,
|
color: `hsl(80, 65%, 52%)`,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
},
|
},
|
||||||
] as DataPoint[]
|
] as DataPoint[]
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
|
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
|
||||||
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
|
<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),
|
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))`,
|
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
stackId: "a"
|
stackId: "a",
|
||||||
}))}
|
}))}
|
||||||
tickFormatter={(val) => `${val}%`}
|
tickFormatter={(val) => `${val}%`}
|
||||||
contentFormatter={({ value }) => `${value}%`}
|
contentFormatter={({ value }) => `${value}%`}
|
||||||
@@ -174,7 +186,7 @@ export default memo(function CpuCoresSheet({
|
|||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
legend={false}
|
domain={[0, highestCpuCorePct]}
|
||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t`Usage`,
|
label: t`Usage`,
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { plural } from "@lingui/core/macro"
|
import { plural } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
AppleIcon,
|
AppleIcon,
|
||||||
ChevronRightSquareIcon,
|
ChevronRightSquareIcon,
|
||||||
ClockArrowUp,
|
ClockArrowUp,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LayoutGridIcon,
|
|
||||||
MemoryStickIcon,
|
MemoryStickIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
Rows,
|
Settings2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
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 { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
@@ -27,12 +35,16 @@ export default function InfoBar({
|
|||||||
chartData,
|
chartData,
|
||||||
grid,
|
grid,
|
||||||
setGrid,
|
setGrid,
|
||||||
|
displayMode,
|
||||||
|
setDisplayMode,
|
||||||
details,
|
details,
|
||||||
}: {
|
}: {
|
||||||
system: SystemRecord
|
system: SystemRecord
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
grid: boolean
|
grid: boolean
|
||||||
setGrid: (grid: boolean) => void
|
setGrid: (grid: boolean) => void
|
||||||
|
displayMode: "default" | "tabs"
|
||||||
|
setDisplayMode: (mode: "default" | "tabs") => void
|
||||||
details: SystemDetailsRecord | null
|
details: SystemDetailsRecord | null
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
@@ -190,24 +202,53 @@ export default function InfoBar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
||||||
<Tooltip>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t`Toggle grid`}
|
aria-label={t`View options`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hidden xl:flex p-0 text-primary"
|
className="hidden xl:flex p-0 text-primary"
|
||||||
onClick={() => setGrid(!grid)}
|
|
||||||
>
|
>
|
||||||
{grid ? (
|
<Settings2Icon className="size-4 opacity-90" />
|
||||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
|
|
||||||
) : (
|
|
||||||
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</DropdownMenuTrigger>
|
||||||
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
</Tooltip>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 { $userSettings } from "@/lib/stores"
|
||||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { ChartCard } from "../system"
|
import { ChartCard } from "./chart-card"
|
||||||
|
|
||||||
export default memo(function NetworkSheet({
|
export default memo(function NetworkSheet({
|
||||||
chartData,
|
chartData,
|
||||||
|
|||||||
@@ -636,7 +636,6 @@ const SmartDevicesTable = memo(function SmartDevicesTable({
|
|||||||
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
|
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
|
||||||
return (
|
return (
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
<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) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{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> }) {
|
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
|
||||||
return (
|
return (
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
<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) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
|
|||||||
@@ -391,7 +391,6 @@ function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
|
|||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
return (
|
return (
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
<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) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -147,6 +147,12 @@
|
|||||||
button {
|
button {
|
||||||
cursor: pointer;
|
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 {
|
@utility container {
|
||||||
|
|||||||
Reference in New Issue
Block a user