From b1270e341cd2ffadbfd31ba93f9404e0b4e32f90 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Wed, 23 Sep 2026 19:47:42 +0200 Subject: [PATCH] feat(ui): Add view settings to network monitors table (#2396) --- .../network-monitors-table/monitor-dialog.tsx | 11 +- .../network-monitors-columns.tsx | 16 ++ .../network-monitors-table.tsx | 176 ++++++++++++++++-- internal/site/src/index.css | 9 +- internal/site/src/types.d.ts | 3 + 5 files changed, 193 insertions(+), 22 deletions(-) diff --git a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx index a227ef20..565c0eb8 100644 --- a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx +++ b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx @@ -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 <>
diff --git a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx index 92d25aba..f0c4af0d 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx @@ -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 { + label?: string + } +} + const protocolColors: Record = { 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 }) => , @@ -164,6 +172,7 @@ export function getMonitorColumns( }, { id: "protocol", + meta: { label: t`Protocol` }, accessorFn: (record) => record.protocol, header: ({ column }) => , cell: ({ getValue }) => { @@ -173,6 +182,7 @@ export function getMonitorColumns( }, { id: "interval", + meta: { label: t`Interval` }, accessorFn: (record) => record.interval, invertSorting: true, header: ({ column }) => , @@ -180,6 +190,7 @@ export function getMonitorColumns( }, { id: "res", + meta: { label: t`Response` }, accessorFn: (record) => record.res, invertSorting: true, header: ({ column }) => , @@ -187,6 +198,7 @@ export function getMonitorColumns( }, { id: "res1h", + meta: { label: t`Avg 1h` }, accessorFn: (record) => record.resAvg1h, invertSorting: true, header: ({ column }) => , @@ -194,6 +206,7 @@ export function getMonitorColumns( }, { id: "max1h", + meta: { label: t`Max 1h` }, accessorFn: (record) => record.resMax1h, invertSorting: true, header: ({ column }) => , @@ -201,6 +214,7 @@ export function getMonitorColumns( }, { id: "min1h", + meta: { label: t`Min 1h` }, accessorFn: (record) => record.resMin1h, invertSorting: true, header: ({ column }) => , @@ -208,6 +222,7 @@ export function getMonitorColumns( }, { id: "loss", + meta: { label: t`Loss 1h` }, accessorFn: (record) => record.loss1h, invertSorting: true, header: ({ column }) => , @@ -236,6 +251,7 @@ export function getMonitorColumns( }, { id: "updated", + meta: { label: t`Updated` }, invertSorting: true, accessorFn: (record) => record.updated, header: ({ column }) => , diff --git a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx index 4f381795..78f81efb 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx @@ -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( - `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( + () => + $userSettings.get()[sortSettingsKey] ?? + JSON.parse(sessionStorage.getItem(sortStorageKey) || "null") ?? [ + { id: systemId ? "target" : "system", desc: false }, + ] ) const [columnFilters, setColumnFilters] = useState([]) - const [columnVisibility, setColumnVisibility] = useState({}) + const [columnVisibility, setColumnVisibility] = useState( + () => $userSettings.get().monitorCols ?? JSON.parse(localStorage.getItem("besz-monitor-cols") || "{}") + ) const [rowSelection, setRowSelection] = useState({}) 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()) + 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 ( -
+
Network Monitors @@ -250,14 +318,14 @@ export default function NetworkMonitorsTableNew({ Response time monitoring from agents.
-
+
{monitors.length > 0 && ( -
+
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 && ( + + +
+
+ + + Sort By + + +
+ {table.getAllColumns().map((column) => { + if (!column.getCanSort()) return null + let Icon = + if (sorting[0]?.id === column.id) { + Icon = sorting[0]?.desc ? ( + + ) : ( + + ) + } + return ( + { + 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} + + ) + })} +
+
+
+ + + Visible Fields + + +
+ {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + e.preventDefault()} + checked={column.getIsVisible()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {column.columnDef.meta?.label ?? column.id} + + ))} +
+
+
+
+ {canManageMonitors ? : null} {canManageMonitors ? ( @@ -337,12 +474,14 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({ table, rows, colLength, + visibleColumnsKey, rowSelection, isLoading, }: { table: TableType rows: Row[] 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 @@ -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] diff --git a/internal/site/src/index.css b/internal/site/src/index.css index d9e26c9d..d3f34f09 100644 --- a/internal/site/src/index.css +++ b/internal/site/src/index.css @@ -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; -} +} \ No newline at end of file diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index e39997a7..a8c2a3db 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -377,6 +377,9 @@ export interface UserSettings { statusFilter?: "all" | "up" | "down" | "paused" | "pending" viewMode?: "table" | "grid" sortMode?: Array<{ id: string; desc: boolean }> + monitorCols?: Record + monitorSortMode?: Array<{ id: string; desc: boolean }> + monitorSortModeSystem?: Array<{ id: string; desc: boolean }> grid?: boolean displayMode?: "default" | "tabs" }