diff --git a/beszel/site/src/components/systems-table/systems-table.tsx b/beszel/site/src/components/systems-table/systems-table.tsx index f449ad9..d5a994e 100644 --- a/beszel/site/src/components/systems-table/systems-table.tsx +++ b/beszel/site/src/components/systems-table/systems-table.tsx @@ -21,6 +21,9 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" @@ -50,24 +53,32 @@ import { HardDriveIcon, ServerIcon, CpuIcon, - ChevronDownIcon, + LayoutGridIcon, + LayoutListIcon, + ArrowDownIcon, + ArrowUpIcon, + Settings2Icon, + EyeIcon, } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { $hubVersion, $systems, pb } from "@/lib/stores" import { useStore } from "@nanostores/react" import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils" import AlertsButton from "../alerts/alert-button" -import { navigate } from "../router" +import { Link, navigate } from "../router" import { EthernetIcon } from "../ui/icons" import { Trans, t } from "@lingui/macro" import { useLingui } from "@lingui/react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Input } from "../ui/input" +import { ClassValue } from "clsx" + +type ViewMode = "table" | "grid" function CellFormatter(info: CellContext) { const val = info.getValue() as number return ( -
+
{decimalString(val, 1)}% ) { "absolute inset-0 w-full h-full origin-left", (val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600" )} - style={{ transform: `scalex(${val}%)` }} + style={{ + transform: `scalex(${val / 100})`, + }} >
) } -function sortableHeader(column: Column, Icon: any, hideSortIcon = false) { +function sortableHeader(column: Column, hideSortIcon = false) { return ( ) } @@ -100,9 +114,10 @@ export default function SystemsTable() { const data = useStore($systems) const hubVersion = useStore($hubVersion) const [filter, setFilter] = useState() - const [sorting, setSorting] = useState([]) + const [sorting, setSorting] = useState([{ id: t`System`, desc: false }]) const [columnFilters, setColumnFilters] = useState([]) const [columnVisibility, setColumnVisibility] = useLocalStorage("cols", {}) + const [viewMode, setViewMode] = useLocalStorage("viewMode", window.innerWidth > 1024 ? "table" : "grid") const { i18n } = useLingui() useEffect(() => { @@ -120,64 +135,64 @@ export default function SystemsTable() { accessorKey: "name", id: t`System`, enableHiding: false, - cell: (info) => { - const { status } = info.row.original - return ( - - - - - ) - }, - header: ({ column }) => sortableHeader(column, ServerIcon), + icon: ServerIcon, + cell: (info) => ( + + + + + ), + header: ({ column }) => sortableHeader(column), }, { accessorKey: "info.cpu", id: t`CPU`, invertSorting: true, cell: CellFormatter, - header: ({ column }) => sortableHeader(column, CpuIcon), + icon: CpuIcon, + header: ({ column }) => sortableHeader(column), }, { accessorKey: "info.mp", id: t`Memory`, invertSorting: true, cell: CellFormatter, - header: ({ column }) => sortableHeader(column, MemoryStickIcon), + icon: MemoryStickIcon, + header: ({ column }) => sortableHeader(column), }, { accessorKey: "info.dp", id: t`Disk`, invertSorting: true, cell: CellFormatter, - header: ({ column }) => sortableHeader(column, HardDriveIcon), + icon: HardDriveIcon, + header: ({ column }) => sortableHeader(column), }, { accessorFn: (originalRow) => originalRow.info.b || 0, id: t`Net`, invertSorting: true, size: 115, - header: ({ column }) => sortableHeader(column, EthernetIcon), - cell: (info) => { + icon: EthernetIcon, + header: ({ column }) => sortableHeader(column), + cell(info) { const val = info.getValue() as number return ( - {decimalString(val, val >= 100 ? 1 : 2)} MB/s + + {decimalString(val, val >= 100 ? 1 : 2)} MB/s + ) }, }, @@ -186,18 +201,23 @@ export default function SystemsTable() { id: t`Agent`, invertSorting: true, size: 50, - header: ({ column }) => sortableHeader(column, WifiIcon, true), - cell: (info) => { + icon: WifiIcon, + header: ({ column }) => sortableHeader(column, true), + cell(info) { const version = info.getValue() as string if (!version || !hubVersion) { return null } return ( - - + + {info.getValue() as string} ) @@ -206,83 +226,12 @@ export default function SystemsTable() { { id: t({ message: "Actions", comment: "Table column" }), size: 120, - cell: ({ row }) => { - const { id, name, status, host } = row.original - return ( -
- - - - - - - - { - pb.collection("systems").update(id, { - status: status === "paused" ? "pending" : "paused", - }) - }} - > - {status === "paused" ? ( - <> - - Resume - - ) : ( - <> - - Pause - - )} - - copyToClipboard(host)}> - - Copy host - - - - - - Delete - - - - - - - - Are you sure you want to delete {name}? - - - - This action cannot be undone. This will permanently delete all current records for {name} from - the database. - - - - - - Cancel - - pb.collection("systems").delete(id)} - > - Continue - - - - -
- ) - }, + cell: ({ row }) => ( +
+ + +
+ ), }, ] as ColumnDef[] }, [hubVersion, i18n.locale]) @@ -325,86 +274,320 @@ export default function SystemsTable() { - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.id} - - ) - })} + +
+
+ + + Layout + + + setViewMode(view as ViewMode)} + > + e.preventDefault()} className="gap-2"> + + Table + + e.preventDefault()} className="gap-2"> + + Grid + + +
+ +
+ + + Sort By + + +
+ {table.getAllColumns().map((column) => { + if (column.id === t`Actions` || !column.getCanSort()) return null + let Icon = + // if current sort column, show sort direction + if (sorting[0]?.id === column.id) { + if (sorting[0]?.desc) { + Icon = + } else { + Icon = + } + } + return ( + { + e.preventDefault() + setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }]) + }} + key={column.id} + > + {Icon} + {column.id} + + ) + })} +
+
+ +
+ + + Visible Fields + + +
+ {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + e.preventDefault()} + checked={column.getIsVisible()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {column.id} + + ) + })} +
+
+
- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - + {viewMode === "table" ? ( + // table layout +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ) })} - onClick={(e) => { - const target = e.target as HTMLElement - if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) { - navigate(`/system/${encodeURIComponent(row.original.name)}`) - } - }} - > - {row.getVisibleCells().map((cell) => ( - 10 ? "py-2" : "py-2.5")} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - )) - ) : ( - - - No systems found. - - - )} - -
-
-
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + const target = e.target as HTMLElement + if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) { + navigate(`/system/${encodeURIComponent(row.original.name)}`) + } + }} + > + {row.getVisibleCells().map((cell) => ( + 10 ? "py-2" : "py-2.5")} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No systems found. + + + )} + + + + ) : ( + // grid layout +
+ {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const system = row.original + const { status } = system + return ( + + +
+ +
+ + + {system.name} + +
+
+ {table.getColumn(t`Actions`)?.getIsVisible() && ( +
+ + +
+ )} +
+
+ + {table.getAllColumns().map((column) => { + if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null + const cell = row.getAllCells().find((cell) => cell.column.id === column.id) + if (!cell) return null + return ( +
+ {/* @ts-ignore */} + {column.columnDef?.icon && ( + // @ts-ignore + + )} +
+ {column.id}: +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+
+ ) + })} +
+ + {row.original.name} + +
+ ) + }) + ) : ( +
+ No systems found. +
+ )} +
+ )} + ) } + +function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { + className ||= { + "bg-green-500": system.status === "up", + "bg-red-500": system.status === "down", + "bg-primary/40": system.status === "paused", + "bg-yellow-500": system.status === "pending", + } + return ( + + ) +} + +function ActionsButton({ system }: { system: SystemRecord }) { + // const [opened, setOpened] = useState(false) + const { id, status, host, name } = system + return ( + + + + + + + { + pb.collection("systems").update(id, { + status: status === "paused" ? "pending" : "paused", + }) + }} + > + {status === "paused" ? ( + <> + + Resume + + ) : ( + <> + + Pause + + )} + + copyToClipboard(host)}> + + Copy host + + + + + + Delete + + + + + + + + Are you sure you want to delete {name}? + + + + This action cannot be undone. This will permanently delete all current records for {name} from the + database. + + + + + + Cancel + + pb.collection("systems").delete(id)} + > + Continue + + + + + ) +}