import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { isReadOnlyUser, pb } from "@/lib/api" import { cn, formatBytes, formatShortDate, hourWithSeconds, toFixedFloat } from "@/lib/utils" import type { ZfsDataset, ZfsPoolRecord, ZfsVdev } from "@/types" import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import type { Column, ColumnDef } from "@tanstack/react-table" import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table" import { ActivityIcon, BinaryIcon, CheckCircleIcon, CircleAlertIcon, ClockIcon, DatabaseIcon, HardDriveDownloadIcon, HardDriveIcon, HardDriveUploadIcon, LoaderCircleIcon, MoreHorizontalIcon, RefreshCwIcon, RotateCwIcon, XCircleIcon, XIcon, FolderTreeIcon, } from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" 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" { switch (health) { case "ONLINE": return "success" case "DEGRADED": return "warning" case "FAULTED": case "OFFLINE": case "UNAVAIL": case "REMOVED": case "SUSPENDED": return "danger" default: return "outline" } } function formatCapacity(bytes: number): string { if (!bytes) return "-" const { value, unit } = formatBytes(bytes) return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` } function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { const isSorted = column.getIsSorted() return ( ) } function poolType(pool: ZfsPoolRecord): string { return pool.name.startsWith("b:") ? "Btrfs" : "ZFS" } const columns: ColumnDef[] = [ { id: "name", accessorFn: (pool) => pool.display_name || pool.name, header: ({ column }) => , cell: ({ getValue }) => {getValue() as string}, }, { id: "type", accessorFn: poolType, header: ({ column }) => , cell: ({ getValue }) => { const type = getValue() as string return ( {type} ) }, }, { accessorKey: "health", sortingFn: (a, b) => a.original.health.localeCompare(b.original.health), header: ({ column }) => , cell: ({ getValue }) => { const health = (getValue() as string) || "" return {health || t`Unknown`} }, }, { id: "size", accessorFn: (record) => record.size, invertSorting: true, header: ({ column }) => , cell: ({ getValue, row }) => {formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}, }, { id: "used", accessorFn: (record) => record.alloc, invertSorting: true, header: ({ column }) => , cell: ({ getValue, row }) => {formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}, }, { id: "free", accessorFn: (record) => record.free, invertSorting: true, header: ({ column }) => , cell: ({ getValue, row }) => {row.original.raw ? "-" : formatCapacity(getValue() as number)}, }, { id: "scrub", accessorFn: (record) => record.scrub?.state ?? "", header: ({ column }) => , cell: ({ row }) => { const scrub = row.original.scrub if (!scrub?.state) return {t`None`} return ( {scrub.state} {scrub.progress ? ` (${scrub.progress})` : ""} ) }, }, { id: "updated", invertSorting: true, accessorFn: (record) => record.details_updated || record.updated, header: ({ column }) => , cell: ({ getValue }) => { const timestamp = getValue() as string if (!timestamp) return null const formatter = new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate return {formatter(timestamp)} }, }, ] function VdevTable({ vdevs }: { vdevs: ZfsVdev[] }) { if (!vdevs?.length) return null return (
Vdev {t`State`} {t`Read errors`} {t`Write errors`} {t`Checksum errors`} {vdevs.map((vdev) => ( {vdev.name} {vdev.state ?? "-"} 0 && "text-red-600 dark:text-red-400")} > {vdev.readErrs ?? 0} 0 && "text-red-600 dark:text-red-400")} > {vdev.writeErrs ?? 0} 0 && "text-red-600 dark:text-red-400" )} > {vdev.checksumErrs ?? 0} ))}
) } const datasetColumns: ColumnDef[] = [ { accessorKey: "name", sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), header: ({ column }) => , cell: ({ getValue }) => {getValue() as string}, }, { id: "used", accessorFn: (ds) => ds.used ?? 0, invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => {formatCapacity(getValue() as number)}, }, { id: "avail", accessorFn: (ds) => ds.avail ?? 0, invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => {formatCapacity(getValue() as number)}, }, { accessorKey: "mount", sortingFn: (a, b) => (a.original.mount ?? "").localeCompare(b.original.mount ?? ""), header: ({ column }) => , cell: ({ getValue }) => ( {(getValue() as string) || "-"} ), }, ] function DatasetTable({ datasets }: { datasets: ZfsDataset[] }) { const [filter, setFilter] = useState("") const filtered = useMemo(() => { if (!datasets) return [] if (!filter) return datasets const needle = filter.toLowerCase() return datasets.filter((ds) => ds.name.toLowerCase().includes(needle)) }, [datasets, filter]) const table = useReactTable({ data: filtered, columns: datasetColumns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), }) if (!datasets?.length) return null return (

Datasets

setFilter(e.target.value)} className="px-4 w-full" /> {filter && ( )}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ))}
) } function PoolSheet({ poolId, open, onOpenChange, }: { poolId: string | null open: boolean onOpenChange: (open: boolean) => void }) { const [pool, setPool] = useState(null) const titleRef = useRef(null) const [isLoading, setIsLoading] = useState(false) useEffect(() => { let active = true if (!poolId) { setPool(null) return } // Only fetch when opening, not when closing (keeps data visible during close animation) if (!open) return setIsLoading(true) pb.collection("zfs_pools") .getOne(poolId) .then((record) => active && setPool(record as ZfsPoolRecord)) .catch(() => active && setPool(null)) .finally(() => active && setIsLoading(false)) return () => { active = false } }, [open, poolId]) const health = pool?.health || "" const healthVariantValue = healthVariant(health) const HealthIcon = healthVariantValue === "success" ? CheckCircleIcon : healthVariantValue === "warning" ? CircleAlertIcon : XCircleIcon return ( { event.preventDefault() titleRef.current?.focus() }} > {pool ? (pool.display_name || pool.name) : `Storage Pool`} {pool && {health}} {pool?.size ? formatCapacity(pool.size) : null} {pool?.raw && } {pool?.alloc ? ( <> Used: {formatCapacity(pool.alloc)}{pool.raw ? ` (${t`Raw`})` : ""} ) : null} {pool?.free && !pool.raw ? ( <> Free: {formatCapacity(pool.free)} ) : null}
{isLoading ? (
) : ( <> {pool && health && ( Pool Health: {health} {pool.scrub?.state && ( Scrub: {pool.scrub.state} {pool.scrub.progress ? ` (${pool.scrub.progress})` : ""} {pool.scrub.errors ? `, ${pool.scrub.errors} errors` : ""} )} )} {pool?.vdevs?.length || pool?.datasets?.length ? ( <> {pool.vdevs?.length ? : null} ) : ( !isLoading && (
No detail data for this pool.
) )} )}
) } export default function ZfsTable({ systemId }: { systemId?: string }) { const [zfsPools, setZfsPools] = useState() const [globalFilter, setGlobalFilter] = useState("") const [activePoolId, setActivePoolId] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [refreshingId, setRefreshingId] = useState(null) useEffect(() => { let disposed = false let unsubscribe: () => void = () => {} // fetch initial records pb.collection("zfs_pools") .getFullList({ filter: systemId ? pb.filter("system={:id}", { id: systemId }) : "", sort: "name", fields: ZFS_POOL_FIELDS, }) .then((records) => !disposed && setZfsPools(records)) .catch((error) => console.error("Failed to fetch ZFS pools:", error)) // subscribe to realtime updates const pbOptions = systemId ? { filter: `system="${systemId}"` } : undefined ;(async () => { try { const unsubscribeNow = await pb.collection("zfs_pools").subscribe( "*", (event) => { const record = event.record as ZfsPoolRecord setZfsPools((current) => { const pools = current ?? [] const matchesSystemScope = !systemId || record.system === systemId if (event.action === "delete") { return pools.filter((pool) => pool.id !== record.id) } if (!matchesSystemScope) { return pools.filter((pool) => pool.id !== record.id) } const existingIndex = pools.findIndex((pool) => pool.id === record.id) if (existingIndex === -1) { return [record, ...pools] } const next = [...pools] next[existingIndex] = record return next }) }, pbOptions ) if (disposed) { unsubscribeNow() } else { unsubscribe = unsubscribeNow } } catch (error) { console.error("Failed to subscribe to ZFS pool updates:", error) } })() return () => { disposed = true unsubscribe?.() } }, [systemId]) const refreshSystem = useCallback(async (systemId: string) => { try { await pb.send("/api/beszel/zfs/refresh", { method: "POST", query: { system: systemId }, }) } catch (error) { console.error("Failed to refresh ZFS pools:", error) } }, []) const handleRowRefresh = useCallback( async (pool: ZfsPoolRecord) => { if (!pool.system) return setRefreshingId(pool.id) try { await refreshSystem(pool.system) } finally { setRefreshingId((id) => (id === pool.id ? null : id)) } }, [refreshSystem] ) const actionColumn = useMemo>( () => ({ id: "actions", enableSorting: false, header: () => ( Actions ), cell: ({ row }) => { const pool = row.original const isRowRefreshing = refreshingId === pool.id return (
event.stopPropagation()}> { event.stopPropagation() handleRowRefresh(pool) }} disabled={isRowRefreshing} > Refresh
) }, }), [refreshingId, handleRowRefresh] ) const tableColumns = useMemo(() => { return isReadOnlyUser() ? columns : [...columns, actionColumn] }, [actionColumn]) const table = useReactTable({ data: zfsPools || ([] as ZfsPoolRecord[]), columns: tableColumns, initialState: { sorting: [{ id: "name", desc: false }] }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { globalFilter }, onGlobalFilterChange: setGlobalFilter, globalFilterFn: (row, _columnId, filterValue) => { const pool = row.original const searchString = `${pool.display_name ?? ""} ${pool.name} ${poolType(pool)} ${pool.health ?? ""}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) const rows = table.getRowModel().rows // Hide the table on system pages if there's no data if (systemId && !zfsPools?.length && !globalFilter) { return null } const openSheet = (pool: ZfsPoolRecord) => { setActivePoolId(pool.id) setSheetOpen(true) } return (
Storage Pools Click on a pool to view vdev and dataset details.
setGlobalFilter(event.target.value)} className="px-4 w-full max-w-full md:w-64" /> {globalFilter && ( )}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} {rows.map((row) => ( openSheet(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ))}
) }