mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
feat(agent): report btrfs filesystems as storage pools (#2315)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -8,7 +8,7 @@ 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 { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { ZfsCharts } from "./system/charts/zfs-charts"
|
||||
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ChartCard({
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
description: React.ReactNode
|
||||
children: React.ReactNode
|
||||
grid?: boolean
|
||||
empty?: boolean
|
||||
|
||||
@@ -3,6 +3,7 @@ import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { SystemStatsRecord } from "@/types"
|
||||
import { ChartCard } from "../chart-card"
|
||||
import { RawCapacityLabel } from "../raw-capacity-label"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
@@ -10,9 +11,11 @@ import type { SystemData } from "../use-system-data"
|
||||
|
||||
// Accessors for ZFS metrics
|
||||
const poolUsage =
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.z?.[name]?.du ?? 0
|
||||
(name: string, raw: boolean) =>
|
||||
({ stats }: SystemStatsRecord) => {
|
||||
const pool = stats?.z?.[name]
|
||||
return pool && !!pool.raw === raw ? pool.du : null
|
||||
}
|
||||
const poolRead =
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
@@ -26,9 +29,10 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
||||
const { chartData, grid, dataEmpty } = systemData
|
||||
const latest = chartData.systemStats.at(-1)?.stats
|
||||
const pool = latest?.z?.[poolName]
|
||||
if (!pool) {
|
||||
if (!pool || pool.hu) {
|
||||
return null
|
||||
}
|
||||
const displayName = pool.n || poolName
|
||||
let poolTotal = pool.d
|
||||
// round to nearest GB
|
||||
if (poolTotal >= 100) {
|
||||
@@ -39,8 +43,8 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${poolName} ${t`Usage`}`}
|
||||
description={t`Usage of ZFS pool ${poolName}`}
|
||||
title={`${displayName} ${t`Usage`}`}
|
||||
description={pool.raw ? <RawCapacityLabel label={t`Raw usage of storage pool ${displayName}`} /> : t`Usage of storage pool ${displayName}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
@@ -57,7 +61,7 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Pool Usage`,
|
||||
dataKey: poolUsage(poolName),
|
||||
dataKey: poolUsage(poolName, !!pool.raw),
|
||||
color: 4,
|
||||
opacity: 0.4,
|
||||
},
|
||||
@@ -70,15 +74,16 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
||||
export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) {
|
||||
const { chartData, grid, dataEmpty } = systemData
|
||||
const userSettings = useStore($userSettings)
|
||||
if (!chartData.systemStats?.length) {
|
||||
if (!chartData.systemStats?.length || chartData.systemStats.at(-1)?.stats.z?.[poolName]?.hi) {
|
||||
return null
|
||||
}
|
||||
const displayName = chartData.systemStats.at(-1)?.stats.z?.[poolName]?.n || poolName
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${poolName} I/O`}
|
||||
description={t`Throughput of ZFS pool ${poolName}`}
|
||||
title={`${displayName} I/O`}
|
||||
description={t`Throughput of storage pool ${displayName}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
@@ -114,12 +119,15 @@ export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemDat
|
||||
export function ZfsCharts({ systemData }: { systemData: SystemData }) {
|
||||
const latest = systemData.chartData.systemStats?.at(-1)?.stats
|
||||
const pools = latest?.z ?? {}
|
||||
if (Object.keys(pools).length === 0) {
|
||||
const visiblePools = Object.keys(pools)
|
||||
.filter((name) => !pools[name].hu || !pools[name].hi)
|
||||
.sort((a, b) => (pools[a].n || a).localeCompare(pools[b].n || b, undefined, { numeric: true }) || a.localeCompare(b))
|
||||
if (visiblePools.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{Object.keys(pools).map((poolName) => (
|
||||
{visiblePools.map((poolName) => (
|
||||
<div key={poolName} className="contents">
|
||||
<ZfsPoolUsageChart systemData={systemData} poolName={poolName} />
|
||||
<ZfsPoolIOChart systemData={systemData} poolName={poolName} />
|
||||
@@ -24,7 +24,7 @@ export function LazySmartTable({ systemId }: { systemId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const ZfsTable = lazy(() => import("./zfs-table"))
|
||||
const ZfsTable = lazy(() => import("./storage-pools-table"))
|
||||
|
||||
export function LazyZfsTable({ systemId }: { systemId: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
export function RawCapacityLabel({ label = t`Raw capacity` }: { label?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`About raw capacity`}
|
||||
className="inline-flex rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64">
|
||||
{t`Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled.`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
CheckCircleIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
HardDriveDownloadIcon,
|
||||
HardDriveIcon,
|
||||
HardDriveUploadIcon,
|
||||
@@ -35,10 +36,13 @@ import {
|
||||
RotateCwIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
FolderTreeIcon,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
const ZFS_POOL_FIELDS = "id,system,name,health,size,alloc,free,scrub,details_updated,updated"
|
||||
import { RawCapacityLabel } from "./raw-capacity-label"
|
||||
|
||||
const ZFS_POOL_FIELDS = "id,system,name,display_name,health,size,alloc,free,raw,scrub,details_updated,updated"
|
||||
|
||||
/** Maps a zpool health string to a Badge variant. */
|
||||
function healthVariant(health: string): "success" | "warning" | "danger" | "outline" {
|
||||
@@ -81,13 +85,30 @@ function HeaderButton<T>({ column, name, Icon }: { column: Column<T>; name: stri
|
||||
)
|
||||
}
|
||||
|
||||
function poolType(pool: ZfsPoolRecord): string {
|
||||
return pool.name.startsWith("b:") ? "Btrfs" : "ZFS"
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ZfsPoolRecord>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={HardDriveIcon} />,
|
||||
id: "name",
|
||||
accessorFn: (pool) => pool.display_name || pool.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={DatabaseIcon} />,
|
||||
cell: ({ getValue }) => <span className="font-medium ms-1.5">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorFn: poolType,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={FolderTreeIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue() as string
|
||||
return (
|
||||
<Badge variant="outline" className={cn("border-transparent", type === "ZFS" ? "bg-blue-200 text-blue-800" : "bg-yellow-200 text-yellow-800")}>
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "health",
|
||||
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
|
||||
@@ -102,21 +123,21 @@ const columns: ColumnDef<ZfsPoolRecord>[] = [
|
||||
accessorFn: (record) => record.size,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
||||
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>,
|
||||
},
|
||||
{
|
||||
id: "used",
|
||||
accessorFn: (record) => record.alloc,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Used`} Icon={HardDriveDownloadIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
||||
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>,
|
||||
},
|
||||
{
|
||||
id: "free",
|
||||
accessorFn: (record) => record.free,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: `Free`, context: "Free space" })} Icon={HardDriveUploadIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
||||
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{row.original.raw ? "-" : formatCapacity(getValue() as number)}</span>,
|
||||
},
|
||||
{
|
||||
id: "scrub",
|
||||
@@ -201,7 +222,7 @@ const datasetColumns: ColumnDef<ZfsDataset>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={HardDriveIcon} />,
|
||||
header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={DatabaseIcon} />,
|
||||
cell: ({ getValue }) => <span className="font-mono text-xs">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
@@ -310,6 +331,7 @@ function PoolSheet({
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [pool, setPool] = useState<ZfsPoolRecord | null>(null)
|
||||
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -342,23 +364,30 @@ function PoolSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-220 gap-0 overflow-y-auto">
|
||||
<SheetContent
|
||||
className="w-full sm:max-w-220 gap-0 overflow-y-auto"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
titleRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="mb-0 border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{pool ? pool.name : `ZFS Pool`}
|
||||
<SheetTitle ref={titleRef} tabIndex={-1} className="flex items-center gap-2 outline-none">
|
||||
{pool ? (pool.display_name || pool.name) : `Storage Pool`}
|
||||
{pool && <Badge variant={healthVariantValue}>{health}</Badge>}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{pool?.size ? formatCapacity(pool.size) : null}
|
||||
{pool?.raw && <RawCapacityLabel />}
|
||||
{pool?.alloc ? (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<span>
|
||||
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}
|
||||
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}{pool.raw ? ` (${t`Raw`})` : ""}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{pool?.free ? (
|
||||
{pool?.free && !pool.raw ? (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<span>
|
||||
@@ -555,6 +584,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
||||
const table = useReactTable({
|
||||
data: zfsPools || ([] as ZfsPoolRecord[]),
|
||||
columns: tableColumns,
|
||||
initialState: { sorting: [{ id: "name", desc: false }] },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
@@ -562,7 +592,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const pool = row.original
|
||||
const searchString = `${pool.name} ${pool.health ?? ""}`.toLowerCase()
|
||||
const searchString = `${pool.display_name ?? ""} ${pool.name} ${poolType(pool)} ${pool.health ?? ""}`.toLowerCase()
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
@@ -587,7 +617,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">ZFS</CardTitle>
|
||||
<CardTitle className="mb-2">Storage Pools</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a pool to view vdev and dataset details.</Trans>
|
||||
</CardDescription>
|
||||
@@ -1768,8 +1768,8 @@ msgid "Throughput of {extraFsName}"
|
||||
msgstr "Throughput of {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/charts/zfs-charts.tsx
|
||||
msgid "Throughput of ZFS pool {poolName}"
|
||||
msgstr "Throughput of ZFS pool {poolName}"
|
||||
msgid "Throughput of storage pool {poolName}"
|
||||
msgstr "Throughput of storage pool {poolName}"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Time format"
|
||||
@@ -1969,8 +1969,8 @@ msgid "Usage"
|
||||
msgstr "Usage"
|
||||
|
||||
#: src/components/routes/system/charts/zfs-charts.tsx
|
||||
msgid "Usage of ZFS pool {poolName}"
|
||||
msgstr "Usage of ZFS pool {poolName}"
|
||||
msgid "Usage of storage pool {poolName}"
|
||||
msgstr "Usage of storage pool {poolName}"
|
||||
|
||||
#: src/components/routes/system/charts/memory-charts.tsx
|
||||
#: src/components/routes/system/charts/memory-charts.tsx
|
||||
|
||||
8
internal/site/src/types.d.ts
vendored
8
internal/site/src/types.d.ts
vendored
@@ -181,6 +181,12 @@ export interface GPUData {
|
||||
}
|
||||
|
||||
export interface ZfsPool {
|
||||
/** Friendly name; map keys are stable pool identities. */
|
||||
n?: string
|
||||
/** Equivalent filesystem charts are already displayed. */
|
||||
hu?: boolean
|
||||
hi?: boolean
|
||||
raw?: boolean
|
||||
/** total capacity (GiB) */
|
||||
d: number
|
||||
/** allocated (GiB) */
|
||||
@@ -217,6 +223,8 @@ export interface ZfsDataset {
|
||||
}
|
||||
|
||||
export interface ZfsPoolRecord extends RecordModel {
|
||||
display_name?: string
|
||||
raw?: boolean
|
||||
system: string
|
||||
name: string
|
||||
health: string
|
||||
|
||||
Reference in New Issue
Block a user