mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 17:59:28 +08:00
feat: add grid view option for systems table
- Add toggle button to switch between table and grid layouts - Implement card-based grid view with system metrics - Add sort dropdown menu for grid view - Display system status, metrics with icons and labels - Improve handling of long system names - Maintain consistent sorting and filtering between views - Persist view preference in localStorage
This commit is contained in:
@@ -51,6 +51,8 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
LayoutListIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
||||||
@@ -64,18 +66,23 @@ import { useLingui } from "@lingui/react"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
|
|
||||||
|
type ViewMode = 'table' | 'grid'
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
<div className="flex gap-3 items-center tabular-nums tracking-tight">
|
||||||
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
||||||
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
"absolute inset-0 w-full h-full origin-left transition-all duration-500",
|
||||||
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
|
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
|
||||||
)}
|
)}
|
||||||
style={{ transform: `scalex(${val}%)` }}
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
transition: "transform 500ms, background-color 500ms"
|
||||||
|
}}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +110,7 @@ export default function SystemsTable() {
|
|||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
||||||
|
const [viewMode, setViewMode] = useLocalStorage<ViewMode>('viewMode', 'table')
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,7 +139,7 @@ export default function SystemsTable() {
|
|||||||
"bg-primary/40": status === "paused",
|
"bg-primary/40": status === "paused",
|
||||||
"bg-yellow-500": status === "pending",
|
"bg-yellow-500": status === "pending",
|
||||||
})}
|
})}
|
||||||
style={{ marginBottom: "-1px" }}
|
style={{ marginBottom: "-2px" }}
|
||||||
></span>
|
></span>
|
||||||
<Button
|
<Button
|
||||||
data-nolink
|
data-nolink
|
||||||
@@ -177,7 +185,9 @@ export default function SystemsTable() {
|
|||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap ps-1">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
<span className={cn("tabular-nums whitespace-nowrap", {
|
||||||
|
"ps-1": viewMode === 'table',
|
||||||
|
})}>{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -193,7 +203,9 @@ export default function SystemsTable() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-2 items-center md:pe-5 tabular-nums ps-1">
|
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
|
||||||
|
"ps-1": viewMode === 'table',
|
||||||
|
})}>
|
||||||
<span
|
<span
|
||||||
className={cn("w-2 h-2 left-0 rounded-full", version === hubVersion ? "bg-green-500" : "bg-yellow-500")}
|
className={cn("w-2 h-2 left-0 rounded-full", version === hubVersion ? "bg-green-500" : "bg-yellow-500")}
|
||||||
style={{ marginBottom: "-1px" }}
|
style={{ marginBottom: "-1px" }}
|
||||||
@@ -320,13 +332,70 @@ export default function SystemsTable() {
|
|||||||
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<div className="flex gap-2 ms-auto w-full md:w-[500px]">
|
||||||
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setViewMode(viewMode === 'table' ? 'grid' : 'table')}
|
||||||
|
title={viewMode === 'table' ? t`Switch to grid view` : t`Switch to table view`}
|
||||||
|
>
|
||||||
|
{viewMode === 'table' ? (
|
||||||
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
) : (
|
||||||
|
<LayoutListIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{viewMode === 'grid' && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Trans comment="Context: table columns">Columns</Trans>{" "}
|
<Trans>Sort</Trans> <ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (column.id === t`Actions` || !column.getCanSort()) return null
|
||||||
|
|
||||||
|
const isCurrentSort = sorting[0]?.id === column.id
|
||||||
|
const sortDirection = sorting[0]?.desc ? '↓' : '↑'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={column.id}
|
||||||
|
onClick={() => {
|
||||||
|
const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc
|
||||||
|
setSorting([{ id: column.id, desc: isDesc }])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.id} {isCurrentSort && sortDirection}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{sorting.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setSorting([])}>
|
||||||
|
<Trans>Clear Sort</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
{viewMode === 'table' ? (
|
||||||
|
<>
|
||||||
|
<Trans comment="Context: table columns">Columns</Trans>
|
||||||
<ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
<ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trans>Fields</Trans>
|
||||||
|
<ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -349,7 +418,8 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-sm:p-2">
|
<div className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
|
{viewMode === 'table' ? (
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="rounded-md border overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -404,7 +474,154 @@ export default function SystemsTable() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<Card
|
||||||
|
key={row.original.id}
|
||||||
|
className={cn("cursor-pointer hover:shadow-md transition-all w-full", {
|
||||||
|
"opacity-50": row.original.status === "paused",
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
||||||
|
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader className="pt-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 w-2 h-2 rounded-full", {
|
||||||
|
"bg-green-500": row.original.status === "up",
|
||||||
|
"bg-red-500": row.original.status === "down",
|
||||||
|
"bg-primary/40": row.original.status === "paused",
|
||||||
|
"bg-yellow-500": row.original.status === "pending",
|
||||||
|
})}
|
||||||
|
style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-base truncate">
|
||||||
|
{row.original.name}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
{table.getColumn(t`Actions`)?.getIsVisible() && (
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<AlertsButton system={row.original} />
|
||||||
|
<AlertDialog>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(row.original.id, {
|
||||||
|
status: row.original.status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(row.original.host)}>
|
||||||
|
<CopyIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
|
||||||
|
<Trash2Icon className="me-2.5 h-4 w-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {row.original.name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {row.original.name} from
|
||||||
|
the database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(row.original.id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2.5 text-sm">
|
||||||
|
{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
|
||||||
|
|
||||||
|
const icon = (() => {
|
||||||
|
switch (column.id) {
|
||||||
|
case t`CPU`: return <CpuIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
case t`Memory`: return <MemoryStickIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
case t`Disk`: return <HardDriveIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
case t`Net`: return <EthernetIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
case t`Agent`: return <WifiIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={column.id} className="flex items-center gap-3">
|
||||||
|
{icon}
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="text-muted-foreground w-14">{column.id}:</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-8">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user