diff --git a/agent/disk.go b/agent/disk.go index 32731104..069fb3cb 100644 --- a/agent/disk.go +++ b/agent/disk.go @@ -34,6 +34,34 @@ type diskDiscovery struct { ctx fsRegistrationContext } +// prevDisk stores previous per-device disk counters for a given cache interval +type prevDisk struct { + readBytes uint64 + writeBytes uint64 + readTime uint64 // cumulative ms spent on reads (from ReadTime) + writeTime uint64 // cumulative ms spent on writes (from WriteTime) + ioTime uint64 // cumulative ms spent doing I/O (from IoTime) + weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO) + readCount uint64 // cumulative read operation count + writeCount uint64 // cumulative write operation count + at time.Time +} + +// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t. +func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk { + return prevDisk{ + readBytes: d.ReadBytes, + writeBytes: d.WriteBytes, + readTime: d.ReadTime, + writeTime: d.WriteTime, + ioTime: d.IoTime, + weightedIO: d.WeightedIO, + readCount: d.ReadCount, + writeCount: d.WriteCount, + at: t, + } +} + // parseFilesystemEntry parses a filesystem entry in the format "device__customname" // Returns the device/filesystem part and the custom name part func parseFilesystemEntry(entry string) (device, customName string) { @@ -581,16 +609,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) { prev, hasPrev := a.diskPrev[cacheTimeMs][name] if !hasPrev { // Seed from agent-level fsStats if present, else seed from current - prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time} + prev = prevDisk{ + readBytes: stats.TotalRead, + writeBytes: stats.TotalWrite, + readTime: d.ReadTime, + writeTime: d.WriteTime, + ioTime: d.IoTime, + weightedIO: d.WeightedIO, + readCount: d.ReadCount, + writeCount: d.WriteCount, + at: stats.Time, + } if prev.at.IsZero() { - prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} + prev = prevDiskFromCounter(d, now) } } msElapsed := uint64(now.Sub(prev.at).Milliseconds()) + + // Update per-interval snapshot + a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now) + + // Avoid division by zero or clock issues if msElapsed < 100 { - // Avoid division by zero or clock issues; update snapshot and continue - a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} continue } @@ -602,15 +643,38 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) { // validate values if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond) - // Reset interval snapshot and seed from current - a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} // also refresh agent baseline to avoid future negatives a.initializeDiskIoStats(ioCounters) continue } - // Update per-interval snapshot - a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} + // These properties are calculated differently on different platforms, + // but generally represent cumulative time spent doing reads/writes on the device. + // This can surpass 100% if there are multiple concurrent I/O operations. + // Linux kernel docs: + // This is the total number of milliseconds spent by all reads (as + // measured from __make_request() to end_that_request_last()). + // https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8) + diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100) + diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100) + + // I/O utilization %: fraction of wall time the device had any I/O in progress (0-100). + diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100) + + // Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%). + // Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update. + // Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI). + diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100) + + // r_await / w_await: average time per read/write operation in milliseconds. + // Equivalent to r_await and w_await in iostat. + var rAwait, wAwait float64 + if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 { + rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount)) + } + if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 { + wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount)) + } // Update global fsStats baseline for cross-interval correctness stats.Time = now @@ -620,12 +684,24 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) { stats.DiskWritePs = writeMbPerSecond stats.DiskReadBytes = diskIORead stats.DiskWriteBytes = diskIOWrite + stats.DiskIoStats[0] = diskReadTime + stats.DiskIoStats[1] = diskWriteTime + stats.DiskIoStats[2] = diskIoUtilPct + stats.DiskIoStats[3] = rAwait + stats.DiskIoStats[4] = wAwait + stats.DiskIoStats[5] = diskWeightedIO if stats.Root { systemStats.DiskReadPs = stats.DiskReadPs systemStats.DiskWritePs = stats.DiskWritePs systemStats.DiskIO[0] = diskIORead systemStats.DiskIO[1] = diskIOWrite + systemStats.DiskIoStats[0] = diskReadTime + systemStats.DiskIoStats[1] = diskWriteTime + systemStats.DiskIoStats[2] = diskIoUtilPct + systemStats.DiskIoStats[3] = rAwait + systemStats.DiskIoStats[4] = wAwait + systemStats.DiskIoStats[5] = diskWeightedIO } } } diff --git a/agent/system.go b/agent/system.go index bed0eb25..a79819b7 100644 --- a/agent/system.go +++ b/agent/system.go @@ -8,7 +8,6 @@ import ( "os" "runtime" "strings" - "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/battery" @@ -23,13 +22,6 @@ import ( "github.com/shirou/gopsutil/v4/mem" ) -// prevDisk stores previous per-device disk counters for a given cache interval -type prevDisk struct { - readBytes uint64 - writeBytes uint64 - at time.Time -} - // Sets initial / non-changing values about the host system func (a *Agent) refreshSystemDetails() { a.systemInfo.AgentVersion = beszel.Version diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 6ac601d0..81da1fee 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -48,6 +48,8 @@ type Stats struct { MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] + DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %] + MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats } // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. @@ -93,10 +95,12 @@ type FsStats struct { MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"` MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"` // TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes - DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"` - DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"` - MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"` - MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"` + DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"` + DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"` + MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"` + MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"` + DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"8,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %] + MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats } type NetIoStats struct { diff --git a/internal/records/records.go b/internal/records/records.go index b1deb88a..74e8e519 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -230,6 +230,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.Bandwidth[1] += stats.Bandwidth[1] sum.DiskIO[0] += stats.DiskIO[0] sum.DiskIO[1] += stats.DiskIO[1] + for i := range stats.DiskIoStats { + sum.DiskIoStats[i] += stats.DiskIoStats[i] + } batterySum += int(stats.Battery[0]) sum.Battery[1] = stats.Battery[1] @@ -254,6 +257,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1]) sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0]) sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1]) + for i := range stats.DiskIoStats { + sum.MaxDiskIoStats[i] = max(sum.MaxDiskIoStats[i], stats.MaxDiskIoStats[i], stats.DiskIoStats[i]) + } // Accumulate network interfaces if sum.NetworkInterfaces == nil { @@ -299,6 +305,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * fs.DiskWriteBytes += value.DiskWriteBytes fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes) fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes) + for i := range value.DiskIoStats { + fs.DiskIoStats[i] += value.DiskIoStats[i] + fs.MaxDiskIoStats[i] = max(fs.MaxDiskIoStats[i], value.MaxDiskIoStats[i], value.DiskIoStats[i]) + } } } @@ -350,6 +360,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count) sum.DiskIO[0] = sum.DiskIO[0] / uint64(count) sum.DiskIO[1] = sum.DiskIO[1] / uint64(count) + for i := range sum.DiskIoStats { + sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count) + } sum.NetworkSent = twoDecimals(sum.NetworkSent / count) sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count) sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count) @@ -388,6 +401,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count) fs.DiskReadBytes = fs.DiskReadBytes / uint64(count) fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count) + for i := range fs.DiskIoStats { + fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count) + } } } diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index d7f1e4a7..052de9c7 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -41,8 +41,8 @@ export default function AreaChartDefault({ hideYAxis = false, filter, truncate = false, -}: // logRender = false, -{ + chartProps, +}: { chartData: ChartData // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) customData?: any[] @@ -62,7 +62,7 @@ export default function AreaChartDefault({ hideYAxis?: boolean filter?: string truncate?: boolean - // logRender?: boolean + chartProps?: Omit, "data" | "margin"> }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) @@ -131,6 +131,7 @@ export default function AreaChartDefault({ accessibilityLayer data={displayData} margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin} + {...chartProps} > {!hideYAxis && ( diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index 11c44be4..b4d2d9e1 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -40,8 +40,8 @@ export default function LineChartDefault({ hideYAxis = false, filter, truncate = false, -}: // logRender = false, -{ + chartProps, +}: { chartData: ChartData // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) customData?: any[] @@ -61,7 +61,7 @@ export default function LineChartDefault({ hideYAxis?: boolean filter?: string truncate?: boolean - // logRender?: boolean + chartProps?: Omit, "data" | "margin"> }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) @@ -130,6 +130,7 @@ export default function LineChartDefault({ accessibilityLayer data={displayData} margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin} + {...chartProps} > {!hideYAxis && ( diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 57b8f470..4d4b96da 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -1,18 +1,16 @@ import { memo, useState } from "react" import { Trans } from "@lingui/react/macro" import { compareSemVer, parseSemVer } from "@/lib/utils" - import type { GPUData } from "@/types" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import InfoBar from "./system/info-bar" import { useSystemData } from "./system/use-system-data" import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts" import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts" -import { DiskCharts } from "./system/charts/disk-charts" +import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" -import { ExtraFsCharts } from "./system/charts/extra-fs-charts" import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" import { LoadAverageChart } from "./system/charts/load-average-chart" import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react" @@ -24,6 +22,8 @@ const SEMVER_0_14_0 = parseSemVer("0.14.0") const SEMVER_0_15_0 = parseSemVer("0.15.0") export default memo(function SystemDetail({ id }: { id: string }) { + const systemData = useSystemData(id) + const { system, systemStats, @@ -48,7 +48,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { hasGpuData, hasGpuEnginesData, hasGpuPowerData, - } = useSystemData(id) + } = systemData // extra margin to add to bottom of page, specifically for temperature chart, // where the tooltip can go past the bottom of the page if lots of sensors @@ -103,7 +103,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { /> )} - + @@ -138,7 +138,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { /> )} - + {maybeHasSmartData && } @@ -198,9 +198,9 @@ export default memo(function SystemDetail({ id }: { id: string }) { {mountedTabs.has("disk") && ( <>
- +
- + {maybeHasSmartData && } )} diff --git a/internal/site/src/components/routes/system/charts/disk-charts.tsx b/internal/site/src/components/routes/system/charts/disk-charts.tsx index 1b4afa0f..0af2daac 100644 --- a/internal/site/src/components/routes/system/charts/disk-charts.tsx +++ b/internal/site/src/components/routes/system/charts/disk-charts.tsx @@ -1,106 +1,280 @@ 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 type { SystemStatsRecord } from "@/types" import { ChartCard, SelectAvgMax } from "../chart-card" import { Unit } from "@/lib/enums" +import { pinnedAxisDomain } from "@/components/ui/chart" +import DiskIoSheet from "../disk-io-sheet" +import type { SystemData } from "../use-system-data" +import { useStore } from "@nanostores/react" +import { $userSettings } from "@/lib/stores" -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 ? : null - const userSettings = $userSettings.get() +// Helpers for indexed dios/diosm access +const dios = + (i: number) => + ({ stats }: SystemStatsRecord) => + stats?.dios?.[i] ?? 0 +const diosMax = + (i: number) => + ({ stats }: SystemStatsRecord) => + stats?.diosm?.[i] ?? 0 +const extraDios = + (name: string, i: number) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.dios?.[i] ?? 0 +const extraDiosMax = + (name: string, i: number) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.diosm?.[i] ?? 0 + +export const diskDataFns = { + // usage + usage: ({ stats }: SystemStatsRecord) => stats?.du ?? 0, + extraUsage: + (name: string) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.du ?? 0, + // throughput + read: ({ stats }: SystemStatsRecord) => stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024, + readMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024, + write: ({ stats }: SystemStatsRecord) => stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024, + writeMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024, + // extra fs throughput + extraRead: + (name: string) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.rb ?? (stats?.efs?.[name]?.r ?? 0) * 1024 * 1024, + extraReadMax: + (name: string) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.rbm ?? (stats?.efs?.[name]?.rm ?? 0) * 1024 * 1024, + extraWrite: + (name: string) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.wb ?? (stats?.efs?.[name]?.w ?? 0) * 1024 * 1024, + extraWriteMax: + (name: string) => + ({ stats }: SystemStatsRecord) => + stats?.efs?.[name]?.wbm ?? (stats?.efs?.[name]?.wm ?? 0) * 1024 * 1024, + // read/write time + readTime: dios(0), + readTimeMax: diosMax(0), + extraReadTime: (name: string) => extraDios(name, 0), + extraReadTimeMax: (name: string) => extraDiosMax(name, 0), + writeTime: dios(1), + writeTimeMax: diosMax(1), + extraWriteTime: (name: string) => extraDios(name, 1), + extraWriteTimeMax: (name: string) => extraDiosMax(name, 1), + // utilization (IoTime-based, 0-100%) + util: dios(2), + utilMax: diosMax(2), + extraUtil: (name: string) => extraDios(name, 2), + extraUtilMax: (name: string) => extraDiosMax(name, 2), + // r_await / w_await: average service time per read/write operation (ms) + rAwait: dios(3), + rAwaitMax: diosMax(3), + extraRAwait: (name: string) => extraDios(name, 3), + extraRAwaitMax: (name: string) => extraDiosMax(name, 3), + wAwait: dios(4), + wAwaitMax: diosMax(4), + extraWAwait: (name: string) => extraDios(name, 4), + extraWAwaitMax: (name: string) => extraDiosMax(name, 4), + // average queue depth: stored as queue_depth * 100 in Go, divided here + weightedIO: ({ stats }: SystemStatsRecord) => (stats?.dios?.[5] ?? 0) / 100, + weightedIOMax: ({ stats }: SystemStatsRecord) => (stats?.diosm?.[5] ?? 0) / 100, + extraWeightedIO: + (name: string) => + ({ stats }: SystemStatsRecord) => + (stats?.efs?.[name]?.dios?.[5] ?? 0) / 100, + extraWeightedIOMax: + (name: string) => + ({ stats }: SystemStatsRecord) => + (stats?.efs?.[name]?.diosm?.[5] ?? 0) / 100, +} + +export function RootDiskCharts({ systemData }: { systemData: SystemData }) { + return ( + <> + + + + ) +} + +export function DiskUsageChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) { + const { chartData, grid, dataEmpty } = systemData let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN + if (extraFsName) { + diskSize = chartData.systemStats?.at(-1)?.stats.efs?.[extraFsName]?.d ?? NaN + } // round to nearest GB if (diskSize >= 100) { diskSize = Math.round(diskSize) } - return ( - <> - - { - 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, - }, - ]} - > - + const title = extraFsName ? `${extraFsName} ${t`Usage`}` : t`Disk Usage` + const description = extraFsName ? t`Disk usage of ${extraFsName}` : t`Usage of root partition` - - { - 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} - /> - - + return ( + + { + 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: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage, + }, + ]} + > + + ) +} + +export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) { + const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData + const maxValSelect = isLongerChart ? : null + const userSettings = useStore($userSettings) + + if (!chartData.systemStats?.length) { + return null + } + + const title = extraFsName ? t`${extraFsName} I/O` : t`Disk I/O` + const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem` + + const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0)) + + let CornerEl = maxValSelect + if (hasMoreIOMetrics) { + CornerEl = ( +
+ {maxValSelect} + +
+ ) + } + + let readFn = showMax ? diskDataFns.readMax : diskDataFns.read + let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write + if (extraFsName) { + readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName) + writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName) + } + + return ( + + { + 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}` + }} + /> + + ) +} + +export function DiskUtilizationChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) { + const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData + const maxValSelect = isLongerChart ? : null + + if (!chartData.systemStats?.length) { + return null + } + + let utilFn = showMax ? diskDataFns.utilMax : diskDataFns.util + if (extraFsName) { + utilFn = showMax ? diskDataFns.extraUtilMax(extraFsName) : diskDataFns.extraUtil(extraFsName) + } + return ( + + `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + maxToggled={showMax} + chartProps={{ syncId: "io" }} + dataPoints={[ + { + label: t`Utilization`, + dataKey: utilFn, + color: 1, + opacity: 0.4, + }, + ]} + /> + + ) +} + +export function ExtraFsCharts({ systemData }: { systemData: SystemData }) { + const { systemStats } = systemData.chartData + + const extraFs = systemStats?.at(-1)?.stats.efs + + if (!extraFs || Object.keys(extraFs).length === 0) { + return null + } + + return ( +
+ {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 ( +
+ + + +
+ ) + })} +
) } diff --git a/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx b/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx deleted file mode 100644 index 4baf41ca..00000000 --- a/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx +++ /dev/null @@ -1,120 +0,0 @@ -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 ? : null - const userSettings = $userSettings.get() - const extraFs = systemStats.at(-1)?.stats.efs - if (!extraFs || Object.keys(extraFs).length === 0) { - return null - } - - return ( -
- {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 ( -
- - { - 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, - }, - ]} - > - - - { - 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}` - }} - /> - -
- ) - })} -
- ) -} diff --git a/internal/site/src/components/routes/system/disk-io-sheet.tsx b/internal/site/src/components/routes/system/disk-io-sheet.tsx new file mode 100644 index 00000000..220d5de2 --- /dev/null +++ b/internal/site/src/components/routes/system/disk-io-sheet.tsx @@ -0,0 +1,257 @@ +import { t } from "@lingui/core/macro" +import { useStore } from "@nanostores/react" +import { MoreHorizontalIcon } from "lucide-react" +import { memo, useRef, useState } from "react" +import AreaChartDefault from "@/components/charts/area-chart" +import ChartTimeSelect from "@/components/charts/chart-time-select" +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" +import { DialogTitle } from "@/components/ui/dialog" +import { $userSettings } from "@/lib/stores" +import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import { ChartCard, SelectAvgMax } from "@/components/routes/system/chart-card" +import type { SystemData } from "@/components/routes/system/use-system-data" +import { diskDataFns, DiskUtilizationChart } from "./charts/disk-charts" +import { pinnedAxisDomain } from "@/components/ui/chart" + +export default memo(function DiskIOSheet({ + systemData, + extraFsName, + title, + description, +}: { + systemData: SystemData + extraFsName?: string + title: string + description: string +}) { + const { chartData, grid, dataEmpty, showMax, maxValues, isLongerChart } = systemData + const userSettings = useStore($userSettings) + + const [sheetOpen, setSheetOpen] = useState(false) + + const hasOpened = useRef(false) + + if (sheetOpen && !hasOpened.current) { + hasOpened.current = true + } + + // throughput functions, with extra fs variants if needed + let readFn = showMax ? diskDataFns.readMax : diskDataFns.read + let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write + if (extraFsName) { + readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName) + writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName) + } + + // read and write time functions, with extra fs variants if needed + let readTimeFn = showMax ? diskDataFns.readTimeMax : diskDataFns.readTime + let writeTimeFn = showMax ? diskDataFns.writeTimeMax : diskDataFns.writeTime + if (extraFsName) { + readTimeFn = showMax ? diskDataFns.extraReadTimeMax(extraFsName) : diskDataFns.extraReadTime(extraFsName) + writeTimeFn = showMax ? diskDataFns.extraWriteTimeMax(extraFsName) : diskDataFns.extraWriteTime(extraFsName) + } + + // I/O await functions, with extra fs variants if needed + let rAwaitFn = showMax ? diskDataFns.rAwaitMax : diskDataFns.rAwait + let wAwaitFn = showMax ? diskDataFns.wAwaitMax : diskDataFns.wAwait + if (extraFsName) { + rAwaitFn = showMax ? diskDataFns.extraRAwaitMax(extraFsName) : diskDataFns.extraRAwait(extraFsName) + wAwaitFn = showMax ? diskDataFns.extraWAwaitMax(extraFsName) : diskDataFns.extraWAwait(extraFsName) + } + + // weighted I/O function, with extra fs variant if needed + let weightedIOFn = showMax ? diskDataFns.weightedIOMax : diskDataFns.weightedIO + if (extraFsName) { + weightedIOFn = showMax ? diskDataFns.extraWeightedIOMax(extraFsName) : diskDataFns.extraWeightedIO(extraFsName) + } + + // check for availability of I/O metrics + let hasUtilization = false + let hasAwait = false + let hasWeightedIO = false + for (const record of chartData.systemStats ?? []) { + const dios = record.stats?.dios + if ((dios?.at(2) ?? 0) > 0) hasUtilization = true + if ((dios?.at(3) ?? 0) > 0) hasAwait = true + if ((dios?.at(5) ?? 0) > 0) hasWeightedIO = true + if (hasUtilization && hasAwait && hasWeightedIO) { + break + } + } + + const maxValSelect = isLongerChart ? : null + + const chartProps = { syncId: "io" } + + return ( + + {title} + + + + {hasOpened.current && ( + + + + + a.order - b.order} + reverseStackOrder={true} + dataPoints={[ + { + label: t({ message: "Write", comment: "Disk write" }), + dataKey: writeFn, + color: 3, + opacity: 0.4, + stackId: 0, + order: 0, + }, + { + label: t({ message: "Read", comment: "Disk read" }), + dataKey: readFn, + color: 1, + opacity: 0.4, + stackId: 0, + order: 1, + }, + ]} + 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}` + }} + /> + + + {hasUtilization && } + + + `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + maxToggled={showMax} + chartProps={chartProps} + showTotal={true} + itemSorter={(a, b) => a.order - b.order} + reverseStackOrder={true} + dataPoints={[ + { + label: t`Write`, + dataKey: writeTimeFn, + color: 3, + opacity: 0.4, + stackId: 0, + order: 0, + }, + { + label: t`Read`, + dataKey: readTimeFn, + color: 1, + opacity: 0.4, + stackId: 0, + order: 1, + }, + ]} + /> + + + {hasWeightedIO && ( + + `${toFixedFloat(val, 2)}`} + contentFormatter={({ value }) => decimalString(value, value < 10 ? 3 : 2)} + maxToggled={showMax} + chartProps={chartProps} + dataPoints={[ + { + label: t`Queue Depth`, + dataKey: weightedIOFn, + color: 1, + opacity: 0.4, + }, + ]} + /> + + )} + + {hasAwait && ( + + `${toFixedFloat(val, 2)} ms`} + contentFormatter={({ value }) => `${decimalString(value)} ms`} + maxToggled={showMax} + chartProps={chartProps} + dataPoints={[ + { + label: t`Write`, + dataKey: wAwaitFn, + color: 3, + opacity: 0.3, + }, + { + label: t`Read`, + dataKey: rAwaitFn, + color: 1, + opacity: 0.3, + }, + ]} + /> + + )} + + )} + + ) +}) diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index 935ac2df..da49da87 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -28,6 +28,8 @@ import type { import { $router, navigate } from "../../router" import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data" +export type SystemData = ReturnType + export function useSystemData(id: string) { const direction = useStore($direction) const systems = useStore($systems) diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 1ee46a0d..9ad3ecf7 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -124,6 +124,10 @@ export interface SystemStats { dio?: [number, number] /** max disk I/O bytes [read, write] */ diom?: [number, number] + /** disk io stats [read time factor, write time factor, io utilization %, r_await ms, w_await ms, weighted io %] */ + dios?: [number, number, number, number, number, number] + /** max disk io stats */ + diosm?: [number, number, number, number, number, number] /** network sent (mb) */ ns: number /** network received (mb) */ @@ -186,6 +190,10 @@ export interface ExtraFsStats { rbm: number /** max write per second (mb) */ wbm: number + /** disk io stats [read time factor, write time factor, io utilization %, r_await ms, w_await ms, weighted io %] */ + dios?: [number, number, number, number, number, number] + /** max disk io stats */ + diosm?: [number, number, number, number, number, number] } export interface ContainerStatsRecord extends RecordModel { @@ -413,118 +421,118 @@ export interface SystemdRecord extends RecordModel { } export interface SystemdServiceDetails { - AccessSELinuxContext: string; - ActivationDetails: any[]; - ActiveEnterTimestamp: number; - ActiveEnterTimestampMonotonic: number; - ActiveExitTimestamp: number; - ActiveExitTimestampMonotonic: number; - ActiveState: string; - After: string[]; - AllowIsolate: boolean; - AssertResult: boolean; - AssertTimestamp: number; - AssertTimestampMonotonic: number; - Asserts: any[]; - Before: string[]; - BindsTo: any[]; - BoundBy: any[]; - CPUUsageNSec: number; - CanClean: any[]; - CanFreeze: boolean; - CanIsolate: boolean; - CanLiveMount: boolean; - CanReload: boolean; - CanStart: boolean; - CanStop: boolean; - CollectMode: string; - ConditionResult: boolean; - ConditionTimestamp: number; - ConditionTimestampMonotonic: number; - Conditions: any[]; - ConflictedBy: any[]; - Conflicts: string[]; - ConsistsOf: any[]; - DebugInvocation: boolean; - DefaultDependencies: boolean; - Description: string; - Documentation: string[]; - DropInPaths: any[]; - ExecMainPID: number; - FailureAction: string; - FailureActionExitStatus: number; - Following: string; - FragmentPath: string; - FreezerState: string; - Id: string; - IgnoreOnIsolate: boolean; - InactiveEnterTimestamp: number; - InactiveEnterTimestampMonotonic: number; - InactiveExitTimestamp: number; - InactiveExitTimestampMonotonic: number; - InvocationID: string; - Job: Array; - JobRunningTimeoutUSec: number; - JobTimeoutAction: string; - JobTimeoutRebootArgument: string; - JobTimeoutUSec: number; - JoinsNamespaceOf: any[]; - LoadError: string[]; - LoadState: string; - MainPID: number; - Markers: any[]; - MemoryCurrent: number; - MemoryLimit: number; - MemoryPeak: number; - NRestarts: number; - Names: string[]; - NeedDaemonReload: boolean; - OnFailure: any[]; - OnFailureJobMode: string; - OnFailureOf: any[]; - OnSuccess: any[]; - OnSuccessJobMode: string; - OnSuccessOf: any[]; - PartOf: any[]; - Perpetual: boolean; - PropagatesReloadTo: any[]; - PropagatesStopTo: any[]; - RebootArgument: string; - Refs: any[]; - RefuseManualStart: boolean; - RefuseManualStop: boolean; - ReloadPropagatedFrom: any[]; - RequiredBy: any[]; - Requires: string[]; - RequiresMountsFor: any[]; - Requisite: any[]; - RequisiteOf: any[]; - Result: string; - SliceOf: any[]; - SourcePath: string; - StartLimitAction: string; - StartLimitBurst: number; - StartLimitIntervalUSec: number; - StateChangeTimestamp: number; - StateChangeTimestampMonotonic: number; - StopPropagatedFrom: any[]; - StopWhenUnneeded: boolean; - SubState: string; - SuccessAction: string; - SuccessActionExitStatus: number; - SurviveFinalKillSignal: boolean; - TasksCurrent: number; - TasksMax: number; - Transient: boolean; - TriggeredBy: string[]; - Triggers: any[]; - UnitFilePreset: string; - UnitFileState: string; - UpheldBy: any[]; - Upholds: any[]; - WantedBy: any[]; - Wants: string[]; - WantsMountsFor: any[]; + AccessSELinuxContext: string + ActivationDetails: any[] + ActiveEnterTimestamp: number + ActiveEnterTimestampMonotonic: number + ActiveExitTimestamp: number + ActiveExitTimestampMonotonic: number + ActiveState: string + After: string[] + AllowIsolate: boolean + AssertResult: boolean + AssertTimestamp: number + AssertTimestampMonotonic: number + Asserts: any[] + Before: string[] + BindsTo: any[] + BoundBy: any[] + CPUUsageNSec: number + CanClean: any[] + CanFreeze: boolean + CanIsolate: boolean + CanLiveMount: boolean + CanReload: boolean + CanStart: boolean + CanStop: boolean + CollectMode: string + ConditionResult: boolean + ConditionTimestamp: number + ConditionTimestampMonotonic: number + Conditions: any[] + ConflictedBy: any[] + Conflicts: string[] + ConsistsOf: any[] + DebugInvocation: boolean + DefaultDependencies: boolean + Description: string + Documentation: string[] + DropInPaths: any[] + ExecMainPID: number + FailureAction: string + FailureActionExitStatus: number + Following: string + FragmentPath: string + FreezerState: string + Id: string + IgnoreOnIsolate: boolean + InactiveEnterTimestamp: number + InactiveEnterTimestampMonotonic: number + InactiveExitTimestamp: number + InactiveExitTimestampMonotonic: number + InvocationID: string + Job: Array + JobRunningTimeoutUSec: number + JobTimeoutAction: string + JobTimeoutRebootArgument: string + JobTimeoutUSec: number + JoinsNamespaceOf: any[] + LoadError: string[] + LoadState: string + MainPID: number + Markers: any[] + MemoryCurrent: number + MemoryLimit: number + MemoryPeak: number + NRestarts: number + Names: string[] + NeedDaemonReload: boolean + OnFailure: any[] + OnFailureJobMode: string + OnFailureOf: any[] + OnSuccess: any[] + OnSuccessJobMode: string + OnSuccessOf: any[] + PartOf: any[] + Perpetual: boolean + PropagatesReloadTo: any[] + PropagatesStopTo: any[] + RebootArgument: string + Refs: any[] + RefuseManualStart: boolean + RefuseManualStop: boolean + ReloadPropagatedFrom: any[] + RequiredBy: any[] + Requires: string[] + RequiresMountsFor: any[] + Requisite: any[] + RequisiteOf: any[] + Result: string + SliceOf: any[] + SourcePath: string + StartLimitAction: string + StartLimitBurst: number + StartLimitIntervalUSec: number + StateChangeTimestamp: number + StateChangeTimestampMonotonic: number + StopPropagatedFrom: any[] + StopWhenUnneeded: boolean + SubState: string + SuccessAction: string + SuccessActionExitStatus: number + SurviveFinalKillSignal: boolean + TasksCurrent: number + TasksMax: number + Transient: boolean + TriggeredBy: string[] + Triggers: any[] + UnitFilePreset: string + UnitFileState: string + UpheldBy: any[] + Upholds: any[] + WantedBy: any[] + Wants: string[] + WantsMountsFor: any[] } export interface BeszelInfo {