feat(ui): Add view settings to network monitors table (#2396)

This commit is contained in:
Sven van Ginkel
2026-09-23 19:47:42 +02:00
committed by GitHub
parent 9042a8c5c8
commit b1270e341c
5 changed files with 193 additions and 22 deletions

View File

@@ -23,7 +23,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon, SearchIcon, ServerIcon } from "lucide-react"
import { ChevronDownIcon, ListIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import { cn, supportsNetworkMonitors } from "@/lib/utils"
@@ -446,8 +446,13 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
<>
<div className="flex gap-0 rounded-lg">
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow" disabled={!hasEligibleSystems}>
{/* <PlusIcon className="size-4 me-1" /> */}
<Trans>Add {{ foo: t`Monitor` }}</Trans>
<PlusIcon className="size-4 me-1" />
<span className="sm:hidden">
<Trans>Add</Trans>
</span>
<span className="hidden sm:inline">
<Trans>Add {{ foo: t`Monitor` }}</Trans>
</span>
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>

View File

@@ -40,6 +40,12 @@ import { Badge } from "../ui/badge"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
declare module "@tanstack/react-table" {
interface ColumnMeta<TData, TValue> {
label?: string
}
}
const protocolColors: Record<string, string> = {
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
@@ -98,6 +104,7 @@ export function getMonitorColumns(
},
{
id: "system",
meta: { label: t`System` },
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
@@ -134,6 +141,7 @@ export function getMonitorColumns(
},
{
id: "target",
meta: { label: t`Target` },
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
accessorFn: (record) => getMonitorTarget(record),
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
@@ -164,6 +172,7 @@ export function getMonitorColumns(
},
{
id: "protocol",
meta: { label: t`Protocol` },
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
@@ -173,6 +182,7 @@ export function getMonitorColumns(
},
{
id: "interval",
meta: { label: t`Interval` },
accessorFn: (record) => record.interval,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
@@ -180,6 +190,7 @@ export function getMonitorColumns(
},
{
id: "res",
meta: { label: t`Response` },
accessorFn: (record) => record.res,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
@@ -187,6 +198,7 @@ export function getMonitorColumns(
},
{
id: "res1h",
meta: { label: t`Avg 1h` },
accessorFn: (record) => record.resAvg1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
@@ -194,6 +206,7 @@ export function getMonitorColumns(
},
{
id: "max1h",
meta: { label: t`Max 1h` },
accessorFn: (record) => record.resMax1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
@@ -201,6 +214,7 @@ export function getMonitorColumns(
},
{
id: "min1h",
meta: { label: t`Min 1h` },
accessorFn: (record) => record.resMin1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
@@ -208,6 +222,7 @@ export function getMonitorColumns(
},
{
id: "loss",
meta: { label: t`Loss 1h` },
accessorFn: (record) => record.loss1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
@@ -236,6 +251,7 @@ export function getMonitorColumns(
},
{
id: "updated",
meta: { label: t`Updated` },
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,

View File

@@ -26,27 +26,47 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { memo, useCallback, useMemo, useRef, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { subscribeKeys } from "nanostores"
import { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api"
import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import { $allSystemsById, $direction, getUserChartTime } from "@/lib/stores"
import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores"
import {
cn,
isVisuallyLonger,
matchesFilterGroups,
parseFilterGroups,
parseSemVer,
useBrowserStorage,
} from "@/lib/utils"
import type { ChartData, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
import { ArrowLeftRightIcon, EthernetPortIcon, LoaderCircleIcon, ServerIcon, XIcon } from "lucide-react"
import {
ArrowDownIcon,
ArrowLeftRightIcon,
ArrowUpDownIcon,
ArrowUpIcon,
EthernetPortIcon,
EyeIcon,
LoaderCircleIcon,
ServerIcon,
Settings2Icon,
XIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts"
@@ -66,13 +86,19 @@ export default function NetworkMonitorsTableNew({
monitors: NetworkMonitorRecord[]
isLoading: boolean
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-target-${systemId ? 1 : 0}`,
[{ id: systemId ? "target" : "system", desc: false }],
sessionStorage
const sortSettingsKey = systemId ? "monitorSortModeSystem" : "monitorSortMode"
const sortStorageKey = `besz-sort-np-target-${systemId ? 1 : 0}`
const [sorting, setSorting] = useState<SortingState>(
() =>
$userSettings.get()[sortSettingsKey] ??
JSON.parse(sessionStorage.getItem(sortStorageKey) || "null") ?? [
{ id: systemId ? "target" : "system", desc: false },
]
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => $userSettings.get().monitorCols ?? JSON.parse(localStorage.getItem("besz-monitor-cols") || "{}")
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
@@ -82,6 +108,47 @@ export default function NetworkMonitorsTableNew({
const { toast } = useToast()
const canManageMonitors = !isReadOnlyUser()
// Apply settings from server once they load (handles incognito / new devices)
const appliedSettings = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["monitorCols", sortSettingsKey], (vals) => {
if (!appliedSettings.current.has("monitorCols") && vals.monitorCols !== undefined) {
appliedSettings.current.add("monitorCols")
setColumnVisibility(vals.monitorCols)
}
if (!appliedSettings.current.has(sortSettingsKey) && vals[sortSettingsKey] !== undefined) {
appliedSettings.current.add(sortSettingsKey)
setSorting(vals[sortSettingsKey] as SortingState)
}
})
}, [sortSettingsKey])
const handleColumnVisibilityChange = useCallback(
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
setColumnVisibility((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
localStorage.setItem("besz-monitor-cols", JSON.stringify(next))
$userSettings.setKey("monitorCols", next)
queueUserSettings({ monitorCols: next })
return next
})
},
[]
)
const handleSortingChange = useCallback(
(updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
sessionStorage.setItem(sortStorageKey, JSON.stringify(next))
$userSettings.setKey(sortSettingsKey, next)
queueUserSettings({ [sortSettingsKey]: next })
return next
})
},
[sortSettingsKey, sortStorageKey]
)
const longestTarget = useMemo(() => {
let longestTarget = ""
for (const p of monitors) {
@@ -208,9 +275,9 @@ export default function NetworkMonitorsTableNew({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onSortingChange: handleSortingChange,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
@@ -237,11 +304,12 @@ export default function NetworkMonitorsTableNew({
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
const visibleColumnsKey = visibleColumns.map((column) => column.id).join(",")
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<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="grid md-lg:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Network Monitors</Trans>
@@ -250,14 +318,14 @@ export default function NetworkMonitorsTableNew({
<Trans>Response time monitoring from agents.</Trans>
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
<div className="md-lg:ms-auto flex items-center gap-2">
{monitors.length > 0 && (
<div className="relative">
<div className="relative grow">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-50"
className="ms-auto px-4 w-full max-w-full md-lg:w-50"
/>
{globalFilter && (
<Button
@@ -273,6 +341,74 @@ export default function NetworkMonitorsTableNew({
)}
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
<div className="grid grid-cols-2 divide-y md:divide-s md:divide-y-0">
<div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<ArrowUpDownIcon className="size-4" />
<Trans>Sort By</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1 pb-1">
{table.getAllColumns().map((column) => {
if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span>
if (sorting[0]?.id === column.id) {
Icon = sorting[0]?.desc ? (
<ArrowUpIcon className="me-2 size-4" />
) : (
<ArrowDownIcon className="me-2 size-4" />
)
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
handleSortingChange([
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
])
}}
key={column.id}
>
{Icon}
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuItem>
)
})}
</div>
</div>
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
))}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
{canManageMonitors ? (
<EditMonitorDialog
@@ -325,6 +461,7 @@ export default function NetworkMonitorsTableNew({
table={table}
rows={rows}
colLength={visibleColumns.length}
visibleColumnsKey={visibleColumnsKey}
rowSelection={rowSelection}
isLoading={isLoading}
/>
@@ -337,12 +474,14 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
table,
rows,
colLength,
visibleColumnsKey,
rowSelection,
isLoading,
}: {
table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[]
colLength: number
visibleColumnsKey: string
rowSelection: RowSelectionState
isLoading: boolean
}) {
@@ -390,6 +529,7 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
rowSelection={rowSelection}
visibleColumnsKey={visibleColumnsKey}
openSheet={openSheet}
/>
)
@@ -442,6 +582,9 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
virtualRow,
isSelected,
rowSelection: _rowSelection,
// Column visibility doesn't change the row object identity, so this prop exists only
// to force a re-render (and a fresh row.getVisibleCells() read) when columns are toggled.
visibleColumnsKey: _visibleColumnsKey,
openSheet,
}: {
row: Row<NetworkMonitorRecord>
@@ -449,6 +592,7 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
isSelected: boolean
// Menus depend on the entire selection, including changes to other rows.
rowSelection: RowSelectionState
visibleColumnsKey: string
openSheet: (monitor: NetworkMonitorRecord) => void
}) {
const system = useStore($allSystemsById)[row.original.system]

View File

@@ -68,6 +68,7 @@
--breakpoint-xs: 26.6rem;
--breakpoint-450: 28rem;
--breakpoint-md-lg: 53rem;
--breakpoint-2xl: 90rem;
--radius-sm: calc(var(--radius) - 4px);
@@ -117,6 +118,7 @@
}
@layer utilities {
/* Fonts */
@supports (font-variation-settings: normal) {
:root {
@@ -147,11 +149,11 @@
button {
cursor: pointer;
}
/* cosmetic patch for half pixel gap in table headers when scrolling content shows at top */
thead.sticky:before {
content: "";
@apply absolute -top-2 left-0 w-full h-4 bg-table-header z-50
@apply absolute -top-2 left-0 w-full h-4 bg-table-header z-50
}
}
@@ -172,6 +174,7 @@
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@@ -188,4 +191,4 @@
.recharts-yAxis {
@apply tabular-nums;
}
}

View File

@@ -377,6 +377,9 @@ export interface UserSettings {
statusFilter?: "all" | "up" | "down" | "paused" | "pending"
viewMode?: "table" | "grid"
sortMode?: Array<{ id: string; desc: boolean }>
monitorCols?: Record<string, boolean>
monitorSortMode?: Array<{ id: string; desc: boolean }>
monitorSortModeSystem?: Array<{ id: string; desc: boolean }>
grid?: boolean
displayMode?: "default" | "tabs"
}